feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
New router app/api/doc_center.py: GET /api/cma/docs (list, ?with_content) and GET /api/cma/docs/{slug}; runtime disk read, role-gated (ceo/finance/business/it), no existing API semantics changed.
Content dir /srv/hermes/shared/cma-content (docs/*.md + categories.json). Placed outside the repo on purpose: the deploy tree equals the committed tree (deploy.sh checkout -f + clean -fdx), so in-repo content would be reset on every deploy and content edits would fail the deploy-tree dirty check.
CMAKnowledge.vue: 20 hardcoded items migrated to md, page now fetches at runtime and renders md with the existing marked dep (no new deps); table borders restored via :deep() since scoped styles never reach v-html content.
Tests: backend/tests/test_doc_center_20260924.py (20 cases). Full suite 1448 passed (baseline 1428 + 20).
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""CMA 文档中心 API —— md 单一来源(任务 yanxue-20260924-cma-docs-center-poc)
|
||||
|
||||
口径(任总 2026-09-24 00:54 拍板):**指标进 UI、结论出 md、机器走 API/MCP。**
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. 内容单一来源 = 运行期目录(默认 `/srv/hermes/shared/cma-content`,可用 `CMA_DOCS_DIR` 覆盖),
|
||||
一篇一个 `docs/<slug>.md`,front-matter 带 title/category/category_name/icon/summary/order。
|
||||
2. **故意不放在仓库内**:CMA 部署链是「部署树 = origin/main 已提交树」(deploy.sh `[0]/[1]`
|
||||
`git checkout -f` + `git clean -fdx`):仓内内容目录会被每次部署重置,且内容改动会让
|
||||
部署树出现「未提交修改」而被 `[1.1]` 判失败、**阻断整个部署**。放仓外 ⇒ 改内容不需要
|
||||
git/部署/重启,dev 树与部署树天然共读同一份。
|
||||
3. **本模块每次请求实时读盘,无缓存、不做 build-time 打包** ⇒ 新增/修改一个 md 后,
|
||||
页面刷新即变,前端无需改码、无需 rebuild。
|
||||
4. 只**新增**端点,不改任何既有接口语义;内容为系统级共享知识(与 `knowledge_articles`
|
||||
`entity_id IS NULL` 的全局共享同族),不做账套分区,故不接 `entity_id`。
|
||||
5. 写口 = 文件系统(Bot/Agent 直接写 md),**不提供写 API**,避免出现"第二来源"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.auth_middleware import require_role
|
||||
|
||||
logger = logging.getLogger("cma.doc_center_api")
|
||||
|
||||
DEFAULT_DOCS_DIR = "/srv/hermes/shared/cma-content"
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/docs",
|
||||
tags=["文档中心"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── 内容目录(运行期读盘)──────────────────────────
|
||||
|
||||
|
||||
def content_root() -> Path:
|
||||
"""内容根目录(每次调用实时解析,便于测试/迁移换目录)"""
|
||||
return Path(os.environ.get("CMA_DOCS_DIR", DEFAULT_DOCS_DIR))
|
||||
|
||||
|
||||
def docs_dir() -> Path:
|
||||
return content_root() / "docs"
|
||||
|
||||
|
||||
def _parse_scalar(raw: str):
|
||||
raw = raw.strip()
|
||||
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
|
||||
body = raw[1:-1]
|
||||
return body.replace('\\"', '"').replace("\\\\", "\\")
|
||||
if re.fullmatch(r"-?\d+", raw or ""):
|
||||
return int(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def parse_front_matter(text: str) -> Optional[dict]:
|
||||
"""极简 YAML front-matter 解析(只支持 `key: value` 标量,避免引入新依赖)"""
|
||||
if not text.startswith("---"):
|
||||
return None
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return None
|
||||
meta = {}
|
||||
for line in text[3:end].splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
key, sep, val = line.partition(":")
|
||||
if not sep:
|
||||
continue
|
||||
meta[key.strip()] = _parse_scalar(val)
|
||||
body = text[end + 4:]
|
||||
if body.startswith("\n"):
|
||||
body = body[1:]
|
||||
return {"meta": meta, "body": body}
|
||||
|
||||
|
||||
def _category_registry() -> list:
|
||||
"""分类注册表(categories.json):决定分类顺序与展示名/图标"""
|
||||
path = content_root() / "categories.json"
|
||||
cats = []
|
||||
if path.is_file():
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
cats = list(data.get("categories") or [])
|
||||
except Exception as exc: # 注册表损坏不阻断读内容,只在响应里暴露
|
||||
logger.warning("categories.json 解析失败: %s", exc)
|
||||
return []
|
||||
return cats
|
||||
|
||||
|
||||
def _read_doc(path: Path):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return None, f"读取失败: {exc}"
|
||||
parsed = parse_front_matter(text)
|
||||
if parsed is None:
|
||||
return None, "缺少 front-matter(--- 包裹的 title/category/... )"
|
||||
meta = parsed["meta"]
|
||||
for field in ("title", "category", "summary"):
|
||||
if not str(meta.get(field, "")).strip():
|
||||
return None, f"front-matter 缺必填字段: {field}"
|
||||
stat = path.stat()
|
||||
return {
|
||||
"slug": path.stem,
|
||||
"title": str(meta["title"]),
|
||||
"category": str(meta["category"]),
|
||||
"summary": str(meta["summary"]),
|
||||
"order": int(meta.get("order") or 0),
|
||||
"icon": str(meta.get("icon") or ""),
|
||||
"category_name": str(meta.get("category_name") or ""),
|
||||
"bytes": stat.st_size,
|
||||
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"content": parsed["body"],
|
||||
}, None
|
||||
|
||||
|
||||
def load_docs() -> tuple:
|
||||
"""实时读盘:返回 (docs, skipped, available)。docs 已按 (category order, doc order, slug) 排序"""
|
||||
d = docs_dir()
|
||||
if not d.is_dir():
|
||||
return [], [], False
|
||||
cats = _category_registry()
|
||||
cat_order = {c.get("key"): int(c.get("order") or 0) for c in cats}
|
||||
docs, skipped = [], []
|
||||
for path in sorted(d.glob("*.md")):
|
||||
if not SLUG_RE.match(path.stem):
|
||||
skipped.append({"file": path.name, "reason": "文件名不符合 slug 规则 ^[a-z0-9][a-z0-9-]{0,63}$"})
|
||||
continue
|
||||
doc, err = _read_doc(path)
|
||||
if err:
|
||||
skipped.append({"file": path.name, "reason": err})
|
||||
continue
|
||||
docs.append(doc)
|
||||
docs.sort(key=lambda x: (cat_order.get(x["category"], 10_000), x["category"], x["order"], x["slug"]))
|
||||
return docs, skipped, True
|
||||
|
||||
|
||||
def _categories_view(docs: list) -> list:
|
||||
"""分类视图:按注册表 `order` 升序(未登记分类补在末尾,不丢内容)"""
|
||||
cats = _category_registry()
|
||||
seen, out, seq = set(), [], 0
|
||||
for c in cats:
|
||||
key = c.get("key")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
seq += 1
|
||||
out.append({
|
||||
"key": key,
|
||||
"name": c.get("name") or key,
|
||||
"icon": c.get("icon") or "",
|
||||
"order": int(c.get("order") or 0),
|
||||
"_seq": seq,
|
||||
"count": len([d for d in docs if d["category"] == key]),
|
||||
})
|
||||
for d in docs:
|
||||
if d["category"] in seen:
|
||||
continue
|
||||
seen.add(d["category"])
|
||||
seq += 1
|
||||
out.append({
|
||||
"key": d["category"],
|
||||
"name": d["category_name"] or d["category"],
|
||||
"icon": d["icon"] or "",
|
||||
"order": 10_000,
|
||||
"_seq": seq,
|
||||
"count": len([x for x in docs if x["category"] == d["category"]]),
|
||||
})
|
||||
out.sort(key=lambda c: (c["order"], c["_seq"]))
|
||||
for c in out:
|
||||
c.pop("_seq")
|
||||
return out
|
||||
|
||||
|
||||
def _meta_only(doc: dict) -> dict:
|
||||
return {k: v for k, v in doc.items() if k != "content"}
|
||||
|
||||
|
||||
# ────────────────────────── 端点(只新增)──────────────────────────
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_docs(
|
||||
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
|
||||
category: Optional[str] = Query(None, description="按分类 key 过滤"),
|
||||
):
|
||||
"""文档列表(实时读盘)。
|
||||
|
||||
- `data`:按「分类顺序 → 篇内 order → slug」排序的文档元数据(`with_content=true` 时含正文)
|
||||
- `categories`:分类视图(name/icon/count),前端按它分组渲染
|
||||
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
|
||||
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
|
||||
"""
|
||||
docs, skipped, available = load_docs()
|
||||
all_docs = docs
|
||||
if category:
|
||||
docs = [d for d in docs if d["category"] == category]
|
||||
return {
|
||||
"count": len(docs),
|
||||
"available": available,
|
||||
"source": str(docs_dir()),
|
||||
"categories": _categories_view(all_docs), # 分类视图按全量内容统计(不受 category 过滤影响)
|
||||
"data": docs if with_content else [_meta_only(d) for d in docs],
|
||||
"skipped": skipped,
|
||||
"format": "markdown",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{slug}")
|
||||
def get_doc(slug: str):
|
||||
"""单篇文档(含 md 正文,实时读盘)"""
|
||||
if not SLUG_RE.match(slug or ""):
|
||||
raise HTTPException(400, "无效的文档标识(只允许 [a-z0-9-],1-64 位,字母数字开头)")
|
||||
path = docs_dir() / f"{slug}.md"
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, "文档不存在")
|
||||
doc, err = _read_doc(path)
|
||||
if err:
|
||||
raise HTTPException(422, f"文档格式不合法: {err}")
|
||||
return doc
|
||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db, get_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth, require_auth_401
|
||||
@@ -100,6 +100,7 @@ app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
||||
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
Reference in New Issue
Block a user