From 2cff58ca6a2ee26fd2f0b115baa4df9d158e288d Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Thu, 24 Sep 2026 01:36:22 +0800 Subject: [PATCH] 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) --- backend/app/api/doc_center.py | 326 +++++++++++++++++- .../tests/test_doc_center_reports_20260924.py | 311 +++++++++++++++++ frontend/src/views/CMAKnowledge.vue | 174 +++++++++- 3 files changed, 808 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_doc_center_reports_20260924.py diff --git a/backend/app/api/doc_center.py b/backend/app/api/doc_center.py index d21e496e..b7205abc 100644 --- a/backend/app/api/doc_center.py +++ b/backend/app/api/doc_center.py @@ -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: → 指标详情页;其余 → 指标字典页关键词)""" + 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:`。**排除即静默**(不进列表、不报错),这正是负对照的判据。 + """ + 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` = `:<文件名主干>`""" + key, sep, stem = (rid or "").partition(":") + if not sep or not key: + raise HTTPException(400, "无效的报告标识(应为 :<文件名>)") + 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 diff --git a/backend/tests/test_doc_center_reports_20260924.py b/backend/tests/test_doc_center_reports_20260924.py new file mode 100644 index 00000000..cdf18117 --- /dev/null +++ b/backend/tests/test_doc_center_reports_20260924.py @@ -0,0 +1,311 @@ +"""报告解读(type=report)端点测试 —— 任务 yanxue-20260924-cma-report-source + +规范:/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md + +覆盖: +1. 正样本:带 front-matter + `conclusion` ⇒ 进列表(字段完整 / kpi_links 一跳链) +2. 负对照 a:无 front-matter 的回执件 ⇒ **不进列表、不报错**(excluded.no_frontmatter 计数) +3. 负对照 b:有 front-matter 但缺 `conclusion` ⇒ 不进列表(excluded.missing_conclusion) +4. 软必填:title/date/domain/sources 缺失 ⇒ 仍收录 + 标 incomplete + 兜底值 +5. 筛选:domain / entity / kind / date_from+date_to +6. 权限:非本账套 entity 的报告不可见(列表不含 + 详情 403);ceo/it 跨账套可见 +7. 详情:正文 200 / 未知 rid 404 / 非法 rid 400(路径穿越) +8. 核心判据:改 md 的 conclusion ⇒ 下一请求即变(运行期读盘,不改前端、不 rebuild) +9. 回归:`type` 缺省 = 知识库分支(既有结构 categories/skipped/format 逐字不变) +10. 落点缺失 ⇒ available=false 且 count=0,不 500 +""" +import json +import os + +import pytest +from fastapi.testclient import TestClient + +from tests.conftest import ( + TEST_USER_PASSWORD, auth_header, create_test_user, get_token_for_user, +) + +REPORTS_ENV = "CMA_REPORTS_DIRS" +DOCS_ENV = "CMA_DOCS_DIR" + + +@pytest.fixture +def token(client: TestClient, db) -> str: + """默认会话 = ceo 角色 + entity_id=1(酣客)""" + create_test_user(db) + return get_token_for_user(client) + + +def write_report(root, name, *, title="样例报告", date="2026-09-24", domain="tech", + entity="通用", kind="report", conclusion="一句话结论(≤60字)", + sources="srv/hermes/wisdom/_reports/x.md", extra="", body="正文段落。\n"): + path = root / f"{name}.md" + lines = ["---"] + if title is not None: + lines.append(f"title: {title}") + if date is not None: + lines.append(f"date: {date}") + if domain is not None: + lines.append(f"domain: {domain}") + if entity is not None: + lines.append(f"entity: {entity}") + if kind is not None: + lines.append(f"kind: {kind}") + if conclusion is not None: + lines.append(f"conclusion: {conclusion}") + if sources is not None: + lines.append(f"sources: {sources}") + if extra: + lines.append(extra) + lines.append("---") + path.write_text("\n".join(lines) + "\n" + body, encoding="utf-8") + return path + + +@pytest.fixture +def reports_dir(tmp_path, monkeypatch): + """隔离的临时报告落点(不动任何真实落点)""" + root = tmp_path / "wisdom-reports" + root.mkdir(parents=True) + monkeypatch.setenv(REPORTS_ENV, f"wisdom={root}") + return root + + +@pytest.fixture +def docs_dir(tmp_path, monkeypatch): + root = tmp_path / "cma-content" + (root / "docs").mkdir(parents=True) + (root / "categories.json").write_text(json.dumps({"categories": [ + {"key": "term", "name": "术语解释", "icon": "📖", "order": 10}, + ]}, ensure_ascii=False), encoding="utf-8") + monkeypatch.setenv(DOCS_ENV, str(root)) + return root + + +@pytest.fixture +def two_entities(client, db): + """额外造一个 active 账套(博海,id=2)—— 权限分区用例用""" + from app.models import Entity + + if not db.query(Entity).filter(Entity.id == 2).first(): + db.add(Entity(id=2, name="博海测试企业", short_name="博海", status="active")) + db.commit() + return db + + +def login_for(client, db, username, entity_id, role="finance"): + """建用户 + 只授权指定账套 + 登录,返回 token(显式 UserEntity ⇒ 不触发全网自动授权)""" + from app.models import UserEntity + + user = create_test_user(db, username=username, name=username, role=role) + db.add(UserEntity(user_id=user.id, entity_id=entity_id)) + db.commit() + r = client.post("/api/cma/auth/login", json={ + "username": username, "password": TEST_USER_PASSWORD, "entity_id": entity_id, + }) + assert r.status_code == 200, r.text + return r.json()["token"] + + +class TestReportListPositive: + def test_frontmatter_report_listed(self, client, reports_dir, token): + write_report(reports_dir, "r-ok", extra="kpi_refs: kpi:7, 应收账款周转率") + r = client.get("/api/cma/docs?type=report", headers=auth_header(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["type"] == "report" and body["count"] == 1 and body["available"] is True + rec = body["data"][0] + assert rec["id"] == "wisdom:r-ok" and rec["title"] == "样例报告" + assert rec["conclusion"] == "一句话结论(≤60字)" + assert rec["domain"] == "tech" and rec["entity"] == "通用" and rec["kind"] == "report" + assert rec["sources"] == ["srv/hermes/wisdom/_reports/x.md"] + assert rec["incomplete"] == [] + # kpi_refs 一跳链:数字/kpi: → 指标详情页;文本 → 指标字典页关键词 + links = {x["raw"]: x["url"] for x in rec["kpi_links"]} + assert links["kpi:7"] == "/kpis/7" + assert links["应收账款周转率"].startswith("/kpis?keyword=") + # 列表不带正文(与知识库同口径) + assert "content" not in rec + assert body["sources"][0]["key"] == "wisdom" and body["sources"][0]["md_count"] == 1 + + def test_facets_and_sort(self, client, reports_dir, token): + write_report(reports_dir, "r-a", title="旧", date="2026-09-20", domain="finance") + write_report(reports_dir, "r-b", title="新", date="2026-09-24", domain="tech") + body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json() + assert [d["title"] for d in body["data"]] == ["新", "旧"] # date desc + assert {f["key"] for f in body["facets"]["domain"]} == {"finance", "tech"} + assert body["facets"]["date"] == {"min": "2026-09-20", "max": "2026-09-24"} + + +class TestNegativeControls: + """★ 本单负对照判据:不进列表、不报错""" + + def test_no_frontmatter_excluded_silently(self, client, reports_dir, token): + (reports_dir / "receipt-no-front.md").write_text( + "# 回执\n\n任务已完成,无 front-matter。\n", encoding="utf-8") + write_report(reports_dir, "r-ok") + r = client.get("/api/cma/docs?type=report", headers=auth_header(token)) + assert r.status_code == 200, r.text # 不报错 + body = r.json() + assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"] + assert body["excluded"]["no_frontmatter"] == 1 + assert not any("receipt-no-front" in d["id"] for d in body["data"]) + + def test_missing_conclusion_excluded_silently(self, client, reports_dir, token): + write_report(reports_dir, "r-no-conclusion", conclusion=None) + write_report(reports_dir, "r-empty-conclusion", conclusion=" ") + write_report(reports_dir, "r-ok") + r = client.get("/api/cma/docs?type=report", headers=auth_header(token)) + assert r.status_code == 200, r.text + body = r.json() + assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"] + assert body["excluded"]["missing_conclusion"] == 2 + # 自诊断(diagnose=1)能看到被排除原因,但默认响应不含任何报错 + d = client.get("/api/cma/docs?type=report&diagnose=1", headers=auth_header(token)).json() + reasons = {x["file"]: x["reason"] for x in d["excluded_detail"]} + assert reasons["r-no-conclusion.md"] == "missing_conclusion" + + def test_excluded_report_detail_404_not_500(self, client, reports_dir, token): + (reports_dir / "receipt-no-front.md").write_text("无 front-matter\n", encoding="utf-8") + r = client.get("/api/cma/docs/report/wisdom:receipt-no-front", headers=auth_header(token)) + assert r.status_code == 422 and "no_frontmatter" in r.text + + def test_soft_required_fields_marked_incomplete(self, client, reports_dir, token): + write_report(reports_dir, "r-partial", title=None, date=None, domain=None, sources=None) + body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json() + rec = body["data"][0] + assert rec["incomplete"] == ["title", "date", "domain", "sources"] + assert rec["title"] == "r-partial" # 兜底:文件名 + assert rec["domain"] == "未分类" # 兜底:未分类 + assert rec["date"] # 兜底:文件 mtime 日 + + +class TestFilters: + def test_domain_entity_kind_date(self, client, reports_dir, token): + write_report(reports_dir, "r-fin", date="2026-09-20", domain="finance", entity="通用", kind="analysis") + write_report(reports_dir, "r-tech", date="2026-09-24", domain="tech", entity="通用", kind="warning") + base = "/api/cma/docs?type=report" + + def ids(q): + return [d["id"] for d in client.get(base + q, headers=auth_header(token)).json()["data"]] + + assert ids("&domain=finance") == ["wisdom:r-fin"] + assert ids("&kind=warning") == ["wisdom:r-tech"] + assert ids("&date_from=2026-09-22") == ["wisdom:r-tech"] + assert ids("&date_to=2026-09-22") == ["wisdom:r-fin"] + assert ids("&domain=market") == [] + + +class TestPermissionByEntity: + """权限:按 entity + CMA 既有 roles 分区(客户只看自己实体)""" + + def _seed(self, reports_dir): + write_report(reports_dir, "r-hanke", date="2026-09-24", entity="酣客") + write_report(reports_dir, "r-bohai", date="2026-09-24", entity="博海") + write_report(reports_dir, "r-common", date="2026-09-24", entity="通用") + write_report(reports_dir, "r-group", date="2026-09-24", entity="集团") + write_report(reports_dir, "r-nomark", date="2026-09-24", entity=None) + + def test_entity1_finance_sees_only_own(self, client, db, reports_dir, two_entities): + self._seed(reports_dir) + tok = login_for(client, db, "fin1", 1) + body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json() + ids = {d["id"] for d in body["data"]} + assert ids == {"wisdom:r-hanke", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"} + assert body["scope"]["cross_entity"] is False + assert body["scope"]["entity_id"] == 1 + # 详情:跨账套报告不可见 + r = client.get("/api/cma/docs/report/wisdom:r-bohai", headers=auth_header(tok)) + assert r.status_code == 403 and "无权访问企业" in r.text + # 本账套报告可取 + assert client.get("/api/cma/docs/report/wisdom:r-hanke", + headers=auth_header(tok)).status_code == 200 + + def test_entity2_finance_sees_own_only(self, client, db, reports_dir, two_entities): + self._seed(reports_dir) + tok = login_for(client, db, "fin2", 2) + body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json() + ids = {d["id"] for d in body["data"]} + assert ids == {"wisdom:r-bohai", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"} + assert client.get("/api/cma/docs/report/wisdom:r-hanke", + headers=auth_header(tok)).status_code == 403 + + def test_ceo_cross_entity(self, client, db, reports_dir, two_entities): + self._seed(reports_dir) + tok = login_for(client, db, "ceo1", 1, role="ceo") + body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json() + assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True + + def test_it_cross_entity(self, client, db, reports_dir, two_entities): + self._seed(reports_dir) + tok = login_for(client, db, "it1", 1, role="it") + body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json() + assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True + + def test_no_token_401(self, client, reports_dir): + assert client.get("/api/cma/docs?type=report").status_code == 401 + assert client.get("/api/cma/docs/report/wisdom:r-ok").status_code == 401 + + +class TestReportDetail: + def test_detail_body_and_unknown_and_traversal(self, client, reports_dir, token): + write_report(reports_dir, "r-ok", body="## 小标题\n\n结论正文。\n") + d = client.get("/api/cma/docs/report/wisdom:r-ok", headers=auth_header(token)) + assert d.status_code == 200 + rec = d.json() + assert rec["id"] == "wisdom:r-ok" and rec["content"] == "## 小标题\n\n结论正文。\n" + + assert client.get("/api/cma/docs/report/wisdom:nope", + headers=auth_header(token)).status_code == 404 + assert client.get("/api/cma/docs/report/nosuchsource:r-ok", + headers=auth_header(token)).status_code == 404 + from app.api import doc_center + from fastapi import HTTPException + + for bad in ["r-ok", "wisdom:../escape", "wisdom:a/b", "wisdom:", "wisdom:..", + "wisdom:.hidden"]: + with pytest.raises(HTTPException) as ei: + doc_center.get_report(bad, request=None, db=None, current_user=None) + assert ei.value.status_code in (400, 404), bad + + +class TestRuntimeSource: + """核心判据:运行期读盘 —— 改一篇报告的结构字段即生效,不改前端、不 rebuild""" + + def test_edit_conclusion_visible_on_next_request(self, client, reports_dir, token): + p = write_report(reports_dir, "r-ok", conclusion="旧结论") + r1 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json() + assert r1["data"][0]["conclusion"] == "旧结论" + p.write_text(p.read_text(encoding="utf-8").replace("旧结论", "新结论"), encoding="utf-8") + r2 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json() + assert r2["data"][0]["conclusion"] == "新结论" + + def test_new_report_visible_without_restart(self, client, reports_dir, token): + assert client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()["count"] == 0 + write_report(reports_dir, "r-new") + body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json() + assert body["count"] == 1 and body["data"][0]["id"] == "wisdom:r-new" + + +class TestKnowledgeRegression: + """回归:type 缺省 = 知识库分支(既有结构逐字不变)+ 报告来源不污染知识库列表""" + + def test_knowledge_branch_intact(self, client, docs_dir, reports_dir, token): + d = docs_dir / "docs" / "term-a.md" + d.write_text('---\ntitle: "A术语"\ncategory: term\ncategory_name: 术语解释\n' + 'icon: "📖"\nsummary: "摘要"\norder: 10\n---\n正文\n', encoding="utf-8") + write_report(reports_dir, "r-ok") + r = client.get("/api/cma/docs", headers=auth_header(token)) + assert r.status_code == 200 + body = r.json() + assert "categories" in body and "skipped" in body and body["format"] == "markdown" + assert [x["slug"] for x in body["data"]] == ["term-a"] + assert "type" not in body + # 报告落点的件不得混进知识库列表 + assert all(x["slug"] != "r-ok" for x in body["data"]) + + def test_missing_reports_dir_not_500(self, client, tmp_path, monkeypatch, token): + monkeypatch.setenv(REPORTS_ENV, f"wisdom={tmp_path / 'not-exist'}") + r = client.get("/api/cma/docs?type=report", headers=auth_header(token)) + assert r.status_code == 200 + body = r.json() + assert body["count"] == 0 and body["available"] is False and body["data"] == [] diff --git a/frontend/src/views/CMAKnowledge.vue b/frontend/src/views/CMAKnowledge.vue index 3cda6828..622474b3 100644 --- a/frontend/src/views/CMAKnowledge.vue +++ b/frontend/src/views/CMAKnowledge.vue @@ -50,7 +50,81 @@ - + + +
+

报告解读

+
+ + + + + + + + + + + 刷新 +
+
+ +
报告加载中...
+
{{ reportError }}
+
+ 暂无报告解读内容(只收录带 front-matter 且含 conclusion 的报告件;回执/审计/施工件按规范不进此页) +
+ +
+
+
+ {{ r.date }} + {{ r.title }} + {{ domainLabel(r.domain) }} + {{ r.entity }} + {{ kindLabel(r.kind) }} +
+
{{ r.conclusion }}
+
+ 缺 {{ r.incomplete.join('/') }} + {{ r.source }}:{{ r.file }} +
+
+
+ + + +
+
+ {{ reportDetail.date }} + {{ domainLabel(reportDetail.domain) }} + {{ reportDetail.entity }} + {{ kindLabel(reportDetail.kind) }} +
+
结论:{{ reportDetail.conclusion }}
+
影响:{{ reportDetail.impact }}
+
+ 出处:{{ reportDetail.sources.join(' · ') }} +
+
+ 待拍板:{{ reportDetail.decisions.join(' · ') }} +
+
+ 关联指标: + {{ k.raw }} ↗ +
+
+
+
+ +
+
+ +
@@ -298,6 +372,84 @@ async function loadDynamicArticles() { } } +// ── 报告解读(type=report):读结论 → 看数(kpi_refs 一跳回链指标页) ────────────── +// 口径(规范 report-frontmatter-spec-20260924):只收录带 front-matter 且含 conclusion 的报告件; +// 回执/审计/施工/工作件天然不带 front-matter ⇒ 不进本页(不搬家、不新建内容目录)。 +// 看数在 管理报表/BI报表 —— 本分区只做「读结论」,不做第二个报表中心。 +import { watch } from "vue" +import { useRouter } from "vue-router" + +const router = useRouter() +const reports = ref([]) +const reportLoading = ref(false) +const reportError = ref("") +const reportRange = ref(null) +const reportDomain = ref("") +const reportEntity = ref("") +const reportKind = ref("") +const reportFacets = ref({ domain: [], entity: [], kind: [], date: { min: "", max: "" } }) +const showReport = ref(false) +const reportDetail = ref(null) +const reportHtml = ref("") + +const REPORT_DOMAIN_LABELS: Record = { + finance: "财务", customer: "客户", process: "流程", learning: "学习成长", + tech: "技术", ops: "运维", market: "市场", gov: "政务", +} +const REPORT_KIND_LABELS: Record = { + report: "报告", analysis: "分析", brief: "摘要", warning: "预警", +} +function domainLabel(k: string) { return REPORT_DOMAIN_LABELS[k] || k || "未分类" } +function kindLabel(k: string) { return REPORT_KIND_LABELS[k] || k || "报告" } + +async function loadReports() { + reportLoading.value = true + try { + const params: any = { type: "report" } + if (reportRange.value && reportRange.value[0]) { + params.date_from = reportRange.value[0] + params.date_to = reportRange.value[1] + } + if (reportDomain.value) params.domain = reportDomain.value + if (reportEntity.value) params.entity = reportEntity.value + if (reportKind.value) params.kind = reportKind.value + const body: any = (await api.get("/docs", { params })) || {} + reports.value = Array.isArray(body.data) ? body.data : [] + reportFacets.value = body.facets || { domain: [], entity: [], kind: [], date: { min: "", max: "" } } + reportError.value = "" + } catch (e) { + reportError.value = "报告加载失败,请稍后重试" + console.error("加载报告解读失败", e) + } + reportLoading.value = false +} + +async function openReport(r: any) { + reportDetail.value = r + reportHtml.value = "" + showReport.value = true + try { + const d: any = await api.get(`/docs/report/${r.id}`) + if (d) { + reportDetail.value = d + reportHtml.value = marked(d.content || "") as string + } + } catch (e) { + ElMessage.error("报告正文加载失败") + } +} + +// 一跳回链:`kpi:` / 数字 → 指标详情页;其余 → 指标字典页(关键词) +function gotoKpi(k: any) { + const url = (k && k.url) || "/kpis" + router.push(url) +} + +// 切到「报告解读」时才拉取(不拖慢知识库首屏) +watch(activeTab, (v) => { + if (v === "reports" && reports.value.length === 0) loadReports() +}) + // 页面加载:先取 md 单一来源文档,再追加 DB 动态知识库(合并语义与改动前一致) import { onMounted } from "vue" onMounted(async () => { @@ -474,6 +626,26 @@ function backToIntro() { .detail-content :deep(th), .detail-content :deep(td) { border: 1px solid #e0e0e0; padding: 8px; text-align: left; } .detail-content :deep(th) { background: #f5f5f5; font-weight: 600; } +/* ── 报告解读(type=report) ── */ +.rp-filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.rp-list { display: flex; flex-direction: column; gap: 10px; } +.rp-item { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: background 0.2s; } +.rp-item:hover { background: #f5f7fa; } +.rp-item-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } +.rp-date { font-size: 12px; color: #909399; } +.rp-title { font-size: 15px; font-weight: 600; color: #303133; } +.rp-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ecf5ff; color: #409eff; } +.rp-tag-entity { background: #f0f9eb; color: #67c23a; } +.rp-tag-kind { background: #fdf6ec; color: #e6a23c; } +.rp-conclusion { font-size: 13px; color: #303133; line-height: 1.65; } +.rp-meta { display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: #c0c4cc; flex-wrap: wrap; } +.rp-warn { color: #e6a23c; } +.rp-detail-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; } +.rp-detail-conclusion { background: #f5f7fa; border-left: 3px solid #409eff; padding: 10px 12px; border-radius: 4px; font-size: 14px; line-height: 1.7; margin-bottom: 8px; } +.rp-detail-row { font-size: 13px; color: #606266; margin-bottom: 6px; line-height: 1.7; } +.rp-kpi-link { margin-right: 12px; padding: 0; } +.rp-hr { border: none; border-top: 1px solid #ebeef5; margin: 14px 0; } + /* ── Quiz Intro ── */ .quiz-intro { display: flex; justify-content: center; padding: 40px 0; } .quiz-intro-card {