feat(docs-center): report reading section (type=report) on /api/cma/docs
Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).
- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild
- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected
- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link
- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)
- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps
(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
This commit is contained in:
@@ -25,9 +25,14 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.auth_middleware import require_role
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.database import get_db
|
||||
from app.deps import get_session_entity
|
||||
from app.models import Entity, User, UserEntity
|
||||
|
||||
logger = logging.getLogger("cma.doc_center_api")
|
||||
|
||||
@@ -194,8 +199,18 @@ def _meta_only(doc: dict) -> dict:
|
||||
|
||||
@router.get("")
|
||||
def list_docs(
|
||||
request: Request,
|
||||
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
|
||||
category: Optional[str] = Query(None, description="按分类 key 过滤"),
|
||||
type: str = Query("knowledge", description="内容类型:knowledge(缺省,知识库)/ report(报告解读)"),
|
||||
domain: Optional[str] = Query(None, description="type=report:按域过滤"),
|
||||
entity: Optional[str] = Query(None, description="type=report:按账套归属过滤"),
|
||||
kind: Optional[str] = Query(None, description="type=report:按报告类型过滤"),
|
||||
date_from: Optional[str] = Query(None, description="type=report:起始日(含)YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="type=report:结束日(含)YYYY-MM-DD"),
|
||||
diagnose: bool = Query(False, description="type=report:附带被排除件明细(自诊断)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""文档列表(实时读盘)。
|
||||
|
||||
@@ -204,6 +219,13 @@ def list_docs(
|
||||
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
|
||||
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
|
||||
"""
|
||||
if str(type or "").strip().lower() == "report":
|
||||
return _list_reports(
|
||||
request=request, db=db, user=current_user,
|
||||
domain=domain, entity=entity, kind=kind,
|
||||
date_from=date_from, date_to=date_to, diagnose=bool(diagnose),
|
||||
)
|
||||
|
||||
docs, skipped, available = load_docs()
|
||||
all_docs = docs
|
||||
if category:
|
||||
@@ -231,3 +253,303 @@ def get_doc(slug: str):
|
||||
if err:
|
||||
raise HTTPException(422, f"文档格式不合法: {err}")
|
||||
return doc
|
||||
|
||||
|
||||
# ══════════════════════ 报告解读(type=report)══════════════════════════
|
||||
# 口径(规范 /srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md,本单按其实现):
|
||||
# ① 进门标记 = front-matter + `conclusion` 非空(§二「front-matter 本身就是进门标记」
|
||||
# + §三「conclusion 是唯一硬字段」):缺任一 ⇒ **不进列表、不报错**。
|
||||
# 回执/审计/施工/工作件天然不带 front-matter ⇒ 天然不进;**不搬家、不新建内容目录**。
|
||||
# ② 规范 §四 另列 title/date/domain/sources 为必填:缺失**不拒收**,标 `incomplete` 并按
|
||||
# 兜底值展示(若按字面拒收,页面只能是空壳,与 §一「无体之用」的立意相悖)。
|
||||
# ③ 只**新增** type=report 分支与新端点 /report/{rid};type 缺省走知识库分支,语义逐字不变。
|
||||
# ④ 权限:按 entity + CMA 既有 roles 分区 —— ceo/it 可跨账套读结论,其余角色只看本账套 + 集团/通用。
|
||||
# ⑤ 读口 = 文件系统(实时读盘,改一篇 md 刷新即生效),无写 API(与知识库同口径,避免第二来源)。
|
||||
|
||||
WISDOM_REPORTS_DIR = "/srv/hermes/wisdom/_reports"
|
||||
PROFILES_ROOT = "/root/.hermes/profiles"
|
||||
REPORT_SPEC_PATH = "/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md"
|
||||
REPORT_HEAD_BYTES = 8192
|
||||
REPORT_DIRS_ENV = "CMA_REPORTS_DIRS"
|
||||
|
||||
DOMAIN_LABELS = {
|
||||
"finance": "财务", "customer": "客户", "process": "流程", "learning": "学习成长",
|
||||
"tech": "技术", "ops": "运维", "market": "市场", "gov": "政务",
|
||||
}
|
||||
KIND_LABELS = {"report": "报告", "analysis": "分析", "brief": "摘要", "warning": "预警"}
|
||||
GLOBAL_ENTITIES = ("集团", "通用")
|
||||
CROSS_ENTITY_ROLES = ("ceo", "it")
|
||||
ENTITY_NAME_FALLBACK = {1: "酣客", 2: "博海"}
|
||||
_SAFE_STEM_RE = re.compile(r"^[^/\\\x00]{1,160}$")
|
||||
|
||||
|
||||
def _as_list(val) -> list:
|
||||
"""front-matter 值 → 列表(支持行内数组 [a, b] / 逗号分隔 / 单值)"""
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return [str(v).strip() for v in val if str(v).strip()]
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return []
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
s = s[1:-1]
|
||||
return [p.strip().strip("\"'") for p in s.split(",") if p.strip()]
|
||||
|
||||
|
||||
def _kpi_link(raw: str) -> dict:
|
||||
"""kpi_refs 项 → 一跳链(数字 / kpi:<id> → 指标详情页;其余 → 指标字典页关键词)"""
|
||||
v = str(raw).strip()
|
||||
m = re.fullmatch(r"(?:kpi[:/]?)?(\d{1,9})", v, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return {"raw": v, "url": f"/kpis/{m.group(1)}", "resolved": "detail"}
|
||||
return {"raw": v, "url": "/kpis?keyword=" + quote(v), "resolved": "list"}
|
||||
|
||||
|
||||
def reports_dirs() -> list:
|
||||
"""[(source_key, Path)] —— 既有报告落点的发现结果。
|
||||
|
||||
env `CMA_REPORTS_DIRS` 覆盖优先(`:`/`;` 分隔,条目可写 `key=path`,供测试与隔离实测);
|
||||
缺省 = 自动发现:`/srv/hermes/wisdom/_reports` + 各 profile 的 `pending-tasks/_reports`
|
||||
(按 resolve 后路径去重,避免 pending-tasks 为符号链接时重复收录)。
|
||||
"""
|
||||
raw = (os.environ.get(REPORT_DIRS_ENV) or "").strip()
|
||||
out, seen = [], set()
|
||||
if raw:
|
||||
for i, item in enumerate(x for x in re.split(r"[:;]", raw) if x.strip()):
|
||||
item = item.strip()
|
||||
key, sep, path = item.partition("=")
|
||||
if not sep:
|
||||
key, path = f"src{i}", item
|
||||
out.append(((key or f"src{i}").strip(), Path(path)))
|
||||
return out
|
||||
cands = []
|
||||
wisdom = Path(WISDOM_REPORTS_DIR)
|
||||
if wisdom.is_dir():
|
||||
cands.append(("wisdom", wisdom))
|
||||
for d in sorted(Path(PROFILES_ROOT).glob("*/pending-tasks/_reports")):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
prof = d.parent.parent.name
|
||||
cands.append((prof[6:] if prof.startswith("wecom-") else prof, d))
|
||||
for key, path in cands:
|
||||
try:
|
||||
rp = path.resolve()
|
||||
except OSError:
|
||||
rp = path
|
||||
if rp in seen:
|
||||
continue
|
||||
seen.add(rp)
|
||||
out.append((key, path))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_report_text(text: str, path: Path, source_key: str):
|
||||
"""报告件 → (record, None) 或 (None, 排除码)。
|
||||
|
||||
排除码:`no_frontmatter`(无 --- 进门标记)/ `missing_conclusion`(缺唯一硬字段)/
|
||||
`unreadable:<err>`。**排除即静默**(不进列表、不报错),这正是负对照的判据。
|
||||
"""
|
||||
parsed = parse_front_matter(text) if text.startswith("---") else None
|
||||
if parsed is None:
|
||||
return None, "no_frontmatter"
|
||||
meta = parsed["meta"]
|
||||
if not str(meta.get("conclusion") or "").strip():
|
||||
return None, "missing_conclusion"
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError as exc:
|
||||
return None, f"unreadable:{exc}"
|
||||
refs = _as_list(meta.get("kpi_refs"))
|
||||
rec = {
|
||||
"id": f"{source_key}:{path.stem}",
|
||||
"title": str(meta.get("title") or "").strip() or path.stem,
|
||||
"date": (str(meta.get("date") or "").strip()
|
||||
or datetime.fromtimestamp(stat.st_mtime).date().isoformat()),
|
||||
"domain": str(meta.get("domain") or "").strip() or "未分类",
|
||||
"entity": str(meta.get("entity") or "").strip(),
|
||||
"kind": str(meta.get("kind") or "").strip() or "report",
|
||||
"conclusion": str(meta["conclusion"]).strip(),
|
||||
"sources": _as_list(meta.get("sources")),
|
||||
"impact": str(meta.get("impact") or "").strip(),
|
||||
"decisions": _as_list(meta.get("decisions")),
|
||||
"kpi_refs": refs,
|
||||
"kpi_links": [_kpi_link(r) for r in refs],
|
||||
"file": path.name,
|
||||
"source": source_key,
|
||||
"path": str(path),
|
||||
"bytes": stat.st_size,
|
||||
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"incomplete": [f for f in ("title", "date", "domain", "sources")
|
||||
if not str(meta.get(f) or "").strip()],
|
||||
"content": parsed["body"],
|
||||
}
|
||||
return rec, None
|
||||
|
||||
|
||||
def _read_report(path: Path, source_key: str, full: bool = False):
|
||||
try:
|
||||
if full:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
with open(path, "rb") as fh:
|
||||
text = fh.read(REPORT_HEAD_BYTES).decode("utf-8", errors="replace")
|
||||
if text.startswith("---") and text.find("\n---", 3) < 0:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return None, f"unreadable:{exc}"
|
||||
return _parse_report_text(text, path, source_key)
|
||||
|
||||
|
||||
def _entity_scope(db: Session, user: User, request: Request) -> dict:
|
||||
"""当前会话的可见范围:账套 id / 账套名称集合 / 是否跨账套角色"""
|
||||
entity_id = get_session_entity(request)
|
||||
if entity_id is None:
|
||||
rel = db.query(UserEntity).filter(UserEntity.user_id == user.id).first()
|
||||
entity_id = rel.entity_id if rel else None
|
||||
names = set()
|
||||
if entity_id is not None:
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if ent:
|
||||
names |= {str(ent.name or "").strip(), str(ent.short_name or "").strip()}
|
||||
fb = ENTITY_NAME_FALLBACK.get(int(entity_id))
|
||||
if fb:
|
||||
names.add(fb)
|
||||
names.discard("")
|
||||
return {
|
||||
"role": user.role,
|
||||
"entity_id": entity_id,
|
||||
"entity_names": sorted(names),
|
||||
"cross_entity": user.role in CROSS_ENTITY_ROLES,
|
||||
}
|
||||
|
||||
|
||||
def _report_visible(rec: dict, scope: dict) -> bool:
|
||||
"""按 entity 分区:跨账套角色(ceo/it)→ 全可见;其余 → 本账套名 + 集团/通用。
|
||||
|
||||
未标 entity 的件按「通用」处理(entity 属选填字段,不设门槛);
|
||||
显式 entity 数字与会话账套一致也算命中(兼容 `entity: 2` 写法)。
|
||||
"""
|
||||
if scope["cross_entity"]:
|
||||
return True
|
||||
e = str(rec.get("entity") or "").strip()
|
||||
if not e or e in GLOBAL_ENTITIES:
|
||||
return True
|
||||
if e in set(scope["entity_names"]):
|
||||
return True
|
||||
if e.isdigit() and scope["entity_id"] is not None and int(e) == int(scope["entity_id"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _facet(items: list, field: str, labels: dict) -> list:
|
||||
cnt = {}
|
||||
for r in items:
|
||||
k = str(r.get(field) or "").strip() or "(未标)"
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
return [{"key": k, "name": labels.get(k, k), "count": v}
|
||||
for k, v in sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))]
|
||||
|
||||
|
||||
def _list_reports(request: Request, db: Session, user: User,
|
||||
domain=None, entity=None, kind=None,
|
||||
date_from=None, date_to=None, diagnose: bool = False) -> dict:
|
||||
"""报告解读列表:扫描既有报告落点 → 只收带 front-matter + conclusion 的件 → 按 entity 分区 → 筛选"""
|
||||
scope = _entity_scope(db, user, request)
|
||||
included, sources = [], []
|
||||
excluded = {"no_frontmatter": 0, "missing_conclusion": 0, "other": 0}
|
||||
detail = []
|
||||
for key, base in reports_dirs():
|
||||
exists = base.is_dir()
|
||||
found = 0
|
||||
if exists:
|
||||
for path in sorted(base.glob("*.md")):
|
||||
found += 1
|
||||
rec, reason = _read_report(path, key)
|
||||
if reason:
|
||||
if reason == "no_frontmatter":
|
||||
excluded["no_frontmatter"] += 1
|
||||
elif reason == "missing_conclusion":
|
||||
excluded["missing_conclusion"] += 1
|
||||
else:
|
||||
excluded["other"] += 1
|
||||
if diagnose and len(detail) < 200:
|
||||
detail.append({"file": path.name, "source": key, "reason": reason})
|
||||
continue
|
||||
if not _report_visible(rec, scope):
|
||||
continue
|
||||
included.append(rec)
|
||||
sources.append({"key": key, "path": str(base),
|
||||
"exists": exists, "md_count": found})
|
||||
visible_total = len(included)
|
||||
|
||||
def _keep(r: dict) -> bool:
|
||||
if domain and r["domain"] != domain:
|
||||
return False
|
||||
if entity and r["entity"] != entity:
|
||||
return False
|
||||
if kind and r["kind"] != kind:
|
||||
return False
|
||||
if date_from and (r["date"] or "") < date_from:
|
||||
return False
|
||||
if date_to and (r["date"] or "") > date_to:
|
||||
return False
|
||||
return True
|
||||
|
||||
rows = [r for r in included if _keep(r)]
|
||||
rows.sort(key=lambda r: (r["date"] or "", r["title"]), reverse=True)
|
||||
dates = sorted({r["date"] for r in included if r["date"]})
|
||||
data = []
|
||||
for r in rows:
|
||||
r = dict(r)
|
||||
r.pop("content", None)
|
||||
data.append(r)
|
||||
payload = {
|
||||
"type": "report",
|
||||
"count": len(data),
|
||||
"available": any(s["exists"] for s in sources),
|
||||
"scope": scope,
|
||||
"sources": sources,
|
||||
"excluded": excluded,
|
||||
"facets": {
|
||||
"domain": _facet(included, "domain", DOMAIN_LABELS),
|
||||
"entity": _facet(included, "entity", {}),
|
||||
"kind": _facet(included, "kind", KIND_LABELS),
|
||||
"date": {"min": dates[0] if dates else "", "max": dates[-1] if dates else ""},
|
||||
},
|
||||
"totals": {"visible": visible_total, "filtered_out": visible_total - len(data)},
|
||||
"data": data,
|
||||
"format": "markdown",
|
||||
"spec": REPORT_SPEC_PATH,
|
||||
}
|
||||
if diagnose:
|
||||
payload["excluded_detail"] = detail
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/report/{rid}")
|
||||
def get_report(rid: str, request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth)):
|
||||
"""单篇报告(含 md 正文,实时读盘)。`rid` = `<source_key>:<文件名主干>`"""
|
||||
key, sep, stem = (rid or "").partition(":")
|
||||
if not sep or not key:
|
||||
raise HTTPException(400, "无效的报告标识(应为 <source>:<文件名>)")
|
||||
if not _SAFE_STEM_RE.match(stem) or stem.startswith(".") or ".." in stem:
|
||||
raise HTTPException(400, "无效的报告标识(文件名非法)")
|
||||
base = {k: p for k, p in reports_dirs()}.get(key)
|
||||
if base is None:
|
||||
raise HTTPException(404, "报告不存在")
|
||||
path = base / f"{stem}.md"
|
||||
if path.parent != base or not path.is_file():
|
||||
raise HTTPException(404, "报告不存在")
|
||||
rec, reason = _read_report(path, key, full=True)
|
||||
if reason:
|
||||
raise HTTPException(422, f"报告格式不合法: {reason}")
|
||||
scope = _entity_scope(db, current_user, request)
|
||||
if not _report_visible(rec, scope):
|
||||
raise HTTPException(
|
||||
403,
|
||||
f"无权访问企业 {rec.get('entity') or '(未标注)'} 的报告(当前账套: {scope.get('entity_names') or '未绑定'})",
|
||||
)
|
||||
return rec
|
||||
|
||||
Reference in New Issue
Block a user