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:
Hermes CI Fix
2026-10-18 16:31:52 +08:00
parent 57f9dbae15
commit 2cff58ca6a
3 changed files with 808 additions and 3 deletions
+324 -2
View File
@@ -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
@@ -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:<id> → 指标详情页;文本 → 指标字典页关键词
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"] == []
+173 -1
View File
@@ -50,7 +50,81 @@
</MyDialog>
</el-tab-pane>
<!-- Tab 2: 职业道德自测 -->
<!-- Tab 2: 报告解读(读结论)—— 与知识库并列的分区,不是第二个报表中心(看数在 管理报表/BI报表) -->
<el-tab-pane label="📄 报告解读" name="reports">
<div class="kb-header">
<h3>报告解读</h3>
<div class="rp-filters">
<el-date-picker v-model="reportRange" type="daterange" value-format="YYYY-MM-DD"
start-placeholder="开始日" end-placeholder="结束日" style="width:250px;" @change="loadReports" />
<el-select v-model="reportDomain" placeholder="域" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.domain" :key="f.key" :label="f.name" :value="f.key" />
</el-select>
<el-select v-model="reportEntity" placeholder="账套" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.entity" :key="f.key" :label="f.key" :value="f.key" />
</el-select>
<el-select v-model="reportKind" placeholder="类型" clearable style="width:110px" @change="loadReports">
<el-option v-for="f in reportFacets.kind" :key="f.key" :label="f.name" :value="f.key" />
</el-select>
<el-button @click="loadReports">刷新</el-button>
</div>
</div>
<div v-if="reportLoading" class="kb-hint">报告加载中...</div>
<div v-else-if="reportError" class="kb-hint kb-hint-error">{{ reportError }}</div>
<div v-else-if="reports.length === 0" class="kb-hint">
暂无报告解读内容(只收录带 front-matter 且含 conclusion 的报告件;回执/审计/施工件按规范不进此页)
</div>
<div v-else class="rp-list">
<div v-for="r in reports" :key="r.id" class="rp-item" @click="openReport(r)">
<div class="rp-item-head">
<span class="rp-date">{{ r.date }}</span>
<span class="rp-title">{{ r.title }}</span>
<span class="rp-tag">{{ domainLabel(r.domain) }}</span>
<span v-if="r.entity" class="rp-tag rp-tag-entity">{{ r.entity }}</span>
<span class="rp-tag rp-tag-kind">{{ kindLabel(r.kind) }}</span>
</div>
<div class="rp-conclusion">{{ r.conclusion }}</div>
<div class="rp-meta">
<span v-if="r.incomplete && r.incomplete.length" class="rp-warn">缺 {{ r.incomplete.join('/') }}</span>
<span>{{ r.source }}:{{ r.file }}</span>
</div>
</div>
</div>
<!-- 报告详情弹窗:结论 + md 正文 + kpi_refs 一跳回链(读结论 → 看数) -->
<MyDialog v-model="showReport" :title="reportDetail?.title || '报告详情'" :width="760">
<div v-if="reportDetail" class="detail-content">
<div class="rp-detail-meta">
<span class="rp-date">{{ reportDetail.date }}</span>
<span class="rp-tag">{{ domainLabel(reportDetail.domain) }}</span>
<span v-if="reportDetail.entity" class="rp-tag rp-tag-entity">{{ reportDetail.entity }}</span>
<span class="rp-tag rp-tag-kind">{{ kindLabel(reportDetail.kind) }}</span>
</div>
<div class="rp-detail-conclusion"><strong>结论:</strong>{{ reportDetail.conclusion }}</div>
<div v-if="reportDetail.impact" class="rp-detail-row"><strong>影响:</strong>{{ reportDetail.impact }}</div>
<div v-if="reportDetail.sources && reportDetail.sources.length" class="rp-detail-row">
<strong>出处:</strong>{{ reportDetail.sources.join(' · ') }}
</div>
<div v-if="reportDetail.decisions && reportDetail.decisions.length" class="rp-detail-row">
<strong>待拍板:</strong>{{ reportDetail.decisions.join(' · ') }}
</div>
<div v-if="reportDetail.kpi_links && reportDetail.kpi_links.length" class="rp-detail-row">
<strong>关联指标:</strong>
<el-button v-for="k in reportDetail.kpi_links" :key="k.raw" link type="primary"
class="rp-kpi-link" @click="gotoKpi(k)">{{ k.raw }} ↗</el-button>
</div>
<hr class="rp-hr" />
<div v-html="reportHtml"></div>
</div>
<template #footer>
<el-button @click="showReport = false">关闭</el-button>
</template>
</MyDialog>
</el-tab-pane>
<!-- Tab 3: 职业道德自测 -->
<el-tab-pane label="⚖️ 职业道德自测" name="ethics-quiz">
<div v-if="!quizStarted" class="quiz-intro">
<div class="quiz-intro-card">
@@ -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<any[]>([])
const reportLoading = ref(false)
const reportError = ref("")
const reportRange = ref<any>(null)
const reportDomain = ref("")
const reportEntity = ref("")
const reportKind = ref("")
const reportFacets = ref<any>({ domain: [], entity: [], kind: [], date: { min: "", max: "" } })
const showReport = ref(false)
const reportDetail = ref<any>(null)
const reportHtml = ref("")
const REPORT_DOMAIN_LABELS: Record<string, string> = {
finance: "财务", customer: "客户", process: "流程", learning: "学习成长",
tech: "技术", ops: "运维", market: "市场", gov: "政务",
}
const REPORT_KIND_LABELS: Record<string, string> = {
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:<id>` / 数字 → 指标详情页;其余 → 指标字典页(关键词)
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 {