From e4f26939185fcef1fd780820ef2089fa238dcd3d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 28 Sep 2026 11:47:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E7=82=B9=E5=87=BB=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=9C=9F=EF=BC=9A=E6=8A=A5=E5=91=8A=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E6=AF=8F=E4=B8=AA=E6=95=B0=E5=AD=97=E9=83=BD=E7=82=B9?= =?UTF-8?q?=E5=BE=97=E5=BC=80=E5=87=BA=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡 填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。 内核(engine/evidence.py,口径卡、出具契约、表达式等) - 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本 - 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个 - 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理 - 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档 报告撰写节点(report)与引用模式的出具契约 - 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写 (max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认) - report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败 - 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具 - 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道) 证据接口 - GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链 - 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存 - 没有报告的旧运行降级为 none 或 legacy_contract 界面 - EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位, ←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去 - EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、 封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿 - 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差 - 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」 验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。 --- backend/app/api/copilot.py | 34 +- backend/app/api/evidence.py | 434 +++ backend/app/core/events.py | 3 + backend/app/engine/compiler.py | 7 +- backend/app/engine/evidence.py | 1445 +++++++++ backend/app/engine/expressions.py | 98 + backend/app/engine/issuance.py | 114 +- backend/app/engine/nodes/io.py | 279 +- backend/app/engine/nodes/metrics.py | 278 +- backend/app/engine/nodes/report.py | 303 ++ backend/app/engine/review.py | 26 +- backend/app/engine/schema.py | 76 +- backend/app/engine/state.py | 4 + backend/app/engine/variables.py | 15 +- backend/app/main.py | 3 +- backend/app/seed.py | 56 + backend/tests/test_contract_citations.py | 332 ++ backend/tests/test_copilot_evidence_guide.py | 40 + backend/tests/test_evidence_api.py | 386 +++ backend/tests/test_evidence_markers.py | 659 ++++ backend/tests/test_issuance_matched.py | 5 +- backend/tests/test_issuance_positions.py | 87 + backend/tests/test_metric_lineage.py | 407 +++ backend/tests/test_report_node.py | 450 +++ backend/tests/test_report_schema.py | 125 + backend/tests/test_review.py | 53 + backend/tests/test_template_traceable.py | 102 + frontend/src/api/client.ts | 22 +- frontend/src/canvas/Inspector.tsx | 50 + frontend/src/canvas/NodeCard.tsx | 10 +- frontend/src/canvas/nodeDefs.ts | 52 +- frontend/src/dev/preview.tsx | 40 +- frontend/src/dev/ui-harness.tsx | 117 +- frontend/src/index.css | 58 + frontend/src/lib/evidence.ts | 428 +++ frontend/src/lib/terms.ts | 106 + frontend/src/pages/runs/ArtifactsPane.tsx | 14 +- frontend/src/pages/runs/RunDetailView.tsx | 2 + frontend/src/pages/runs/model.ts | 17 + frontend/src/run/AssistantPanel.tsx | 3 +- frontend/src/run/AssistantStream.tsx | 108 +- frontend/src/run/EvidenceDoc.tsx | 573 ++++ frontend/src/run/EvidencePanel.tsx | 452 +++ frontend/src/run/Markdown.tsx | 457 ++- frontend/src/run/__tests__/evidence-doc.json | 2784 +++++++++++++++++ .../src/run/__tests__/markdown-pinned.json | 59 + frontend/src/run/decode.ts | 38 +- frontend/src/store/chat.ts | 16 + frontend/src/store/evidence.ts | 91 + frontend/src/types.ts | 214 +- scripts/check-all.mjs | 4 +- scripts/check-chat.mjs | 60 +- scripts/check-decode.mjs | 76 + scripts/check-evidence.mjs | 697 +++++ scripts/check-stream.mjs | 47 + scripts/check-ui-kit.mjs | 108 + 56 files changed, 12309 insertions(+), 215 deletions(-) create mode 100644 backend/app/api/evidence.py create mode 100644 backend/app/engine/evidence.py create mode 100644 backend/app/engine/nodes/report.py create mode 100644 backend/tests/test_contract_citations.py create mode 100644 backend/tests/test_copilot_evidence_guide.py create mode 100644 backend/tests/test_evidence_api.py create mode 100644 backend/tests/test_evidence_markers.py create mode 100644 backend/tests/test_issuance_positions.py create mode 100644 backend/tests/test_metric_lineage.py create mode 100644 backend/tests/test_report_node.py create mode 100644 backend/tests/test_report_schema.py create mode 100644 backend/tests/test_template_traceable.py create mode 100644 frontend/src/lib/evidence.ts create mode 100644 frontend/src/run/EvidenceDoc.tsx create mode 100644 frontend/src/run/EvidencePanel.tsx create mode 100644 frontend/src/run/__tests__/evidence-doc.json create mode 100644 frontend/src/run/__tests__/markdown-pinned.json create mode 100644 frontend/src/store/evidence.ts create mode 100644 scripts/check-evidence.mjs diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index f7e97c0b..75ea1495 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -130,7 +130,7 @@ def _datasource_section(rows: list[Any]) -> str: "- 认准方言:Oracle 用 FETCH FIRST n ROWS ONLY,不是 LIMIT\n" "- 一次只写一条语句;分号拼接会被拒\n" "- 字段拿不准就在图里先放一个 db_schema 工具节点,别猜字段名\n" - "- 取数结果要进口径卡(metrics 节点)才能被叙述引用,别让 llm 节点直接对数字做算术\n" + "- 取数结果要进口径卡(metrics 节点)才能被报告撰写节点(report)引用,别让 llm 节点直接对数字做算术\n" ) @@ -163,8 +163,30 @@ NODE_REFERENCE = """\ - validate:JSON Schema 校验,可自动让模型修复。config: {schema, source, max_retries} - transform:数据整形。config: {mode: expression|template|json, expression/template, assign_to} - subgraph:嵌套另一个工作流。config: {workflow_id, input} +- metrics:口径卡。报告里要出现的数字、以及所有派生计算(比率、增幅、占比、差值)都登记在这里。 + config: {caliber, caliber_version, metrics:[{id, name, unit, decimals, format, expression}], on_missing, assign_to} + expression 是受限表达式(不是模板):vars.q.rows[0][0]、round((vars.q.rows[0][0] - vars.q.rows[0][1]) / vars.q.rows[0][1] * 100, 1) + format:thousands(默认,千分位)/ plain(不分组,年份编号这类)/ percent_of_ratio(值是 0.0235 这样的比率,显示成 2.35%); + decimals 写 -15 到 15 的整数;percent_of_ratio 的 decimals 按比率算:要显示两位百分比(2.35%)写 4, + 写 2 只剩「2%」,0.004 这样的小比率还会显示不出来、报告引用不了。on_missing 默认 fail(缺输入整张卡失败);写 null 则缺输入的指标记为空值,交给出具契约判档 + db_query__* 返回的是 JSON 文本 {columns, rows},口径卡取不到里面的字段:先接一个 + transform(mode=json,template="{{ nodes.取数节点id }}",assign_to="q"),口径卡再写 vars.q.rows[0][0] +- report:报告撰写(带引用)。config: {instructions, system, metrics_from:[口径卡id], on_violation, max_repairs, assign_to} + **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游口径卡的指标和运行输入, + 模型只能写 [[m:指标id]]、[[i:输入字段]] 这样的引用标记,数值由系统换成口径卡里的真实值; + 模型自己写的数字会被打回重写。它只能引用口径卡指标和运行输入,所以报告里要出现的每个数都得先在口径卡里登记。 + metrics_from 不写就取所有上游口径卡;on_violation 不写时探索运行是 flag(标出来、照常产出),正式运行是 fail +- output 的出具契约:config.contract = {report_from: 报告节点id, metrics_from:[口径卡id], + required:[必需指标id], expected:[期望指标id], strict: true}。成果字段写 {{ nodes.报告节点id.text }}, + 前后不要拼别的字——拼了就没法逐段对应证据,出具会降档 -模型字段(llm / agent / supervisor 的 config.model): +搭图规则(有数字结论时必须遵守): +1. 取数(tool 节点查库,接 transform 解析)→ 口径卡 → report → output(配 report_from 契约)。 + agent 的回答是自由文本,进不了口径卡:要出可追溯的报告,取数用 tool 节点 +2. 能用 SUM / COUNT 做的聚合放在 SQL 里,口径卡只做标量运算 +3. 不要用 code 节点做业务计算;code 只做格式转换 + +模型字段(llm / agent / supervisor / report 的 config.model): - **不要填**。留空表示跟随当前供应商的默认模型,这几乎总是对的。 - 你不知道这套部署里有哪些 model id——凭供应商名字猜(比如看到 DeepSeek 就写 "deepseek-chat")会得到 401 invalid_model,整个节点跑不起来。 @@ -250,6 +272,10 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str: "- 涉及数据的问题必须用 db_query__* / db_schema__* 去真查," "不要只对问题本身做文字加工(改写、摘要、分类都不是回答)\n" "- 出口节点给出的应当是这个问题的答案本身\n" + # 报告撰写节点只能引用口径卡里登记过的数。agent 查到的结论还进不了口径卡, + # 普通问数也套上 report 的话,答案里的数会全部被判成没有出处 + "- 用户要的是报告、周报,或者要能核对每个数的出处时,按搭图规则走 tool 取数 → 口径卡 → " + "report → output(配 report_from 契约);普通的问数照旧用 agent,不必加 report\n" # 只把历史放进 prompt 是不够的:模型拿到上一轮的图,默认仍然会 # 重新设计一张"更完整"的。而用户说"再按月份拆一下"时要的是上一张 # 图改个 SQL——重建出来的那张经常接到另一张表上,答案对不上前一轮。 @@ -1596,6 +1622,8 @@ async def review_run( ).as_dict() result = await rv.review( - model, question=payload.question, answer=answer, signals=signals + model, question=payload.question, answer=answer, signals=signals, + # 报告撰写节点产出的答案逐段对应着证据文档:只能加说明,不能改写 + locked=bool(output and output.get("_evidence")), ) return result.as_dict() diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py new file mode 100644 index 00000000..99a65a18 --- /dev/null +++ b/backend/app/api/evidence.py @@ -0,0 +1,434 @@ +"""证据接口:整次运行的证据图,和点开报告里一个片段时的出处链。 + +一件证据可信,当且仅当它能从封存范围内的事件(seq ≤ manifest_seq)出发走到, +并且取回时哈希复验通过: + +- 报告文档:report.checked 事件里的 doc_artifact +- 口径卡的指标集:口径卡节点 node.finished 事件里的 evidence 台账 +- 成果(哪些字段是报告原文):出口节点 node.finished 事件里的 node_output 工件 +- 运行输入:入口节点 node.finished 事件里的 node_output 工件 + +artifacts 表可以事后插行,run.output 是可以改写的一列,封存之后追加的事件不在 +核对范围里——这三样都不当来源。本期只解析数字层(口径卡指标、运行输入)的链; +旧运行按旧契约的位置信息标出 matched(legacy_contract),不按数值猜来源。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from fastapi import APIRouter +from sqlalchemy import select + +from app.api.coded import CodedHTTPException +from app.core.artifact_store import load +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.evidence import ( + RenderError, + find_segment, + input_eid, + iter_units, + make_eid, + render_metric, +) + +router = APIRouter(prefix="/api/runs", tags=["evidence"]) + +SCHEMA = "agentlab.evidence/1" + +RUN_NOT_FOUND = "run_not_found" +REPORT_NOT_FOUND = "evidence_report_not_found" +SEGMENT_NOT_FOUND = "evidence_segment_not_found" +DOC_MISSING = "evidence_doc_missing" +DOC_TAMPERED = "evidence_doc_tampered" + +LEGACY_NOTE = "旧版出具:按数值匹配,不是显式引用。同一个值对得上好几个指标时,出处不唯一" +NONE_NOTE = "这次运行没有报告文档,也没有出具契约,没有可以展示的证据" +NO_DOC_NOTE = "这次运行的出具契约用的是引用模式,但报告没有产出可以核对的文档" + + +# -------------------------------------------------------------------------- +# 封存范围 +# -------------------------------------------------------------------------- + + +@dataclass +class _Sealed: + run_id: str + graph: dict[str, Any] + seal: dict[str, Any] + events: list[tuple[int, str, str | None, dict[str, Any]]] + #: 封存范围内 node.finished.evidence 记过的 (节点, 工件) + ledger: set[tuple[str, str]] = field(default_factory=set) + #: 节点 id → 它最后一次 node.finished 的产出工件 id + outputs: dict[str, str] = field(default_factory=dict) + + @property + def sealed(self) -> bool: + return bool(self.seal["sealed"]) + + @property + def trusted(self) -> bool: + """封存了、而且封存核对通过。封存范围内有事件被改过的话,范围里的哪一条都不能再 + 当「已封存」展示——只看 sealed 的消费方会把一条断掉的封存链画成绿的。""" + return self.sealed and self.seal["ok"] is True + + def nodes_of(self, node_type: str) -> list[str]: + return [str(n.get("id")) for n in self.graph.get("nodes") or [] + if isinstance(n, dict) and n.get("type") == node_type] + + def payload_of(self, node_ids: list[str]) -> dict[str, Any] | None: + """这几个节点里最后跑完的那个的产出,从封存的事件指向的工件里取。""" + for node_id in reversed([nid for _, t, nid, _ in self.events if t == "node.finished" and nid in node_ids]): + artifact = self.outputs.get(node_id) + try: + payload = load(artifact) if artifact else None + except ValueError: + payload = None + if isinstance(payload, dict): + return payload + return None + + +async def _sealed(run_id: str) -> _Sealed: + from app.engine.runner import verify_manifest + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id).order_by(RunEvent.seq))] + graph = run.graph if isinstance(run.graph, dict) else {} + verdict = await verify_manifest(run_id) + bound = verdict.get("sealed_at") if verdict.get("sealed") else None + # 没封存(还在跑、停在审批)的运行没有「封存之后」:现有的事件都算,但每件证据都标未封存 + events = [r for r in rows if bound is None or r[0] <= bound] + out = _Sealed(run_id=run_id, graph=graph, events=events, seal={ + "sealed": bool(verdict.get("sealed")), "ok": verdict.get("ok"), "manifest_seq": bound, + "legacy": bool(verdict.get("legacy", False))}) + for _, etype, node_id, data in events: + if etype != "node.finished" or not node_id or not isinstance(data, dict): + continue + if data.get("artifact"): + out.outputs[node_id] = data["artifact"] + for entry in data.get("evidence") or []: + if isinstance(entry, dict) and entry.get("artifact"): + out.ledger.add((node_id, entry["artifact"])) + return out + + +# -------------------------------------------------------------------------- +# 报告文档 +# -------------------------------------------------------------------------- + + +@dataclass +class _Report: + node_id: str + doc_artifact: str + ok: bool | None + repairs: int | None + stats: dict[str, Any] | None + doc: dict[str, Any] | None + #: True 取回并复验通过;False 内容和哈希对不上(或者不是这次运行这个节点写的);None 文件不在了 + hash_ok: bool | None + fields: list[str] + + +def _reports(sealed: _Sealed) -> list[_Report]: + """封存范围内每个报告节点最后一次的自查结果,按第一次出现的顺序。""" + latest: dict[str, dict[str, Any]] = {} + for _, etype, node_id, data in sealed.events: + if etype == "report.checked" and node_id and isinstance(data, dict) and data.get("doc_artifact"): + latest[node_id] = data + evidence = _output_evidence(sealed) + out = [] + for node_id, data in latest.items(): + artifact = str(data["doc_artifact"]) + try: + doc = load(artifact) + hash_ok: bool | None = True if doc is not None else None + except ValueError: + doc, hash_ok = None, False + # 文档要真是这次运行里这个节点写的:内容寻址只保证没被改,不保证没被张冠李戴 + if isinstance(doc, dict) and (doc.get("node_id") != node_id or doc.get("run_id") != sealed.run_id): + doc, hash_ok = None, False + fields = [f for r in evidence if r.get("report_node") == node_id and r.get("doc_artifact") == artifact + for f in r.get("fields") or []] + out.append(_Report(node_id=node_id, doc_artifact=artifact, ok=data.get("ok"), repairs=data.get("repairs"), + stats=(doc or {}).get("stats") or data.get("stats"), + doc=doc if isinstance(doc, dict) else None, hash_ok=hash_ok, fields=fields)) + return out + + +def _output_evidence(sealed: _Sealed) -> list[dict[str, Any]]: + output = sealed.payload_of(sealed.nodes_of("output")) or {} + primary = output.get("_evidence") + if not isinstance(primary, dict): + return [] + return [primary, *[r for r in primary.get("others") or [] if isinstance(r, dict)]] + + +def _input_payloads(sealed: _Sealed) -> dict[str, Any]: + return sealed.payload_of(sealed.nodes_of("input")) or {} + + +def _entry_sealed(sealed: _Sealed, entry: dict[str, Any], inputs: dict[str, Any]) -> bool: + if not sealed.trusted: + return False + if entry.get("kind") == "input": + name = (entry.get("locator") or {}).get("field") + return name in inputs and inputs[name] == entry.get("value") \ + and entry.get("eid") == input_eid(name, entry.get("value")) + return (str(entry.get("node_id") or ""), str(entry.get("artifact") or "")) in sealed.ledger + + +# -------------------------------------------------------------------------- +# GET /api/runs/{run_id}/evidence +# -------------------------------------------------------------------------- + + +@router.get("/{run_id}/evidence") +async def evidence_graph(run_id: str) -> dict[str, Any]: + """整次运行的证据图:报告、每件证据、谁引用了谁。mode 是 cited / legacy_contract / none。""" + sealed = await _sealed(run_id) + reports = _reports(sealed) + body: dict[str, Any] = {"run_id": run_id, "schema": SCHEMA, "seal": sealed.seal, + "reports": [], "evidence": [], "edges": []} + if not reports: + issuance = _issuance_of(sealed) + if issuance is None: + return {**body, "mode": "none", "note": NONE_NOTE} + if issuance.get("mode") == "citations": + # 引用模式的契约,报告却被跳过、或者文档没落下来:不是旧版出具,照实说没有文档,并带上契约记的缘由 + gaps = [str(g) for g in issuance.get("gaps") or [] if g] + why = next((g for g in gaps if "报告" in g), gaps[0] if gaps else None) + return {**body, "mode": "none", "note": f"{NO_DOC_NOTE}:{why}" if why else NO_DOC_NOTE} + return {**body, "mode": "legacy_contract", "legacy": _legacy(sealed, issuance)} + + inputs = _input_payloads(sealed) + for report in reports: + body["reports"].append({ + "node_id": report.node_id, "doc_artifact": report.doc_artifact, + "doc_sealed": sealed.trusted, "hash_ok": report.hash_ok, "ok": report.ok, + "repairs": report.repairs, "fields": report.fields, "stats": report.stats, + }) + if report.doc is not None: + evidence, edges = _graph_of(report, sealed, inputs) + body["evidence"].extend(evidence) + body["edges"].extend(edges) + return {**body, "mode": "cited"} + + +def _graph_of(report: _Report, sealed: _Sealed, inputs: dict[str, Any]) -> tuple[list, list]: + doc = report.doc or {} + cited_by: dict[str, list[str]] = {} + units_of: dict[str, list[str]] = {} + edges: list[dict[str, Any]] = [] + for _, unit in iter_units(doc): + for alias in unit.get("cites") or []: + units_of.setdefault(alias, []).append(unit["id"]) + for seg in unit.get("segments") or []: + cite = seg.get("cite") or {} + if seg.get("state") == "deterministic" and cite.get("status") == "resolved": + cited_by.setdefault(cite["alias"], []).append(seg["id"]) + edges.append({"from": seg["id"], "to": cite["alias"], "rel": "cites", "report": report.node_id}) + for support in unit.get("see") or []: + if support.get("status") == "resolved": + edges.append({"from": unit["id"], "to": support["alias"], "rel": "supports", + "report": report.node_id}) + + evidence = [] + cards: dict[str, dict[str, Any] | None] = {} + for alias, entry in (doc.get("catalog") or {}).items(): + evidence.append({ + "alias": alias, "eid": entry.get("eid"), "kind": entry.get("kind"), "label": entry.get("label"), + "node_id": entry.get("node_id"), "artifact": entry.get("artifact"), + "sealed": _entry_sealed(sealed, entry, inputs), "cited_by": cited_by.get(alias, []), + "units": units_of.get(alias, []), "report": report.node_id, + }) + if entry.get("kind") != "metric" or not entry.get("artifact"): + continue + artifact = entry["artifact"] + if artifact not in cards: + cards[artifact] = _load_card(artifact)[0] + metric = _metric_in(cards[artifact], (entry.get("locator") or {}).get("metric")) + for item in (metric or {}).get("inputs") or []: + edges.append({"from": alias, "to": item.get("path"), "rel": "input", "node_id": item.get("node_id"), + "report": report.node_id}) + return evidence, edges + + +def _load_card(artifact: str) -> tuple[dict[str, Any] | None, bool]: + try: + card = load(artifact) + except ValueError: + return None, False + return (card, True) if isinstance(card, dict) else (None, False) + + +def _metric_in(card: dict[str, Any] | None, metric_id: Any) -> dict[str, Any] | None: + return next((m for m in (card or {}).get("metrics") or [] if m.get("id") == metric_id), None) + + +def _issuance_of(sealed: _Sealed) -> dict[str, Any] | None: + """成果上的 _issuance;成果里取不到时退回封存范围内最后一条 issuance 事件。""" + output = sealed.payload_of(sealed.nodes_of("output")) or {} + issuance = output.get("_issuance") + if not isinstance(issuance, dict): + issuance = next((data for _, etype, _, data in reversed(sealed.events) if etype == "issuance"), None) + return issuance if isinstance(issuance, dict) else None + + +def _legacy(sealed: _Sealed, issuance: dict[str, Any]) -> dict[str, Any]: + """旧契约:把 _issuance 里的 matched 按位置标出来,认出偏移对得上的是哪个成果字段。 + + 偏移是 trace_numbers 在叙述串里算的;叙述模板就是某个成果字段时,每个 token 在那个 + 字段的 [start, end) 上应当原样出现。对不上的字段不认——宁可不标,也不猜。 + """ + output = sealed.payload_of(sealed.nodes_of("output")) or {} + + def positioned(item: dict[str, Any]) -> dict[str, Any]: + where = isinstance(item.get("start"), int) and isinstance(item.get("end"), int) + return {**item, "positioned": where} + + matched = [positioned(m) for m in issuance.get("matched") or [] if isinstance(m, dict)] + unmatched = [positioned(u) for u in issuance.get("unmatched_numbers") or [] if isinstance(u, dict)] + placed = [m for m in matched + unmatched if m["positioned"]] + fields = [name for name, value in output.items() + if not name.startswith("_") and isinstance(value, str) and placed + and all(value[m["start"]:m["end"]] == m.get("token") for m in placed)] + return {"note": LEGACY_NOTE, "tier": issuance.get("tier"), "matched": matched, "unmatched": unmatched, + "field": fields[0] if len(fields) == 1 else None} + + +# -------------------------------------------------------------------------- +# GET /api/runs/{run_id}/evidence/segments/{segment_id} +# -------------------------------------------------------------------------- + + +@router.get("/{run_id}/evidence/segments/{segment_id}") +async def evidence_segment(run_id: str, segment_id: str, report: str | None = None) -> dict[str, Any]: + """点开报告里的一个片段:片段、所在的句子、出处链(本期是指标步骤加它的输入)、封存状态。 + + 一次运行里有好几份报告时用 ?report=<节点 id> 指定;不指定取成果里标注的那一份。 + """ + sealed = await _sealed(run_id) + reports = _reports(sealed) + if not reports: + raise CodedHTTPException(404, "这次运行没有封存的报告文档,没有可以点开的片段", REPORT_NOT_FOUND) + chosen = _choose(reports, report, sealed) + if chosen.hash_ok is False: + raise CodedHTTPException(409, "报告文档和它的哈希对不上,疑似被改过,不能再当证据展示", DOC_TAMPERED) + if chosen.doc is None: + raise CodedHTTPException(404, "报告文档在工件库里取不回来", DOC_MISSING) + found = find_segment(chosen.doc, segment_id) + if found is None: + raise CodedHTTPException(404, f"报告里没有片段 {segment_id}", SEGMENT_NOT_FOUND) + + block, unit, seg = found + doc = chosen.doc + markdown = str(doc.get("markdown") or "") + start, end = unit.get("span") or [0, 0] + chain = _chain(seg, doc, sealed) + covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step) + return { + "report": {"node_id": chosen.node_id, "doc_artifact": chosen.doc_artifact}, + "segment": {"id": seg["id"], "text": seg.get("text"), "kind": seg.get("kind"), "state": seg.get("state"), + "span": seg.get("span"), "unit": unit.get("id"), + **{k: seg[k] for k in ("ref", "issue", "strong", "cite") if k in seg}}, + "unit": {"id": unit.get("id"), "kind": unit.get("kind"), "text": markdown[start:end], + "span": unit.get("span"), "cites": unit.get("cites") or []}, + "block": {"id": block.get("id"), "type": block.get("type")}, + "chain": chain, + "note": _note(seg, unit), + "violations": [v for v in doc.get("violations") or [] + if v.get("segment") == seg["id"] or (v.get("unit") == unit.get("id") and not v.get("segment"))], + "seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"], "covered": covered}, + # 本期没有查询快照的行,也就没有要遮的列;字段先占位,前端不用等二期再改 + "redacted": {"columns": []}, + } + + +def _choose(reports: list[_Report], wanted: str | None, sealed: _Sealed) -> _Report: + if wanted: + chosen = next((r for r in reports if r.node_id == wanted), None) + if chosen is None: + raise CodedHTTPException(404, f"这次运行里没有报告节点 {wanted} 的文档", REPORT_NOT_FOUND) + return chosen + primary = next(iter(_output_evidence(sealed)), {}) + return next((r for r in reports if r.node_id == primary.get("report_node")), reports[0]) + + +def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]: + cite = seg.get("cite") or {} + if seg.get("state") != "deterministic" or cite.get("status") != "resolved": + return [] + entry = (doc.get("catalog") or {}).get(cite.get("alias")) or {} + if cite.get("kind") == "input": + name = (cite.get("locator") or {}).get("field") + inputs = _input_payloads(sealed) + return [{"step": "run_input", "alias": cite.get("alias"), "field": name, "value": entry.get("value"), + "rendered": seg.get("text"), "eid": cite.get("eid"), + "eid_ok": cite.get("eid") == entry.get("eid") == input_eid(name, entry.get("value")), + "node_id": next(iter(sealed.nodes_of("input")), None), + "sealed": _entry_sealed(sealed, entry, inputs)}] + if cite.get("kind") != "metric": + return [] + return _metric_chain(seg, cite, entry, sealed) + + +def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], + sealed: _Sealed) -> list[dict[str, Any]]: + """指标步骤:从封存台账里那件 metric_set 工件取回整张卡,按 id 找到指标,再验三件事—— + eid 能由工件和定位重算出来、卡里的值按同样的格式渲染出来就是报告上的字、这件工件 + 确实记在口径卡节点封存过的台账里。""" + artifact = str(entry.get("artifact") or "") + metric_id = (cite.get("locator") or {}).get("metric") + card, hash_ok = _load_card(artifact) if artifact else (None, False) + metric = _metric_in(card, metric_id) + try: + rendered_now = render_metric(metric, cite.get("conv")) if metric else None + except RenderError: + rendered_now = None + source = metric or entry + step = { + "step": "metric", "alias": cite.get("alias"), "metric": metric_id, + "name": source.get("name") or metric_id, "value": source.get("value"), "unit": source.get("unit") or "", + "decimals": source.get("decimals"), "format": source.get("format"), "conv": cite.get("conv"), + "rendered": seg.get("text"), + "caliber": (card or {}).get("caliber", entry.get("caliber")), + "version": (card or {}).get("caliber_version", entry.get("version")), + "expression": (metric or {}).get("expression"), "substituted": (metric or {}).get("substituted"), + "recompute_ok": (metric or {}).get("recompute_ok"), "status": source.get("status"), + "node_id": entry.get("node_id"), "artifact": artifact or None, "eid": cite.get("eid"), + "eid_ok": cite.get("eid") == entry.get("eid") == make_eid("metric", artifact or None, {"metric": metric_id}), + "hash_ok": hash_ok and metric is not None, + "render_ok": rendered_now is not None and rendered_now == seg.get("text"), + "sealed": sealed.trusted and (str(entry.get("node_id") or ""), artifact) in sealed.ledger, + # 口径卡的版本还没纳入升版处置(那一套目前只管钉了版本的子工作流) + "caliber_upgrade": None, + } + return [step, *({"step": "input", **item} for item in (metric or {}).get("inputs") or [])] + + +def _note(seg: dict[str, Any], unit: dict[str, Any]) -> str: + """片段为什么是现在这个状态,一句人话。""" + issue = seg.get("issue") + if issue == "uncited_number": + return "这个数字没有出处:写作者直接写了数字,没有用引用标记,系统没法核对它" + if issue == "unresolved_ref": + return f"引用解析不了:{(seg.get('cite') or {}).get('reason') or '证据目录里没有它'}" + if seg.get("state") == "deterministic": + return "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写" + kind = seg.get("kind") + if kind == "structural": + return "这是 Markdown 的版式符号" + if unit.get("kind") == "heading": + return "这是标题" + if unit.get("kind") == "connective": + return "这是连接性文字,不陈述数据事实,所以不需要证据" + return "这是结论句里的文字。本期只核对数字,句子本身有没有依据要到后续版本由模型判断" diff --git a/backend/app/core/events.py b/backend/app/core/events.py index 821a6ba4..f5616b21 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -63,6 +63,9 @@ class EventType(StrEnum): # 出具与口径治理 ISSUANCE = "issuance" # 出具契约的判定结果(档位 / 缺数据 / 未回指数字) CALIBER_UPGRADE = "caliber.upgrade" # 钉住的方法卡有新版本,按声明的策略处置 + # 报告撰写节点的自查结果:报告文档工件 id、统计、违规清单。落在封存范围内, + # 证据接口靠它找到文档——不去翻不受封存保护的 artifacts 表 + REPORT_CHECKED = "report.checked" # 其他 LOG = "log" diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index 719dd3a6..5df9de17 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -11,7 +11,7 @@ from langgraph.graph import END, START, StateGraph from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.engine.context import NodeContext, NodeError, RunContext -from app.engine.nodes import control, human, io, knowledge, llm, metrics, multi, tools +from app.engine.nodes import control, human, io, knowledge, llm, metrics, multi, report, tools from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges, innermost_loops from app.engine.state import GraphState @@ -35,6 +35,7 @@ RUNNERS: dict[NodeType, NodeRunner] = { NodeType.MEMORY: knowledge.run_memory, NodeType.METRICS: metrics.run_metrics, NodeType.RETRIEVE: knowledge.run_retrieve, + NodeType.REPORT: report.run_report, } # 这些节点的出边由"决定"路由,不能直接连死 @@ -111,6 +112,10 @@ def _wrap( # 如实发出来,只多一个已截断的值,且只有真的赋了值的节点才有。 vars=_vars_preview(updates), artifact=artifact_id, + # 这一步新记进证据台账的条目(口径卡的指标集工件等)。事件在封存范围 + # 内,证据接口从这里出发找工件,不去读可以事后插行的 artifacts 表。 + # 没产出证据的节点不带这个键,事件和以前一字不差 + **({"evidence": updates["evidence"]} if updates.get("evidence") else {}), ) trail = { "node_id": node.id, diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py new file mode 100644 index 00000000..aa3f89a4 --- /dev/null +++ b/backend/app/engine/evidence.py @@ -0,0 +1,1445 @@ +"""可点击的证据:报告里的每个数字由系统从证据里取、按规则渲染,出处写在数据结构里。 + +以前叙述是模型写的自由文本,出具时再按数值回头猜每个数来自哪个指标——5 个指标 +都是 0.0 时,报告里所有的 0 全算给第一个。这里反过来:模型只写引用标记 +`[[m:gmv]]`,数值由系统从口径卡取出、按口径卡的格式渲染。叙述层在结构上就写不出 +数字,按数值猜出处这件事不再需要。 + +整个模块是纯函数(不碰数据库、不调模型),报告节点自查、出口契约复核、证据接口 +共用这一份,免得三处各写一套规则、各判各的。 + +标记语法(本期支持的四种): + + [[m:<指标 id>]] 口径卡指标,渲染成「值+单位」 + [[m:<指标 id>|万]] 换算显示:万 / 亿 / pct / int / .N(小数位),由渲染器完成 + [[i:<输入字段>]] 运行输入 + [[see:,…]] 依据:挂在句末,不渲染 + +`[[v:]]` `[[t:]]` `[[c:]]` `[[q:]]` `[[table:]]` 能解析,本期一律判为解析不了, +原因写「这种引用在后续版本支持」。 + +偏移一律按 Unicode 码点算(Python 的 str 下标)。前端的 JS 字符串按 UTF-16 计, +碰到码点在 BMP 之外的字符(emoji)要自己换算。 +""" + +from __future__ import annotations + +import bisect +import math +import re +from decimal import ROUND_HALF_UP, Context, Decimal +from typing import Any, Literal, TypedDict + +from app.core.artifact_store import canonical_json, content_hash +from app.engine.issuance import extract_numbers, number_allowance + +DOC_SCHEMA = "agentlab.report/1" + +# -------------------------------------------------------------------------- +# 数据结构(方案 1.5)。都是普通 dict,TypedDict 只用来写清形状 +# -------------------------------------------------------------------------- + + +class EvidenceEntry(TypedDict, total=False): + """台账条目:state["evidence"] 里的一行,同时写进 node.finished.evidence。""" + + kind: Literal["query", "metric_set", "retrieval", "tool", "node_output", "schema"] + node_id: str + exec: int # 这个节点第几次执行(循环里会有多次),从 1 数 + artifact: str # 被引用的那件工件 + via: str # 外层包装的工件(query_snapshot 外面的 tool_snapshot) + call_id: str + tool: str + source: str + columns: list[str] + rows: int + truncated: bool + caliber: str # metric_set 专有:口径名、版本、指标 id 清单 + version: str + metrics: list[str] + + +class Citation(TypedDict, total=False): + ref: str # 标记里写的原文:gmv、week、Q4.r0.amount + alias: str # 目录里的键:m:gmv、i:week、Q4 + locator: dict[str, Any] + eid: str + kind: str # metric / input / query / cell / table / column / quote + role: Literal["value", "entity", "quote", "support"] + status: Literal["resolved", "unresolved", "mismatch"] + reason: str # unresolved 时为什么 + conv: str # 换算:万 / 亿 / pct / int / .N + value: Any + rendered: str + + +class Segment(TypedDict, total=False): + id: str + kind: Literal["text", "number", "value", "entity", "quote", "structural"] + text: str + span: list[int] # [start, end),在 doc.markdown 里的码点偏移 + ref: str # 带前缀的原始引用:m:gmv、i:week + cite: Citation + state: Literal["deterministic", "probabilistic", "none", "neutral"] + strong: bool + issue: str # 这一段本身的问题:uncited_number / unresolved_ref + + +class Unit(TypedDict, total=False): + id: str + kind: Literal["claim", "connective", "heading", "code"] + span: list[int] # 内容的起止(不含行首的 Markdown 语法) + cites: list[str] # 这一句引用过的 alias,行内和 [[see:]] 合在一起、去重 + see: list[Citation] # [[see:]] 里的每一个引用,单独核对 + segments: list[Segment] + loc: dict[str, int] # 表格单元格:{row, col},表头 row = -1 + depth: int # 列表项的缩进层级 + + +class Block(TypedDict, total=False): + id: str + type: Literal["heading", "paragraph", "list", "table", "quote", "code", "hr"] + level: int + ordered: bool + start: int # 有序列表的起始序号 + lang: str + units: list[Unit] + + +class Violation(TypedDict, total=False): + code: str + message: str + span: list[int] + text: str + segment: str + unit: str + ref: str + context: str + + +# -------------------------------------------------------------------------- +# eid:内容派生的全局标识 +# -------------------------------------------------------------------------- + + +def make_eid(kind: str, artifact: str | None, locator: dict[str, Any] | None) -> str: + """ev::<16 位 hex>。同样的 (kind, 工件, 定位) 永远得到同一个 eid。 + + 解析时重算一次,和目录里记的对不上就说明目录被改过。 + """ + digest = content_hash(canonical_json({"kind": kind, "artifact": artifact, "locator": locator or {}})) + return f"ev:{kind}:{digest[:16]}" + + +def input_eid(field: str, value: Any) -> str: + """运行输入的 eid。输入没有工件,工件那一格放值的指纹:同一个字段换了值 + (2026-W37 → 2026-W38)就是另一件证据,跨运行比对才有意义。 + + 目录条目里记着 value,拿它就能复算:make_eid("input", content_hash(canonical_json(value)), {"field": field})。 + """ + return make_eid("input", content_hash(canonical_json(value)), {"field": field}) + + +# -------------------------------------------------------------------------- +# 渲染:数值 → 报告里显示的那几个字 +# +# 规则是固定的,同一个值永远渲染成同一串字:报告节点边写边渲染一次,出口契约 +# 复核时再渲染一次,两边逐字比对。所以这里一律用 Decimal 按「四舍五入」算,不走 +# float 的 round(2.675 在 float 里是 2.67499…,round 出来是 2.67)。 +# -------------------------------------------------------------------------- + +FORMATS = ("plain", "thousands", "percent_of_ratio") +#: 口径卡没写 format 时按千分位 +DEFAULT_FORMAT = "thousands" +#: 值拿不到时显示的字。不插值、不写 0 +MISSING = "—" + +_CONV_SCALE = {"万": Decimal(10) ** 4, "亿": Decimal(10) ** 8} +_CONV_PLACES = re.compile(r"^\.([0-6])$") +#: 小数位最多写到多少。负数是取整到十位、百位……(round(x, -2) 的意思) +MAX_PLACES = 100 +#: 整数部分最多多少位。浮点数到头也就 309 位,再大的整数按规则一位位写出来没有意义 +_MAX_DIGITS = 1000 + + +class RenderError(ValueError): + """这个值按这种写法渲染不出来。报告里算作解析不了的引用,原因就是异常消息。""" + + +def is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _decimal(value: int | float) -> Decimal: + if isinstance(value, float): + if not math.isfinite(value): + raise RenderError(f"{value} 不是一个有限的数") + return Decimal(repr(value)) + return Decimal(value) + + +def _ctx(d: Decimal, places: int = 10) -> Context: + """精度跟着值走。定死一个精度的话,1e55 留两位小数就超出精度(抛 InvalidOperation), + 70 位的整数去尾零时会被悄悄舍入成 60 位有效数字——显示出来的就不是那个数了。""" + need = max(len(d.as_tuple().digits), d.adjusted() + max(places, 0)) + 10 + return Context(prec=max(60, need), rounding=ROUND_HALF_UP) + + +def _shown(d: Decimal) -> str: + text = format(d, "f") + return text if len(text) <= 24 else format(d, ".6g") + + +def _digits(d: Decimal, places: int | None, *, group: bool, strip: bool = False, + zero: str | None = None) -> str: + """按小数位四舍五入后写出来。 + + 一个不是 0 的值写出来成了 0(29 单换算成「0万单」、0.004 取整成「0」),就不写: + 抛 RenderError,原因是 zero。系统渲染的数带着「有出处」的标记,一个假的 0 比模型 + 自己编的数还难发现。 + """ + # 没写小数位时最多留 10 位再去掉尾零:0.1 + 0.2 显示 0.3,而不是 0.30000000000000004 + exp = -(10 if places is None else places) + ctx = _ctx(d, -exp) + q = d.quantize(Decimal(1).scaleb(exp), context=ctx) + if q.is_zero() and not d.is_zero(): + raise RenderError(zero or f"{_shown(d)} 按 {-exp} 位小数显示为 0,看不出真实的值") + if places is None or strip: + q = q.normalize(context=ctx) + if q.is_zero(): + q = q.copy_abs() # 不显示 -0.0 + return format(q, ",f" if group else "f") + + +def _clean(text: str) -> str: + """文本值进正文前压成一行:换行会被当成新的段落或列表。""" + return re.sub(r"\s+", " ", text).strip() + + +def render_number( + value: Any, + *, + unit: str = "", + decimals: int | None = None, + fmt: str | None = None, + conv: str | None = None, +) -> str: + """按口径卡的定义把一个值渲染成字。 + + - format:thousands(默认,千分位)/ plain(不分组,年份、编号这类)/ + percent_of_ratio(值是比率 0.0235,显示成 2.35%,小数位按 decimals − 2) + - decimals:固定小数位;不写就按值本来的样子(整数不带小数点) + - 单位直接接在数字后面:45,678.5元、8.7% + - conv 是报告里写的换算:万 / 亿(保留两位、去尾零,单位接在后面:4.57万元)、 + pct(比率 ×100 加 %)、int(取整)、.N(N 位小数) + + 值是 None 显示「—」。文本值原样(压成一行),不能换算。 + + 不是 0 的值显示出来成了 0(换算、取整、小数位不够),抛 RenderError——报告里这个 + 引用就算解析不了,写作者会被要求换一种写法,而不是给读者一个带出处的假 0。 + """ + fmt = fmt or DEFAULT_FORMAT + if fmt not in FORMATS: + raise RenderError(f"format 只能是 {' / '.join(FORMATS)},写的是 {fmt!r}") + if value is None: + return MISSING + if not is_number(value): + if conv: + raise RenderError(f"「{_clean(str(value))[:40]}」不是数,不能换算成 {conv}") + if isinstance(value, bool): + return "是" if value else "否" + return _clean(str(value)) + if decimals is not None and (isinstance(decimals, bool) or not isinstance(decimals, int) + or not -MAX_PLACES <= decimals <= MAX_PLACES): + raise RenderError(f"小数位要是 -{MAX_PLACES} 到 {MAX_PLACES} 的整数,写的是 {decimals!r}") + try: + return _render_numeric(_decimal(value), unit=unit, decimals=decimals, fmt=fmt, conv=conv) + except RenderError: + raise + except (ArithmeticError, ValueError) as e: # decimal 的 InvalidOperation 也在这里 + raise RenderError(f"{value!r} 按规则渲染不出来({type(e).__name__})") from e + + +def _render_numeric(d: Decimal, *, unit: str, decimals: int | None, fmt: str, conv: str | None) -> str: + if d.adjusted() >= _MAX_DIGITS: + raise RenderError(f"这个数有 {d.adjusted() + 1} 位,太大了,没法按规则显示") + ratio = fmt == "percent_of_ratio" + group = fmt != "plain" + shown = _shown(d) + if conv in _CONV_SCALE: + if ratio: + raise RenderError(f"这个指标是比率,不能换算成 {conv}") + scaled = _ctx(d).divide(d, _CONV_SCALE[conv]) + return _digits(scaled, 2, group=True, strip=True, + zero=f"{shown} 换算成「{conv}」后显示为 0,精度不够:去掉换算,或者换小一级的单位" + ) + conv + unit + rounded = f"{shown} 按 |{conv} 显示为 0,精度不够:去掉换算,或者换成 |.N 多留几位小数" + if conv == "pct": + if "%" in unit or "%" in unit: + raise RenderError("这个指标本身就是百分数(单位是 %),不能再换算成 pct") + places = max(decimals - 2, 0) if decimals is not None else 1 + return _digits(_ctx(d).multiply(d, 100), places, group=group, zero=rounded) + "%" + + if conv == "int": + places: int | None = 0 + elif conv and (m := _CONV_PLACES.match(conv)): + places = int(m.group(1)) + elif conv: + raise RenderError(f"不认识的换算 |{conv},只支持 万 / 亿 / pct / int / .N(N 为 0–6)") + else: + rounded = (f"{shown} 按口径卡的小数位({decimals})显示为 0,精度不够:口径卡要多留几位小数" + if decimals is not None else + f"{shown} 太小:不写小数位时最多留 10 位小数,显示为 0。口径卡要写 decimals") + places = (max(decimals - 2, 0) if ratio else decimals) if decimals is not None else None + if ratio: + return _digits(_ctx(d).multiply(d, 100), places, group=group, zero=rounded) + "%" + return _digits(d, places, group=group, zero=rounded) + unit + + +def render_metric(metric: dict[str, Any], conv: str | None = None) -> str: + """口径卡里的一个指标按它自己的格式渲染。""" + return render_number( + metric.get("value"), + unit=str(metric.get("unit") or ""), + decimals=metric.get("decimals"), + fmt=metric.get("format"), + conv=conv, + ) + + +# -------------------------------------------------------------------------- +# 标记语法 +# -------------------------------------------------------------------------- + +#: 流式渲染时,一个 [[ 之后攒多少字还没闭合,就认定它不是标记、原样放出去 +_HOLD_LIMIT = 160 +#: 一个标记(从 [[ 到 ]],含两头)最长多少字。整篇解析和流式渲染用同一个上限: +#: 超长的两边都当普通文字,流里看到的才会和最终正文一模一样。标记本身最长也就几十个字 +MARKER_MAX = _HOLD_LIMIT + 1 +#: 紧跟在 [[ 后面:MARKER_MAX 以内就能碰到 ]](中间不许有方括号和换行) +_MARKER_GUARD = rf"(?=[^\[\]\n]{{0,{MARKER_MAX - 4}}}\]\])" +#: [[kind:body]]。body 里不许有方括号和换行——这样一个没闭合的 [[ 最多吞到行尾 +MARKER_RE = re.compile( + r"\[\[" + _MARKER_GUARD + r"[ \t]*(?P[A-Za-z]+)[ \t]*:(?P[^\[\]\n]*?)\]\]") +SUPPORTED_KINDS = frozenset({"m", "i", "see"}) +LATER_KINDS = frozenset({"v", "t", "c", "q", "table"}) +LATER_REASON = "这种引用在后续版本支持" +_ROLE = {"t": "entity", "c": "entity", "q": "quote"} +_SEG_KIND = {"t": "entity", "c": "entity", "q": "quote"} + + +def _parse_body(kind: str, body: str) -> dict[str, Any]: + if kind == "see": + return {"refs": [r.strip() for r in body.split(",") if r.strip()]} + if kind == "table": + head, *rest = body.split() or [""] + return {"ref": head, "options": dict(p.split("=", 1) for p in rest if "=" in p)} + ref, _, tail = body.partition("|") + if kind == "q": + return {"ref": ref.strip(), "quote": tail.strip()} + return {"ref": ref.strip(), "conv": tail.strip() or None} + + +def parse_markers(text: str) -> list[dict[str, Any]]: + """文本里所有的引用标记,按出现顺序:{kind, body, raw, start, end, ref / conv / refs / …}。""" + out: list[dict[str, Any]] = [] + for m in MARKER_RE.finditer(text): + kind, body = m.group("kind"), m.group("body").strip() + out.append({"kind": kind, "body": body, "raw": m.group(0), "start": m.start(), "end": m.end(), + **_parse_body(kind, body)}) + return out + + +def _parse_ref(ref: str) -> dict[str, Any]: + """片段上记的 ref(m:gmv|万)还原成标记的解析结果。""" + kind, _, body = ref.partition(":") + kind, body = kind.strip(), body.strip() + return {"kind": kind, "body": body, **_parse_body(kind, body)} + + +def placeholder(kind: str, body: str) -> str: + """解析不了的引用在正文里的占位:⟦?m:gmvx⟧。流里和最终文档里是同一个样子。""" + return f"⟦?{kind}:{body}⟧" + + +# -------------------------------------------------------------------------- +# 证据目录:alias → 条目 +# -------------------------------------------------------------------------- + + +def _metric_sources( + nodes: dict[str, Any], ledger: list[dict[str, Any]], metrics_from: list[str] | None, + allowed: set[str] | None, +) -> list[tuple[str, dict[str, Any], str | None]]: + latest: dict[str, str] = {} + order: list[str] = [] + for entry in ledger: + if isinstance(entry, dict) and entry.get("kind") == "metric_set" and entry.get("node_id"): + order.append(entry["node_id"]) + if entry.get("artifact"): + latest[entry["node_id"]] = entry["artifact"] + # 台账里没有、节点产出里有的:旧 checkpoint 续跑时台账是空的,口径卡的产出还在 + order.extend(nid for nid, p in nodes.items() if isinstance(p, dict) and p.get("kind") == "metric_set") + if metrics_from is not None: + order = list(metrics_from) + out = [] + for nid in dict.fromkeys(order): + payload = nodes.get(nid) + if allowed is not None and nid not in allowed: + continue + if isinstance(payload, dict) and payload.get("kind") == "metric_set": + out.append((nid, payload, payload.get("artifact") or latest.get(nid))) + return out + + +def build_catalog( + *, + nodes: dict[str, Any], + ledger: list[dict[str, Any]] | None = None, + inputs: dict[str, Any] | None = None, + metrics_from: list[str] | str | None = None, + allowed: set[str] | frozenset[str] | list[str] | None = None, +) -> dict[str, dict[str, Any]]: + """从证据台账、节点产出、运行输入建目录。 + + - nodes:state["nodes"],口径卡的指标值从这里取(和台账里的工件是同一份内容) + - ledger:state["evidence"],决定顺序、提供工件 id;查询、检索按台账顺序编 Q1… / K1… + - metrics_from:只收这几张口径卡;不传(None、空列表、空字符串)就收全部——和 + validate_graph 的理解一致,那边空列表就是「所有上游口径卡」 + - allowed:只收这些节点的证据(报告节点传它的祖先),不传不限 + + 同一个指标 id 出现在两张卡里时不登记 m:,只登记 m:<卡的节点 id>.—— + 按先来后到默认给一个,正是旧契约错配的那种错。 + """ + if not metrics_from: + metrics_from = None + elif isinstance(metrics_from, str): + metrics_from = [metrics_from] + allow = set(allowed) if allowed is not None else None + entries = [e for e in (ledger or []) if isinstance(e, dict)] + catalog: dict[str, dict[str, Any]] = {} + + sources = _metric_sources(nodes, entries, metrics_from, allow) + seen: dict[str, int] = {} + for _, payload, _ in sources: + for metric in payload.get("metrics") or []: + mid = str(metric.get("id") or "") + seen[mid] = seen.get(mid, 0) + 1 + for node_id, payload, artifact in sources: + for metric in payload.get("metrics") or []: + mid = str(metric.get("id") or "") + if not mid: + continue + alias = f"m:{mid}" if seen[mid] == 1 else f"m:{node_id}.{mid}" + if alias in catalog: + continue + try: + rendered = render_metric(metric) + except RenderError: + rendered = MISSING + locator = {"metric": mid} + catalog[alias] = { + "alias": alias, "kind": "metric", "eid": make_eid("metric", artifact, locator), + "node_id": node_id, "artifact": artifact, "locator": locator, + "caliber": payload.get("caliber") or "", "version": payload.get("caliber_version") or "", + "name": metric.get("name") or mid, "unit": metric.get("unit") or "", + "decimals": metric.get("decimals"), "format": metric.get("format") or DEFAULT_FORMAT, + "value": metric.get("value"), + "status": metric.get("status") or ("ok" if metric.get("value") is not None else "missing_input"), + "rendered": rendered, "label": f"{metric.get('name') or mid} = {rendered}", + } + + queries = retrievals = 0 + for entry in entries: + if allow is not None and entry.get("node_id") not in allow: + continue + if entry.get("kind") == "query": + queries += 1 + alias, kind = f"Q{queries}", "query" + elif entry.get("kind") == "retrieval": + retrievals += 1 + alias, kind = f"K{retrievals}", "retrieval" + else: + continue + rows = entry.get("rows") + catalog[alias] = { + "alias": alias, "kind": kind, "eid": make_eid(kind, entry.get("artifact"), {}), + "locator": {}, + **{k: entry[k] for k in ("node_id", "exec", "artifact", "via", "call_id", "tool", "source", + "columns", "rows", "truncated") if k in entry}, + "label": f"{entry.get('tool') or entry.get('source') or kind}" + + (f" · {rows} 行" if isinstance(rows, int) else ""), + } + + for key, value in (inputs or {}).items(): + if not isinstance(key, str) or not key or isinstance(value, (dict, list, tuple)): + continue + locator = {"field": key} + rendered = render_number(value, fmt="plain") if value not in (None, "") else MISSING + catalog[f"i:{key}"] = { + "alias": f"i:{key}", "kind": "input", "eid": input_eid(key, value), + "locator": locator, "value": value, "rendered": rendered, "label": f"{key} = {rendered}", + } + return catalog + + +# -------------------------------------------------------------------------- +# 解析一个引用 +# -------------------------------------------------------------------------- + + +def _unresolved(marker: dict[str, Any], alias: str, ev_kind: str, reason: str, *, + rendered: str | None = None) -> dict[str, Any]: + """解析不了的引用。rendered 是正文里显示的字:缺值显示「—」,其余是占位 ⟦?m:gmvx⟧。""" + kind, body = marker["kind"], marker.get("body", "") + return {"ref": marker.get("ref") or "", "alias": alias, "kind": ev_kind, + "role": _ROLE.get(kind, "value"), "status": "unresolved", "reason": reason, + "rendered": rendered if rendered is not None else placeholder(kind, body)} + + +def _metric_alias(ref: str, catalog: dict[str, Any]) -> tuple[str | None, str]: + alias = f"m:{ref}" + if alias in catalog: + return alias, "" + doubles = sorted(a for a in catalog if a.startswith("m:") and a.endswith(f".{ref}")) + if doubles: + return None, (f"指标 {ref} 同时出现在好几张口径卡里,要写成带卡名的形式:" + + "、".join(f"[[{a}]]" for a in doubles[:4])) + return None, f"目录里没有指标 {ref}(能用的指标见证据目录)" + + +def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]: + """行内标记(m / i / v / t / c / q / table)→ Citation,带 rendered(正文里显示的字)。""" + kind = marker["kind"] + ref = marker.get("ref") or "" + if kind in LATER_KINDS: + alias = {"t": f"t:{ref}", "c": f"c:{ref}"}.get(kind, ref.split(".")[0]) + ev_kind = {"v": "cell", "t": "table", "c": "column", "q": "quote", "table": "query"}[kind] + return _unresolved(marker, alias, ev_kind, LATER_REASON) + if kind not in ("m", "i"): + return _unresolved(marker, f"{kind}:{ref}", kind, + f"不认识的引用类型 {kind}:本期只支持 m(口径卡指标)、i(运行输入)、see(依据)") + + conv = marker.get("conv") + if kind == "m": + ev_kind = "metric" + alias, why = _metric_alias(ref, catalog) + if alias is None: + return _unresolved(marker, f"m:{ref}", ev_kind, why) + entry = catalog[alias] + if entry.get("value") is None: + return _unresolved(marker, alias, ev_kind, f"指标「{entry.get('name') or ref}」这次没有值(缺输入)," + "不能写进报告", rendered=MISSING) + try: + rendered = render_metric(entry, conv) + except RenderError as e: + return _unresolved(marker, alias, ev_kind, str(e)) + else: + ev_kind, alias = "input", f"i:{ref}" + entry = catalog.get(alias) + if entry is None: + return _unresolved(marker, alias, ev_kind, f"运行输入里没有 {ref}") + if entry.get("value") in (None, ""): + return _unresolved(marker, alias, ev_kind, f"运行输入 {ref} 是空的", rendered=MISSING) + try: + rendered = render_number(entry["value"], fmt="plain", conv=conv) + except RenderError as e: + return _unresolved(marker, alias, ev_kind, str(e)) + cite = {"ref": ref, "alias": alias, "locator": dict(entry.get("locator") or {}), "eid": entry["eid"], + "kind": ev_kind, "role": "value", "status": "resolved", "value": entry.get("value"), + "rendered": rendered} + if conv: + cite["conv"] = conv + return cite + + +_ROW_REF = re.compile(r"^(?P[QK]\d+)(?:\.r(?P\d+))?$") + + +def resolve_support(ref: str, catalog: dict[str, Any]) -> dict[str, Any]: + """[[see:]] 里的一个依据:m:gmv、i:week、Q4、Q4.r0、K2。只核对「这件证据存在」。""" + ref = ref.strip() + kind, sep, body = ref.partition(":") + base = {"ref": ref, "role": "support"} + if sep and kind in LATER_KINDS: + return {**base, "alias": ref, "kind": kind, "status": "unresolved", "reason": LATER_REASON} + alias: str | None = None + locator: dict[str, Any] = {} + why = f"目录里没有 {ref}" + if sep and kind == "m": + alias, why = _metric_alias(body, catalog) + elif sep and kind == "i": + alias = ref if ref in catalog else None + why = f"运行输入里没有 {body}" + elif sep: + why = f"不认识的依据写法 {ref}:写 m:指标、i:输入,或者 Q1 / K1 这样的编号" + elif m := _ROW_REF.match(ref): + alias = m.group("alias") if m.group("alias") in catalog else None + if alias and m.group("row") is not None: + row, total = int(m.group("row")), catalog[alias].get("rows") + if isinstance(total, int) and row >= total: + alias, why = None, f"{m.group('alias')} 只有 {total} 行,没有第 {row} 行(从 0 数)" + else: + locator = {"row": row} + elif f"m:{ref}" in catalog: + alias = f"m:{ref}" # 漏写了 m: 前缀的指标 id,意思没有歧义 + if alias is None: + return {**base, "alias": ref, "kind": kind if sep else "unknown", "status": "unresolved", + "reason": why} + entry = catalog[alias] + return {**base, "alias": alias, "kind": entry["kind"], "status": "resolved", "eid": entry["eid"], + "locator": {**(entry.get("locator") or {}), **locator}} + + +def resolve_ref(ref: str, catalog: dict[str, Any]) -> dict[str, Any]: + """片段上记的 ref(m:gmv|万、i:week)→ Citation。证据接口点开片段时用它重新解析。""" + return resolve_marker(_parse_ref(ref), catalog) + + +def render_markers(text: str, catalog: dict[str, Any]) -> str: + """把一段文本里的标记全部换成字:m / i 渲染成值,see 去掉,解析不了的换成占位。""" + out: list[str] = [] + last = 0 + for marker in parse_markers(text): + out.append(text[last:marker["start"]]) + if marker["kind"] != "see": + out.append(resolve_marker(marker, catalog)["rendered"]) + last = marker["end"] + out.append(text[last:]) + return "".join(out) + + +# -------------------------------------------------------------------------- +# 流式渲染:让 llm.token 里出现的就是最终的数字 +# -------------------------------------------------------------------------- + +def _safe_cut(text: str) -> int: + """能放出去的前缀有多长:最后一个还可能长成标记的 [[ 之前都能放。 + + 一个标记最长 MARKER_MAX 个字,所以没闭合的时候最多攒 MARKER_MAX - 1 = _HOLD_LIMIT 个字; + 超过了、或者碰到换行,它就不可能再是标记。末尾单独一个 [ 也要留着——下一块的 + 开头可能是另一个 [。 + """ + start = text.rfind("[[") + if start != -1 and text.find("]]", start) == -1: + tail = text[start:] + if len(tail) <= _HOLD_LIMIT and "\n" not in tail: + return start + if text.endswith("["): + return len(text) - 1 + return len(text) + + +class StreamRenderer: + """边收模型的 token 边渲染:缓冲到没有未闭合的 [[ 为止,再把这一段里的标记换成字。 + + renderer = StreamRenderer(catalog) + async for chunk in model.astream(messages): + if text := renderer.feed(message_text(chunk)): + ctx.emit(EventType.LLM_TOKEN, delta=text) + if tail := renderer.flush(): + ctx.emit(EventType.LLM_TOKEN, delta=tail) + + 所有 feed / flush 的输出拼起来,等于 render_markers(全文),不论模型怎么分块: + 整篇解析也只认 MARKER_MAX 以内的标记,超长的两边都原样当文字。 + """ + + def __init__(self, catalog: dict[str, Any]) -> None: + self.catalog = catalog + self.pending = "" + + def feed(self, delta: str) -> str: + self.pending += delta or "" + cut = _safe_cut(self.pending) + ready, self.pending = self.pending[:cut], self.pending[cut:] + return render_markers(ready, self.catalog) if ready else "" + + def flush(self) -> str: + ready, self.pending = self.pending, "" + return render_markers(ready, self.catalog) if ready else "" + + +# -------------------------------------------------------------------------- +# 切块:和前端 Markdown.tsx 认同一套块语法(标题、段落、列表、表格、引用、代码、分隔线) +# +# 在模型的原文(带标记)上切,而不是在渲染后的正文上切:一个指标值渲染出来恰好是 +# 「3. 」开头,不能因此把一句话当成有序列表。 +# -------------------------------------------------------------------------- + +_FENCE = re.compile(r"^\s*```([A-Za-z0-9_]*)\s*$") +_HEADING = re.compile(r"^(#{1,6})\s+(.*)$") +_HR = re.compile(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$") +_BULLET = re.compile(r"^(\s*)[-*+]\s+(.*)$") +_ORDERED = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$") +_QUOTE = re.compile(r"^(\s*>\s?)(.*)$") +_TABLE_ROW = re.compile(r"^\s*\|(.+)\|\s*$") +_TABLE_SEP = re.compile(r"^\s*\|[\s:|-]+\|\s*$") + + +def _is_block_start(line: str) -> bool: + return bool(_FENCE.match(line) or _HR.match(line) or _HEADING.match(line) or _BULLET.match(line) + or _ORDERED.match(line) or _QUOTE.match(line) or _TABLE_ROW.match(line)) + + +class _Item: + """一个将来的 unit(或者一段要再切成句子的内容):原文上的若干片 (s|c, start, end)。""" + + __slots__ = ("pieces", "pos", "split", "kind", "loc", "depth") + + def __init__(self, pieces: list[tuple[str, int, int]], pos: int, *, split: bool = False, + kind: str | None = None, loc: dict[str, int] | None = None, depth: int | None = None): + self.pieces, self.pos, self.split, self.kind = pieces, pos, split, kind + self.loc, self.depth = loc, depth + + +class _Block: + __slots__ = ("type", "meta", "items") + + def __init__(self, type_: str, meta: dict[str, Any], items: list[_Item]): + self.type, self.meta, self.items = type_, meta, items + + +def _scan_blocks(text: str, protected: list[tuple[int, int]]) -> list[_Block]: + lines = text.split("\n") + starts: list[int] = [] + acc = 0 + for line in lines: + starts.append(acc) + acc += len(line) + 1 + + def end(i: int) -> int: + return starts[i] + len(lines[i]) + + blocks: list[_Block] = [] + i, n = 0, len(lines) + while i < n: + line = lines[i] + if not line.strip(): + i += 1 + continue + + fence = _FENCE.match(line) + if fence: + j = i + 1 + while j < n and not _FENCE.match(lines[j]): + j += 1 + body = [("c", starts[i + 1], end(j - 1))] if j > i + 1 else [] + last = min(j, n - 1) + blocks.append(_Block("code", {"lang": fence.group(1)}, + [_Item(body, body[0][1] if body else end(last), kind="code")])) + i = j + 1 + continue + + if _HR.match(line): + blocks.append(_Block("hr", {}, [_Item([], end(i), kind="connective")])) + i += 1 + continue + + heading = _HEADING.match(line) + if heading: + cs = starts[i] + heading.start(2) + pieces = [("s", starts[i], cs)] + ([("c", cs, end(i))] if cs < end(i) else []) + blocks.append(_Block("heading", {"level": len(heading.group(1))}, + [_Item(pieces, starts[i], kind="heading")])) + i += 1 + continue + + if _TABLE_ROW.match(line) and i + 1 < n and _TABLE_SEP.match(lines[i + 1]): + items = _table_cells(lines[i], starts[i], -1, protected) + i += 2 + row = 0 + while i < n and _TABLE_ROW.match(lines[i]): + items.extend(_table_cells(lines[i], starts[i], row, protected)) + row += 1 + i += 1 + blocks.append(_Block("table", {}, items)) + continue + + if _QUOTE.match(line): + pieces: list[tuple[str, int, int]] = [] + first = i + while i < n and (q := _QUOTE.match(lines[i])): + if i > first: + pieces.append(("c", starts[i] - 1, starts[i])) # 行与行之间的换行属于正文 + cs = starts[i] + len(q.group(1)) + pieces.append(("s", starts[i], cs)) + if cs < end(i): + pieces.append(("c", cs, end(i))) + i += 1 + blocks.append(_Block("quote", {}, [_Item(pieces, starts[first], split=True)])) + continue + + bullet, ordered = _BULLET.match(line), _ORDERED.match(line) + if bullet or ordered: + items = [] + meta: dict[str, Any] = {"ordered": bool(ordered)} + if ordered: + meta["start"] = int(ordered.group(2)) + while i < n: + b = _BULLET.match(lines[i]) + o = None if b else _ORDERED.match(lines[i]) + if b or o: + m = b or o + cs = starts[i] + m.start(2 if b else 3) + pieces = [("s", starts[i], cs)] + ([("c", cs, end(i))] if cs < end(i) else []) + items.append(_Item(pieces, starts[i], + depth=len(m.group(1).replace("\t", " ")) // 2)) + i += 1 + elif lines[i].strip() and not _is_block_start(lines[i]) and items: + # 悬挂缩进的续行接到上一项:换行算正文,行首缩进算结构(Markdown.tsx 会 trim 掉) + lead = len(lines[i]) - len(lines[i].lstrip()) + stop = len(lines[i].rstrip()) + items[-1].pieces.append(("c", starts[i] - 1, starts[i])) + if lead: + items[-1].pieces.append(("s", starts[i], starts[i] + lead)) + items[-1].pieces.append(("c", starts[i] + lead, starts[i] + stop)) + i += 1 + else: + break + blocks.append(_Block("list", meta, items)) + continue + + first = i + i += 1 + while i < n and lines[i].strip() and not _is_block_start(lines[i]): + i += 1 + blocks.append(_Block("paragraph", {}, [_Item([("c", starts[first], end(i - 1))], starts[first], + split=True)])) + return blocks + + +def _table_cells(line: str, base: int, row: int, protected: list[tuple[int, int]]) -> list[_Item]: + """一行表格拆成格。和 Markdown.tsx 的 splitRow 一样按 | 拆,只是标记里的 |([[m:x|万]])不算。""" + lo = len(line) - len(line.lstrip()) + hi = len(line.rstrip()) + if line[lo:lo + 1] == "|": + lo += 1 + if hi > lo and line[hi - 1] == "|": + hi -= 1 + inside = [(s - base, e - base) for s, e in protected if base <= s and e <= base + len(line)] + cuts = [p for p in range(lo, hi) if line[p] == "|" and not any(s <= p < e for s, e in inside)] + items = [] + for col, (a, b) in enumerate(zip([lo, *[c + 1 for c in cuts]], [*cuts, hi])): + ca = a + len(line[a:b]) - len(line[a:b].lstrip()) + cb = a + len(line[a:b].rstrip()) + pieces = [("c", base + ca, base + cb)] if ca < cb else [] + items.append(_Item(pieces, base + (ca if ca < cb else a), loc={"row": row, "col": col})) + return items + + +def _attach_gaps(items: list[_Item], length: int) -> bool: + """没被任何一项认领的原文(空行、块尾换行、表格分隔行、代码围栏)作为结构片挂到后一项上。 + + 挂好之后,所有项的片按顺序拼起来必须正好铺满原文——这是「片段拼起来等于正文」 + 的来源。铺不满说明切块有漏洞,返回 False,调用方退回整篇一段。 + """ + covered = sorted((s, e) for item in items for _, s, e in item.pieces if e > s) + gaps: list[tuple[int, int]] = [] + pos = 0 + for s, e in covered: + if s > pos: + gaps.append((pos, s)) + pos = max(pos, e) + if pos < length: + gaps.append((pos, length)) + if not items: + return not gaps + positions = [item.pos for item in items] + for s, e in gaps: + k = bisect.bisect_left(positions, e) + (items[k] if k < len(items) else items[-1]).pieces.append(("s", s, e)) + for item in items: + item.pieces = sorted((p for p in item.pieces if p[2] > p[1]), key=lambda p: p[1]) + pos = 0 + for item in items: + for _, s, e in item.pieces: + if s != pos: + return False + pos = e + return pos == length + + +# -------------------------------------------------------------------------- +# 切句:段落和引用按句子切成 unit;[[see:]] 挂在它前面那一句 +# -------------------------------------------------------------------------- + +_TERMINATOR = re.compile(r"(?:[。!?!?]+|\.(?=\s|$)|\n)[”’\"'))」』】》]*") +_SEE_TAIL = re.compile(r"(?:[ \t\u3000]*\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*\s*") +#: 和 Markdown.tsx 的 INLINE 同一套:句号落在代码、链接、粗体里面时不切,不然一对 ** 会被拆到两句 +_INLINE = re.compile("|".join([ + r"(`+)[^`]+?\1", + r"\[[^\]]+\]\([^)\s]+[^)]*\)", + r"\*\*[^*]+?\*\*", + r"__[^_]+?__", + r"(? list[list[tuple[str, int, int]]]: + content = [(s, e) for kind, s, e in item.pieces if kind == "c"] + if not item.split or not content: + return [item.pieces] + # 把内容片接成一条虚拟文本 V,记下 V 的每个位置对应原文哪里 + v_parts, v_map = [], [] + for s, e in content: + v_map.append((sum(len(p) for p in v_parts), s, e)) + v_parts.append(text[s:e]) + virtual = "".join(v_parts) + + def to_raw(v: int) -> int: # V 里第 v 个字符之前的那个边界,落在原文哪里 + for v0, s, e in v_map: + if v0 < v <= v0 + (e - s): + return s + (v - v0) + return content[0][0] + + def to_v(raw: int) -> int | None: + for v0, s, e in v_map: + if s <= raw <= e: + return v0 + (raw - s) + return None + + protected = [(m.start(), m.end()) for m in _INLINE.finditer(virtual)] + for marker in markers: + a, b = to_v(marker["start"]), to_v(marker["end"]) + if a is not None and b is not None: + protected.append((a, b)) + + cuts: list[int] = [] + for m in _TERMINATOR.finditer(virtual): + if any(s < m.start() < e or s < m.end() < e for s, e in protected): + continue + cut = _SEE_TAIL.match(virtual, m.end()).end() + if 0 < cut < len(virtual) and not any(s < cut < e for s, e in protected): + cuts.append(cut) + cuts = sorted(set(cuts)) + # 只剩空白或者只有 [[see:]] 的一截不单独成句,并进相邻的那一句 + kept: list[int] = [] + last = 0 + for k, cut in enumerate(cuts): + following = cuts[k + 1] if k + 1 < len(cuts) else len(virtual) + if _ONLY_SEE.match(virtual[last:cut]) or _ONLY_SEE.match(virtual[cut:following]): + continue + kept.append(cut) + last = cut + raw_cuts = [to_raw(v) for v in kept] + + groups: list[list[tuple[str, int, int]]] = [[]] + queue = list(raw_cuts) + for kind, s, e in item.pieces: + while queue and kind == "c" and s < queue[0] < e: + groups[-1].append((kind, s, queue[0])) + groups.append([]) + s = queue.pop(0) + if queue and s >= queue[0]: + groups.append([]) + queue.pop(0) + groups[-1].append((kind, s, e)) + return [g for g in groups if g] + + +# -------------------------------------------------------------------------- +# 裸数字 +# -------------------------------------------------------------------------- + +_STRUCT_BEFORE = re.compile(r"(?:第|前|后|首|末|近|最近|Top|TOP|top|No\.|NO\.)\s*$") +_STRUCT_AFTER = re.compile( + r"\s*(?:个(?:方面|原因|问题|建议|要点|维度|阶段|步骤|部分|层面|角度|环节|关键|特点|措施)" + r"|点(?:建议|原因|看法)|条建议|步)") + + +def _structural_number(text: str, token: Any) -> bool: + """「前 3 名」「第 2 季度」「从 3 个方面看」「3 月」:序号和结构,不是结论数字。限 20 以内。""" + value = token.value + if token.decimals or token.is_percent or not float(value).is_integer() or not 0 < value <= 20: + return False + before, after = text[max(0, token.start - 6):token.start], text[token.end:token.end + 8] + if _STRUCT_BEFORE.search(before) or _STRUCT_AFTER.match(after): + return True + # 行文中间的列举:(1)…(2)…、1、…2、…(行首的「1. 」「2、」出具校验本来就放过) + if (re.search(r"[((]\s*$", before) and re.match(r"\s*[))]", after)) or after.startswith("、"): + return True + return bool((value <= 12 and re.match(r"\s*月", after)) + or (value <= 31 and re.match(r"\s*[日号]", after)) + or (value <= 4 and re.match(r"\s*季度", after))) + + +#: 结构片段涂白时只涂 Markdown 符号本身,数字和字母原样留着:「100. 」前端照样显示成 +#:
    ,「```45678」的语言标签显示在代码块头上——这些数字和正文过同一套规则 +#: (1–2 位的行首序号照旧放过,py3 这类标识符照旧不算)。表格竖线涂成一个不是空白的字: +#: 表格第一格里的「1.」不在行首,不能沾行首序号免检的光 +_SYNTAX_BLANK = {**{c: " " for c in "`>#*+-~=:"}, "|": "\u00a6"} + + +def _bare_numbers(markdown: str, masks: list[tuple[int, int]], allow: list[Any] | None, + syntax: list[tuple[int, int]] = ()) -> list[Any]: + """正文里没有出处的数字,再用出具校验同一套规则抽数字。 + + - masks:引用渲染出来的字,整段涂白(保留换行,行首序号的免检规则要认得行首) + - syntax:结构片段,只涂 Markdown 符号(见 _SYNTAX_BLANK),里面的数字照样要查 + """ + chars = list(markdown) + for s, e in masks: + for k in range(max(s, 0), min(e, len(chars))): + if chars[k] != "\n": + chars[k] = " " + for s, e in syntax: + for k in range(max(s, 0), min(e, len(chars))): + chars[k] = _SYNTAX_BLANK.get(chars[k], chars[k]) + masked = "".join(chars) + allowed = number_allowance(allow) + return [t for t in extract_numbers(masked) if not allowed(t) and not _structural_number(masked, t)] + + +def _context(text: str, start: int, end: int) -> str: + return text[max(0, start - 18):min(len(text), end + 18)].replace("\n", " ") + + +# -------------------------------------------------------------------------- +# 组装文档 +# -------------------------------------------------------------------------- + +#: 粗体:**…** 或 __…__,里面可以有标记(标记里的下划线不算收尾,[[m:refund_rate]] 很常见) +_BOLD = re.compile(r"(\*\*|__)(?P(?:\[\[" + _MARKER_GUARD + r"[^\[\]\n]*?\]\]|(?!\1)[^\n])+?)\1") +_DIRECTIONAL = re.compile( + r"增长|增加|上升|提升|提高|上涨|下降|下滑|减少|降低|回落|下跌|反弹|超过|高于|低于|领先|落后|最高|最低" + r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|环比|同比|翻倍|持平|波动" + r"|(? tuple[str, list[tuple[int, int]]]: + """包着引用标记的粗体去掉星号,记下哪一段要加粗:**销售额 [[m:gmv]]** → 销售额 [[m:gmv]]。 + + 星号留在正文里的话,数字一切成单独的片段,两边的文字片段各剩半对 **,前端只能 + 原样显示星号。不含标记的粗体原样留着,它整个落在一个文字片段里,前端照常渲染。 + """ + out: list[str] = [] + ranges: list[tuple[int, int]] = [] + last = size = 0 + for m in _BOLD.finditer(raw): + inner = m.group("inner") + if not MARKER_RE.search(inner): + continue + out.append(raw[last:m.start()]) + size += m.start() - last + ranges.append((size, size + len(inner))) + out.append(inner) + size += len(inner) + last = m.end() + out.append(raw[last:]) + return "".join(out), ranges + + +def _classify(kind: str | None, segments: list[dict[str, Any]], see: list[dict[str, Any]]) -> str: + """unit 的种类。本期没有裁判,按确定的规则分:引用了证据、写了数字、或者有方向词、 + 因果词的算结论;其余的是连接性的话。""" + if kind: + return kind + if see or any(s["kind"] != "text" and s["kind"] != "structural" for s in segments): + return "claim" + if any(s["kind"] == "text" and _DIRECTIONAL.search(s["text"]) for s in segments): + return "claim" + return "connective" + + +def compose_doc( + raw: str, + catalog: dict[str, Any], + *, + node_id: str = "", + run_id: str | None = None, + allow_numbers: list[Any] | None = None, +) -> dict[str, Any]: + """模型写的原文(带标记)→ 报告文档:渲染后的 markdown、块、句、片段、统计、违规。 + + stats / violations 由 verify_doc 算出,和出口契约复核用的是同一个函数。 + """ + source = (raw or "").replace("\r\n", "\n").replace("\r", "\n").strip() + text, strong = _normalize_strong(source) + markers = parse_markers(text) + for marker in markers: + marker["strong"] = any(a <= marker["start"] and marker["end"] <= b for a, b in strong) + + blocks = _scan_blocks(text, [(m["start"], m["end"]) for m in markers]) + items = [item for block in blocks for item in block.items] + if not _attach_gaps(items, len(text)): + # 切块出了漏洞:整篇当一段,宁可版式粗糙,也不能让哪个字落在片段外面没人查 + blocks = [_Block("paragraph", {}, [_Item([("c", 0, len(text))], 0, split=True)])] if text else [] + items = [item for block in blocks for item in block.items] + _attach_gaps(items, len(text)) + + parts: list[str] = [] + size = 0 + out_blocks: list[dict[str, Any]] = [] + all_segments: list[dict[str, Any]] = [] + + def push(seg: dict[str, Any], segments: list[dict[str, Any]]) -> None: + nonlocal size + prev = segments[-1] if segments else None + if prev is not None and seg["kind"] in ("text", "structural") and prev["kind"] == seg["kind"] \ + and prev.get("strong") == seg.get("strong") and prev["span"][1] == size: + prev["text"] += seg["text"] + prev["span"][1] += len(seg["text"]) + else: + seg["span"] = [size, size + len(seg["text"])] + segments.append(seg) + parts.append(seg["text"]) + size += len(seg["text"]) + + for block in blocks: + units: list[dict[str, Any]] = [] + for item in block.items: + for pieces in _split_sentences(item, text, markers): + segments: list[dict[str, Any]] = [] + see: list[dict[str, Any]] = [] + anchor = size + for kind, s, e in pieces: + if kind == "s": + push({"kind": "structural", "text": text[s:e], "state": "neutral"}, segments) + continue + def plain(a: int, b: int) -> None: + # 文字按粗体的边界再切一刀:一半加粗一半不加粗的不能并成一段 + cuts = sorted({a, b, *(x for r in strong for x in r if a < x < b)}) + for x, y in zip(cuts, cuts[1:]): + seg = {"kind": "text", "text": text[x:y], "state": "neutral"} + if any(p <= x and y <= q for p, q in strong): + seg["strong"] = True + push(seg, segments) + + cursor = s + for marker in markers: + if marker["start"] < s or marker["end"] > e: + continue + if cursor < marker["start"]: + plain(cursor, marker["start"]) + cursor = marker["end"] + if marker["kind"] == "see": + see.extend(resolve_support(r, catalog) for r in marker["refs"]) + continue + cite = resolve_marker(marker, catalog) + ok = cite["status"] == "resolved" + seg = { + "kind": _SEG_KIND.get(marker["kind"]) + or ("number" if marker["kind"] == "m" or (ok and is_number(cite.get("value"))) + else "value"), + "text": cite["rendered"], "ref": f"{marker['kind']}:{marker['body']}", + "cite": cite, "state": "deterministic" if ok else "none", + } + if not ok: + seg["issue"] = "unresolved_ref" + if marker["strong"]: + seg["strong"] = True + push(seg, segments) + if cursor < e: + plain(cursor, e) + unit: dict[str, Any] = {"kind": item.kind, "segments": segments, "see": see, + "_anchor": anchor} + if item.loc is not None: + unit["loc"] = dict(item.loc) + if item.depth is not None: + unit["depth"] = item.depth + units.append(unit) + all_segments.extend(segments) + out_blocks.append({"type": block.type, **block.meta, "units": units}) + + markdown = "".join(parts) + # 裸数字单独切成片段:前端要在那几个字底下画「无出处」 + refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + syntax = [tuple(s["span"]) for s in all_segments if s["kind"] == "structural"] + bare = _bare_numbers(markdown, refs, allow_numbers, syntax) + for block in out_blocks: + for unit in block["units"]: + unit["segments"] = _split_bare(unit["segments"], bare) + + seg_no = unit_no = 0 + for b_no, block in enumerate(out_blocks): + block["id"] = f"b{b_no}" + for unit in block["units"]: + unit["id"] = f"u{unit_no}" + unit_no += 1 + for seg in unit["segments"]: + seg["id"] = f"s{seg_no}" + seg_no += 1 + body = [s for s in unit["segments"] if s["kind"] != "structural"] + anchor = unit.pop("_anchor") + unit["span"] = [body[0]["span"][0], body[-1]["span"][1]] if body else [anchor, anchor] + unit["kind"] = _classify(unit["kind"], unit["segments"], unit["see"]) + unit["cites"] = list(dict.fromkeys( + c["alias"] for c in [*(s["cite"] for s in unit["segments"] if s.get("cite")), *unit["see"]] + if c["status"] == "resolved")) + # 字段顺序固定,文档内容寻址,同样的输入要得到同样的哈希 + ordered = {k: unit[k] for k in ("id", "kind", "span", "cites", "see", "segments", "loc", "depth") + if k in unit} + unit.clear() + unit.update(ordered) + + doc: dict[str, Any] = { + "schema": DOC_SCHEMA, "run_id": run_id, "node_id": node_id, + "markdown": markdown, "source": source, "catalog": catalog, "blocks": out_blocks, + } + checked = verify_doc(doc, catalog, allow_numbers=allow_numbers) + doc["stats"], doc["violations"] = checked["stats"], checked["violations"] + return doc + + +def _split_bare(segments: list[dict[str, Any]], bare: list[Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for seg in segments: + s0, e0 = seg["span"] + hits = [t for t in bare if s0 <= t.start < e0] if seg["kind"] == "text" else [] + if not hits: + out.append(seg) + continue + extra = {"strong": True} if seg.get("strong") else {} + + def piece(a: int, b: int, **fields: Any) -> None: + out.append({"kind": "text", "text": seg["text"][a - s0:b - s0], "span": [a, b], + "state": seg["state"], **extra, **fields}) + + cursor = s0 + for token in hits: + end = min(token.end, e0) + if cursor < token.start: + piece(cursor, token.start) + piece(token.start, end, kind="number", state="none", issue="uncited_number") + cursor = end + if cursor < e0: + piece(cursor, e0) + return out + + +# -------------------------------------------------------------------------- +# 复核:不信任文档自己的说法,逐项重算 +# -------------------------------------------------------------------------- + +_STRUCTURAL_REST = re.compile(r"^[\s|>#*+\-_:~=.]*$") + + +def _is_structural(text: str) -> bool: + """结构片段里只能有 Markdown 语法:标题井号、列表符号和序号、引用号、表格竖线、围栏。 + + 这里只回答「是不是 Markdown 语法」,不回答「里面的数字要不要出处」——后者交给裸数字 + 检查(结构片段只涂符号,数字照查,见 _SYNTAX_BLANK)。所以行首序号不限位数,和切块的 + _ORDERED、前端 Markdown.tsx 的 ORDERED 一致:「100. 」在前端就是
      ,把它 + 判成夹带内容会变成完整性问题(出口记 gap),而它真正的问题是一个没有出处的数字。 + """ + rest = re.sub(r"`{3,}[A-Za-z0-9_]*", "", text) + rest = re.sub(r"(?m)^[ \t]*\d+[.)](?=\s)", "", rest) + return bool(_STRUCTURAL_REST.match(rest)) + + +def iter_units(doc: dict[str, Any]): + for block in doc.get("blocks") or []: + for unit in block.get("units") or []: + yield block, unit + + +def iter_segments(doc: dict[str, Any]): + """按正文顺序给出全部片段(含结构片段)。""" + for _, unit in iter_units(doc): + yield from unit.get("segments") or [] + + +def find_segment(doc: dict[str, Any], segment_id: str) -> tuple[dict, dict, dict] | None: + """(block, unit, segment),找不到返回 None。""" + for block, unit in iter_units(doc): + for seg in unit.get("segments") or []: + if seg.get("id") == segment_id: + return block, unit, seg + return None + + +def _violation(code: str, message: str, **extra: Any) -> dict[str, Any]: + return {"code": code, "message": message, **{k: v for k, v in extra.items() if v is not None}} + + +def verify_doc( + doc: dict[str, Any], + catalog: dict[str, Any], + *, + allow_numbers: list[Any] | None = None, +) -> dict[str, Any]: + """逐项复核一份报告文档:{ok, violations, stats}。 + + 报告节点自查用它(违规就让写作者重写),出口契约复核也用它(判档)——后者传的 + 是自己从状态里重建的目录,不用文档里存的那份。复核什么: + + - 片段按顺序拼起来正好是 markdown(没有字落在片段外面) + - 结构片段里只有 Markdown 语法 + - 每个带引用的片段:按给定目录重新解析、重新渲染,和片段上的字逐字相等,eid 一致 + - [[see:]] 里的每个依据都能解析 + - 引用涂白、结构片段只涂符号之后,正文里剩下的数字全部算裸数字(结构片段里的 + 「100. 」「```45678」也在内) + - 片段标的状态和核对结果一致(界面按状态画线,不能标着「有出处」其实没有) + """ + violations: list[dict[str, Any]] = [] + stats = {"units": 0, "segments": 0, "claims": 0, "connective": 0, "headings": 0, "numbers": 0, + "numbers_cited": 0, "values": 0, "uncited_numbers": 0, "unresolved": 0, "see": 0, + "uncited_claims": 0, "violations": 0} + if not isinstance(doc, dict): + violations.append(_violation("bad_schema", "报告文档不是一个对象")) + return {"ok": False, "violations": violations, "stats": stats} + if doc.get("schema") != DOC_SCHEMA: + violations.append(_violation("bad_schema", f"不认识的报告文档格式 {doc.get('schema')!r}")) + + markdown = str(doc.get("markdown") or "") + masks: list[tuple[int, int]] = [] # 引用渲染出来的字 + syntax: list[tuple[int, int]] = [] # 结构片段:只涂符号,数字照查 + owners: list[tuple[int, int, str, str]] = [] # (start, end, segment id, unit id) + pos, tiled = 0, True + for _, unit in iter_units(doc): + stats["units"] += 1 + kind = unit.get("kind") + if kind in ("claim", "connective"): + stats["claims" if kind == "claim" else "connective"] += 1 + elif kind == "heading": + stats["headings"] += 1 + cited: list[str] = [] + for seg in unit.get("segments") or []: + stats["segments"] += 1 + text, span = str(seg.get("text") or ""), seg.get("span") + intact = (isinstance(span, list) and len(span) == 2 and all(isinstance(x, int) for x in span) + and markdown[span[0]:span[1]] == text and span[1] - span[0] == len(text)) + if tiled and (not intact or span[0] != pos): + tiled = False + violations.append(_violation("segment_mismatch", + f"片段 {seg.get('id')} 和正文对不上:文档可能被改过", + segment=seg.get("id"), unit=unit.get("id"))) + if intact: + pos = span[1] + owners.append((span[0], span[1], seg.get("id"), unit.get("id"))) + where = {"span": list(span) if intact else None, "text": text, "segment": seg.get("id"), + "unit": unit.get("id")} + state, expected = seg.get("state"), None + found = len(violations) + if seg.get("kind") == "structural": + expected = "neutral" + if intact and _is_structural(text): + syntax.append((span[0], span[1])) + else: + violations.append(_violation("structural_text", + f"结构片段里夹带了内容「{text[:30]}」", **where)) + elif seg.get("ref"): + ref = str(seg["ref"]) + cite = resolve_ref(ref, catalog) + ok = cite["status"] == "resolved" + matches = cite["rendered"] == text + if intact: + masks.append((span[0], span[1])) + if not ok: + violations.append(_violation("unresolved_ref", f"引用 [[{ref}]] 解析不了:{cite['reason']}", + ref=ref, **where)) + elif not matches: + violations.append(_violation( + "render_mismatch", f"片段「{text}」和引用 [[{ref}]] 重新渲染出来的「{cite['rendered']}」" + "对不上", ref=ref, **where)) + elif (seg.get("cite") or {}).get("eid") not in (None, cite["eid"]): + violations.append(_violation("eid_mismatch", f"引用 [[{ref}]] 记的证据标识和目录里的不一致," + "文档可能被改过", ref=ref, **where)) + good = ok and matches + expected = "deterministic" if good else "none" + if seg.get("kind") == "number": + stats["numbers"] += 1 + stats["numbers_cited"] += int(good) + elif good: + stats["values"] += 1 + if ok: + cited.append(cite["alias"]) + elif seg.get("kind") == "number": + expected = "none" + elif state == "deterministic": + expected = "neutral" + # 已经为这一段报过别的问题,状态不对是它的推论,不再重复 + if expected is not None and state != expected and len(violations) == found: + violations.append(_violation("state_mismatch", + f"片段「{text[:30]}」标的状态是 {state},核对下来应该是 {expected}", + **where)) + for support in unit.get("see") or []: + stats["see"] += 1 + again = resolve_support(str(support.get("ref") or ""), catalog) + if again["status"] == "resolved": + cited.append(again["alias"]) + else: + violations.append(_violation("unresolved_ref", + f"依据 [[see:{again['ref']}]] 解析不了:{again['reason']}", + ref=again["ref"], unit=unit.get("id"), + span=list(unit.get("span") or []) or None)) + if kind == "claim" and not cited: + stats["uncited_claims"] += 1 + if tiled and pos != len(markdown): + violations.append(_violation("segment_mismatch", "正文末尾有一段不在任何片段里:文档可能被改过")) + + for token in _bare_numbers(markdown, masks, allow_numbers, syntax): + owner = next(((sid, uid) for s, e, sid, uid in owners if s <= token.start < e), (None, None)) + violations.append(_violation( + "uncited_number", f"数字「{token.raw}」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + span=[token.start, token.end], text=token.raw, context=_context(markdown, token.start, token.end), + segment=owner[0], unit=owner[1])) + + order = {"bad_schema": 0, "segment_mismatch": 1} + violations.sort(key=lambda v: (order.get(v["code"], 2), (v.get("span") or [-1])[0])) + stats["uncited_numbers"] = sum(1 for v in violations if v["code"] == "uncited_number") + stats["numbers"] += stats["uncited_numbers"] + stats["unresolved"] = sum(1 for v in violations if v["code"] == "unresolved_ref") + stats["violations"] = len(violations) + return {"ok": not violations, "violations": violations, "stats": stats} + + +# -------------------------------------------------------------------------- +# 给报告节点用的文字:证据目录、写作规则、违规清单 +# -------------------------------------------------------------------------- + +MARKER_RULES = """写作规则(系统会逐字核对): +1. 任何数字都只能用引用标记写:[[m:指标id]]。要换算显示就写 [[m:指标id|万]],也可以用 |亿、|pct(比率显示成百分数)、|int(取整)、|.1(一位小数)。不要自己写数字,不要计算,不要改写数值——标记会被换成口径卡里的真实数值。 +2. 运行输入用 [[i:字段名]]。 +3. 每句陈述数据的结论,句末用 [[see:m:指标id,…]] 标出依据;过渡、连接性的话不用标。 +4. 日期、ISO 周(2026-W37)、「前 3 名」「第 2 季度」这类序号可以直接写。 +5. 目录里没有的指标不要编造引用;标着「没有值」的指标不要写进报告。""" + + +def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000) -> str: + """证据目录的文字版,放进写作者的 prompt。超过预算就截断,并照实说截断了。""" + lines = ["可引用的证据(写数字只能用下面的引用标记,系统会换成真实数值):"] + groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + for entry in catalog.values(): + if entry.get("kind") == "metric": + key = (entry.get("caliber") or "", entry.get("version") or "", entry.get("node_id") or "") + groups.setdefault(key, []).append(entry) + for (caliber, version, node_id), entries in groups.items(): + title = " @ ".join(v for v in (caliber, version) if v) or node_id + lines.append(f"\n口径卡「{title}」:") + for e in entries: + if e.get("value") is None: + lines.append(f"- [[{e['alias']}]] {e.get('name')}:这次没有值,不要引用") + elif e.get("rendered") == MISSING: + # 有值但按口径卡的格式显示不出来(太小、太大、不是有限的数):引用了也是解析不了 + lines.append(f"- [[{e['alias']}]] {e.get('name')}:按口径卡的格式显示不出来,不要引用") + else: + lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}") + inputs = [e for e in catalog.values() if e.get("kind") == "input"] + if inputs: + lines.append("\n运行输入:") + lines.extend(f"- [[{e['alias']}]] = {e.get('rendered')}" for e in inputs) + others = [e for e in catalog.values() if e.get("kind") in ("query", "retrieval")] + if others: + lines.append("\n查询与检索(本期只能在 [[see:…]] 里当依据,不能直接引用其中的数):") + for e in others: + cols = f",列 {', '.join(map(str, e['columns'][:12]))}" if e.get("columns") else "" + lines.append(f"- {e['alias']}:{e.get('label')}{cols}") + text = "\n".join(lines) + if len(text) > budget: + text = text[:budget].rsplit("\n", 1)[0] + "\n…(目录太长,后面的省略了)" + return text + + +def describe_violations(violations: list[dict[str, Any]], *, limit: int = 20) -> str: + """违规清单的文字版:给写作者的修复指令、给节点的报错,都用这一份。""" + lines = [] + for v in violations[:limit]: + where = f"(…{v['context']}…)" if v.get("context") else "" + lines.append(f"- {v['message']}{where}") + if len(violations) > limit: + lines.append(f"- …另有 {len(violations) - limit} 处") + return "\n".join(lines) diff --git a/backend/app/engine/expressions.py b/backend/app/engine/expressions.py index fcbacba0..0d9123d3 100644 --- a/backend/app/engine/expressions.py +++ b/backend/app/engine/expressions.py @@ -277,6 +277,104 @@ def parse_expression(expr: str) -> tuple[ast.Expression, list[str], list[str]]: return tree, unbrace.found, unknown +# -------------------------------------------------------------------------- +# 引用与代入:口径卡要说清「这个数是拿哪几个输入算出来的」 +# -------------------------------------------------------------------------- + + +def _chain_root(node: ast.AST) -> str | None: + """node 是不是一条从根名字出发的取值链(vars.kpi.gmv、nodes.q.rows[0])。是就返回根。""" + cur = node + while isinstance(cur, (ast.Attribute, ast.Subscript)): + cur = cur.value + if isinstance(cur, ast.Name) and cur.id in EXPRESSION_ROOTS: + return cur.id + return None + + +def _walk_refs(node: ast.AST, found: list[ast.AST]) -> None: + if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node): + found.append(node) + # 链本身不再往里拆(vars.kpi 是 vars.kpi.gmv 的一部分),但下标里的表达式是 + # 独立的输入:vars.rows[vars.i] 取哪一格由 vars.i 决定 + cur = node + while isinstance(cur, (ast.Attribute, ast.Subscript)): + if isinstance(cur, ast.Subscript): + _walk_refs(cur.slice, found) + cur = cur.value + return + for child in ast.iter_child_nodes(node): + _walk_refs(child, found) + + +def leaf_refs(tree: ast.AST) -> list[str]: + """表达式引用了哪些值:每条从根名字出发的最长取值链,按出现顺序去重。 + + parse_expression 只告诉你「有没有不认识的名字」;口径卡要记来源,得知道 + round((vars.kpi.gmv - vars.kpi.gmv_prev) / …) 里用到的是 vars.kpi.gmv 和 + vars.kpi.gmv_prev 这两个值,而不是 vars、kpi 这些零件。 + 路径用 ast.unparse 的写法,substitute 按同样的写法认。 + """ + found: list[ast.AST] = [] + _walk_refs(tree, found) + return list(dict.fromkeys(ast.unparse(n) for n in found)) + + +_SCALARS = (int, float, str, bool, type(None)) +#: 字符串长过这个数就不代进去:上游模型写的一大段文本抄进代入式(和 metric_set 工件), +#: 没人看得懂,只是把工件撑大 +_INLINE_STR_MAX = 80 + + +def _inlinable(value: Any) -> bool: + """这个值能不能以字面量的样子写进代入式。 + + 容器不行;含 {{ 或 }} 的字符串不行——parse_expression 会把整条代入式当成 + 「表达式里写了模板」拒掉,复算就成了假的「对不上」;太长的字符串也不代。 + """ + if not isinstance(value, _SCALARS): + return False + if isinstance(value, str): + return len(value) <= _INLINE_STR_MAX and "{{" not in value and "}}" not in value + return True + + +def _literal(value: Any) -> ast.expr: + """负数写成一元负号:Constant(-3) 放在幂运算左边会被 unparse 成 -3 ** 2, + 读起来和算起来都是 -(3 ** 2)。一元负号节点 unparse 时会自己加括号。""" + if isinstance(value, (int, float)) and not isinstance(value, bool) and value < 0: + return ast.UnaryOp(op=ast.USub(), operand=ast.Constant(-value)) + return ast.Constant(value) + + +class _Substitute(ast.NodeTransformer): + def __init__(self, values: dict[str, Any]) -> None: + self.values = values + + def _replace(self, node: ast.AST) -> ast.AST: + if _chain_root(node): + path = ast.unparse(node) + if path in self.values and _inlinable(self.values[path]): + return ast.copy_location(_literal(self.values[path]), node) + if path in self.values: + return node # 容器、含 {{ }} 的或太长的字符串留原路径,复算时照样取得到 + return self.generic_visit(node) + + visit_Attribute = visit_Subscript = visit_Name = _replace + + +def substitute(tree: ast.AST, values: dict[str, Any]) -> str: + """把引用换成它当时的值,得到代入式:round((45678.5 - 42010.0) / 42010.0 * 100, 1)。 + + 只代标量(含 {{ }} 或长过 80 字的字符串除外)。代入式本身还是一条合法表达式——拿同一个上下文再求一遍,结果必须和 + 原式一样,这是口径卡复算(recompute_ok)的依据。不改动传进来的语法树。 + """ + import copy + + replaced = _Substitute(values).visit(copy.deepcopy(tree)) + return ast.unparse(ast.fix_missing_locations(replaced)) + + def eval_expression(expr: str, ctx: dict[str, Any]) -> Any: """求值一个受限的 Python 表达式。 diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index 862542df..d795eb3c 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field -from typing import Any +from typing import Any, Callable # -------------------------------------------------------------------------- # 出具校验:叙述里的每个数字必须能回指到指标集 @@ -185,6 +185,11 @@ class NumberToken: #: 回指时允许的偏差。缺省按写出的小数位数算;中文数字、带万亿后缀的数有自己的书写精度 tolerance: float | None = None + @property + def end(self) -> int: + """raw 在原文里的结束位置。raw 只去掉了右侧空白,开头就是 start。""" + return self.start + len(self.raw) + @dataclass class TraceReport: @@ -345,6 +350,27 @@ def _token_matches(token: NumberToken, metric_value: float) -> bool: return False +def number_allowance(allow: list[Any] | None) -> Callable[[NumberToken], bool]: + """模板作者放行的字面量:写法相同,或者数值在这个 token 的书写精度内相等。 + + 旧契约的回指和报告节点的裸数字检查共用这一条,两边放行的范围才一致。 + """ + allowed = {str(a).strip() for a in (allow or [])} + values = set() + for a in allowed: + try: + values.add(float(a.replace(",", "").rstrip("%%"))) + except ValueError: + pass + + def ok(token: NumberToken) -> bool: + if token.raw in allowed or token.raw.rstrip("%%").strip() in allowed: + return True + return any(_close(token.value, v, _tolerance(token)) for v in values) + + return ok + + def trace_numbers( narrative: str, metrics: list[dict[str, Any]], @@ -356,40 +382,28 @@ def trace_numbers( allow 是模板作者显式放行的字面量(比如报告标题里的期数)—— 白名单是配置的一部分,进版本、被审查,而不是校验器私藏的宽容。 """ - numeric_values = [ - float(m["value"]) + # 值和指标成对取:以前值列表排掉了布尔、指标列表没排,一个布尔指标就让后面的全错位 + numeric = [ + (m, float(m["value"])) for m in metrics if isinstance(m.get("value"), (int, float)) and not isinstance(m.get("value"), bool) ] - allowed = {str(a).strip() for a in (allow or [])} - allowed_values = set() - for a in allowed: - try: - allowed_values.add(float(a.replace(",", "").rstrip("%%"))) - except ValueError: - pass - + allowed = number_allowance(allow) report = TraceReport() for token in extract_numbers(narrative): - if token.raw in allowed or token.raw.rstrip("%%").strip() in allowed: + if allowed(token): continue - if any(_close(token.value, av, _tolerance(token)) for av in allowed_values): - continue - hit = next( - ( - m - for m, mv in zip( - [m for m in metrics if isinstance(m.get("value"), (int, float))], - numeric_values, - ) - if _token_matches(token, mv) - ), - None, - ) - if hit is not None: - report.matched.append({"token": token.raw, "metric": hit.get("id")}) + where = {"start": token.start, "end": token.end} + # 同一个值可能同时对得上好几个指标(最典型的是一排兜底出来的 0)。以前取第一个 + # 命中的,界面上所有的 0 都标成「来自指标 A」——出处不唯一就照实说不唯一 + hits = list(dict.fromkeys(m.get("id") for m, mv in numeric if _token_matches(token, mv))) + if len(hits) == 1: + report.matched.append({"token": token.raw, "metric": hits[0], **where}) + elif hits: + report.matched.append({"token": token.raw, "metric": None, "candidates": hits, + "ambiguous": True, **where}) else: - report.unmatched.append({"token": token.raw, "context": token.context}) + report.unmatched.append({"token": token.raw, "context": token.context, **where}) return report @@ -400,6 +414,10 @@ def decide_tier( unmatched: list[dict[str, Any]], strict: bool, gaps: list[str] | None = None, + unresolved: list[Any] | None = None, + unsupported: list[Any] | int | None = None, + uncited_claims: list[Any] | int | None = None, + claims_policy: str | dict[str, Any] | None = None, ) -> str: """三档出具的判定。 @@ -413,11 +431,47 @@ def decide_tier( 空串、metrics_from 指向不存在的节点,都会得到一模一样的空列表。 校验没发生就盖 formal 章,比漏检某个数字更糟:它让整套机制看起来在工作。 所以 gaps 用来承载"校验为什么不完整",非空就不允许 formal。 + + 引用模式另有几样:unresolved 是解析不了的引用(报告里写了 [[m:gmvx]],目录里 + 没有),和未回指数字同等对待——那个位置上显示的不是一个有出处的数。 + unsupported / uncited_claims 是结论句的判定,只在声明了 claims_policy 时才参与: + 字符串(judge / require_citation)两样都按降档处理;对象形式可以分别写 + on_unsupported / on_uncited 为 degrade、withhold 或 ignore。不声明就不管, + 旧调用和没开结论检查的契约都不受影响。 """ if missing_required: return "withheld" - if strict and unmatched: + if strict and (unmatched or unresolved): return "withheld" - if missing_expected or unmatched or gaps: + claims = _claims_effect(claims_policy, unsupported, uncited_claims) + if claims == "withhold": + return "withheld" + if missing_expected or unmatched or unresolved or gaps or claims == "degrade": return "degraded" return "formal" + + +def _claims_effect( + policy: str | dict[str, Any] | None, + unsupported: list[Any] | int | None, + uncited: list[Any] | int | None, +) -> str | None: + """结论句对档位的影响:withhold / degrade / None。""" + if isinstance(policy, dict): + name = policy.get("policy") + on_unsupported = policy.get("on_unsupported") or "degrade" + on_uncited = policy.get("on_uncited") or "degrade" + else: + name, on_unsupported, on_uncited = policy, "degrade", "degrade" + if not name or name == "off": + return None + + def count(value: list[Any] | int | None) -> int: + return value if isinstance(value, int) else len(value or []) + + effects = [on for on, n in ((on_unsupported, count(unsupported)), (on_uncited, count(uncited))) if n] + if "withhold" in effects: + return "withhold" + if "degrade" in effects: + return "degrade" + return None diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 7789d14d..ecd5785e 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -1,11 +1,13 @@ from __future__ import annotations import json +import re from typing import Any from app.core.events import EventType from app.engine.context import NodeContext, NodeError from app.engine.expressions import ExpressionError, eval_expression +from app.engine.schema import GraphNode, NodeType, _ancestors from app.engine.state import GraphState, template_context @@ -52,21 +54,99 @@ async def run_output(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 没配映射就把最后一条消息当作结果,让新建的图开箱即用 result = {"result": tctx.get("last_message", "")} + reports = _report_fields(result, mapping, state, ctx) contract = ctx.cfg("contract") if contract: - result["_issuance"] = _apply_contract(contract, state, ctx) + result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports) + evidence = _evidence_of(reports, contract) + if evidence: + result["_evidence"] = evidence return {"output": result, "nodes": {ctx.node.id: result}} +# -------------------------------------------------------------------------- +# 成果字段和上游报告的对应 +# -------------------------------------------------------------------------- + + +def _reference(node: GraphNode) -> re.Pattern[str]: + """字段模板里「带出了这个报告的正文」的写法。 + + nodes.write.text、nodes['write']['text']、整个 nodes.write(过 json 也带着正文),以及 + 存的就是正文的 vars.。nodes.write.repairs / stats / doc_artifact 只是把报告的 + 统计或文档 id 一起放进成果,没碰正文——算成改动的话,一份全部带引用的报告会被一个 + 「改写次数」字段拖成降档。模板只认路径加过滤器、没有方法调用,所以这几种写法就是全部。 + """ + nid = re.escape(node.id) + head = rf"nodes\s*(?:\.\s*{nid}(?![\w-])|\[\s*['\"]{nid}['\"]\s*\])" + alternatives = [rf"{head}(?:\s*\.\s*text(?![\w-])|\s*\[\s*['\"]text['\"]\s*\]|(?!\s*[.\[]))"] + var = str(node.config.get("assign_to") or "").strip() + if var: + alternatives += [rf"vars\s*\.\s*{re.escape(var)}(?![\w-])", + rf"vars\s*\[\s*['\"]{re.escape(var)}['\"]\s*\]"] + return re.compile("|".join(alternatives)) + + +def _report_fields( + result: dict[str, Any], mapping: Any, state: GraphState, ctx: NodeContext, +) -> list[dict[str, Any]]: + """上游每个写出了文档的报告节点:哪些成果字段逐字就是它的原文,哪些引用了它却改动过。 + + 逐字相等的字段,界面能按文档逐段画、逐个数点开证据;模板在报告前后拼了别的字、 + 或者过了过滤器,偏移就对不上了——这种字段不标证据,有契约时记一条 gap。 + 首尾空白不算改动:Markdown 渲染出来一样,文档的 markdown 本来就去掉了首尾空白。 + """ + spec = ctx.run.spec + upstream = _ancestors(spec, ctx.node.id) + nodes = state.get("nodes") or {} + sources = {f.get("name"): f.get("value", "") for f in mapping or [] if isinstance(f, dict) and f.get("name")} + out = [] + for node in spec.nodes: + payload = nodes.get(node.id) + if node.type != NodeType.REPORT or node.id not in upstream or not isinstance(payload, dict): + continue + text = payload.get("text") + if not payload.get("doc_artifact") or not isinstance(text, str) or not text.strip(): + continue + verbatim = [name for name, value in result.items() + if not name.startswith("_") and isinstance(value, str) and value.strip() == text] + pattern = _reference(node) + edited = [name for name, src in sources.items() if name not in verbatim and pattern.search( + src if isinstance(src, str) else json.dumps(src, ensure_ascii=False, default=str))] + out.append({"node": node, "doc_artifact": payload["doc_artifact"], "verbatim": verbatim, + "edited": edited}) + return out + + +def _evidence_of(reports: list[dict[str, Any]], contract: Any) -> dict[str, Any] | None: + """成果上的 _evidence:{report_node, doc_artifact, fields}。 + + 不管有没有契约、是不是正式运行都标——探索运行里的报告一样能点开看证据。契约的 + report_from 指着的那份优先;另有报告也被原样放进了成果的,列在 others 里。 + """ + shown = [r for r in reports if r["verbatim"]] + if not shown: + return None + preferred = contract.get("report_from") if isinstance(contract, dict) else None + shown.sort(key=lambda r: r["node"].id != preferred) + first, *rest = [{"report_node": r["node"].id, "doc_artifact": r["doc_artifact"], "fields": r["verbatim"]} + for r in shown] + return {**first, "others": rest} if rest else first + + def _apply_contract( - contract: dict[str, Any], state: GraphState, ctx: NodeContext + contract: dict[str, Any], state: GraphState, ctx: NodeContext, + *, reports: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: from datetime import datetime, timezone from app.engine.issuance import decide_tier, trace_numbers nodes = state.get("nodes") or {} + # 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述 + report_from = contract.get("report_from") + citations = report_from not in (None, "") # 1) 收指标集:contract.metrics_from 指向一个或多个口径卡节点 metrics: list[dict[str, Any]] = [] @@ -101,26 +181,35 @@ def _apply_contract( if unresolved: gaps.append(f"指标源未解析:{', '.join(unresolved[:5])}") - present = {m["id"] for m in metrics} + # 引用模式下,缺输入记成空值的指标(口径卡 on_missing=null)就是缺:报告里引用不了它, + # 也不能因为 id 在清单里就算「齐了」。旧模式照旧只看 id,行为不变 + present = {m["id"] for m in metrics if not citations or m.get("value") is not None} missing_required = [m for m in (contract.get("required") or []) if m not in present] missing_expected = [m for m in (contract.get("expected") or []) if m not in present] - # 2) 叙述数字回指 - declared_narrative = str(contract.get("narrative", "")) - narrative = ctx.render_str(declared_narrative, state) - # 模板取不到路径时 render 出来是空串,所以"声明了叙述但渲染成空"必须单独识别: - # 节点改名、vars 写错都会让回指校验静默变成空操作 - if declared_narrative.strip() and not narrative.strip(): - gaps.append("叙述模板渲染为空(路径可能写错了)") - elif not declared_narrative.strip(): - gaps.append("契约没有声明叙述,数字回指未执行") + trace = None + cited: dict[str, Any] = {} + if citations: + cited = _check_citations(str(report_from), contract, state, ctx, reports or []) + gaps.extend(cited["gaps"]) + unmatched = cited["unmatched"] + else: + # 2) 叙述数字回指 + declared_narrative = str(contract.get("narrative", "")) + narrative = ctx.render_str(declared_narrative, state) + # 模板取不到路径时 render 出来是空串,所以"声明了叙述但渲染成空"必须单独识别: + # 节点改名、vars 写错都会让回指校验静默变成空操作 + if declared_narrative.strip() and not narrative.strip(): + gaps.append("叙述模板渲染为空(路径可能写错了)") + elif not declared_narrative.strip(): + gaps.append("契约没有声明叙述,数字回指未执行") - trace = ( - trace_numbers(narrative, metrics, allow=contract.get("allow_numbers")) - if narrative.strip() - else None - ) - unmatched = trace.unmatched if trace else [] + trace = ( + trace_numbers(narrative, metrics, allow=contract.get("allow_numbers")) + if narrative.strip() + else None + ) + unmatched = trace.unmatched if trace else [] # 声明了 metrics_from 却一个指标都没收到,同样是"没查成" if sources and not metrics: @@ -140,8 +229,15 @@ def _apply_contract( unmatched=unmatched, strict=bool(contract.get("strict")), gaps=gaps, + unresolved=cited.get("unresolved"), ) + if citations: + return _declare_citations(tier, cited, calibers=calibers, metrics=metrics, gaps=gaps, + missing_required=missing_required, missing_expected=missing_expected, + report_from=str(report_from), declared_at=datetime.now(timezone.utc), + ctx=ctx) + # 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」 matched = [ {**hit, **({"caliber": caliber_of[hit["metric"]]} if caliber_of.get(hit.get("metric")) else {})} @@ -177,6 +273,153 @@ def _apply_contract( return issuance +# -------------------------------------------------------------------------- +# 引用模式:按报告文档逐段复核 +# -------------------------------------------------------------------------- + +#: 文档本身对不上(不是某个数没出处):这次复核没法完成,记 gap +_INTEGRITY = {"bad_schema", "segment_mismatch", "structural_text", "render_mismatch", "eid_mismatch", + "state_mismatch"} + + +def _check_citations( + report_from: str, contract: dict[str, Any], state: GraphState, ctx: NodeContext, + reports: list[dict[str, Any]], +) -> dict[str, Any]: + """独立复核报告文档:{matched, unmatched, unresolved, gaps, doc_artifact, stats}。 + + 不信报告节点自己的统计:文档按 id 从工件库取回(取回时复验哈希),目录按报告节点 + 同一套参数从状态里重建(不用文档里存的那份),每个带引用的片段重新解析、重新渲染、 + 逐字比对。一个配错的报告节点绕不过这里。 + """ + from app.core.artifact_store import load + from app.engine.evidence import iter_units, resolve_ref, verify_doc + from app.engine.nodes.report import report_catalog + + out: dict[str, Any] = {"matched": [], "unmatched": [], "unresolved": [], "gaps": [], + "doc_artifact": None, "stats": None} + gaps = out["gaps"] + spec = ctx.run.spec + node = spec.node_map().get(report_from) + if node is None or node.type != NodeType.REPORT: + gaps.append(f"契约的 report_from 指向的「{report_from}」不是报告撰写节点,引用核对未执行") + return out + payload = (state.get("nodes") or {}).get(report_from) + doc_artifact = payload.get("doc_artifact") if isinstance(payload, dict) else None + if not doc_artifact: + gaps.append(f"报告撰写节点「{node.title}」没有产出报告文档(没跑到、被跳过或者失败了)," + "引用核对未执行") + return out + out["doc_artifact"] = doc_artifact + try: + doc = load(doc_artifact) + except ValueError: + gaps.append(f"报告「{node.title}」的文档和它的哈希对不上,疑似被改过,引用核对未执行") + return out + if not isinstance(doc, dict): + gaps.append(f"报告「{node.title}」的文档在工件库里取不回来,引用核对未执行") + return out + if doc.get("node_id") != report_from or doc.get("run_id") != ctx.run.run_id \ + or doc.get("markdown") != payload.get("text"): + gaps.append(f"报告「{node.title}」的文档和这次运行里它的产出对不上,引用核对未执行") + return out + + catalog = report_catalog(state, spec, node) + checked = verify_doc(doc, catalog, allow_numbers=contract.get("allow_numbers")) + out["stats"] = checked["stats"] + broken: list[str] = [] + flagged: set[str] = set() + for v in checked["violations"]: + where = {k: v[k] for k in ("segment", "unit") if v.get(k)} + if v.get("span"): + where.update(start=v["span"][0], end=v["span"][1]) + if v.get("segment"): + flagged.add(v["segment"]) + if v["code"] == "uncited_number": + out["unmatched"].append({"token": v.get("text", ""), "context": v.get("context", ""), **where}) + elif v["code"] == "unresolved_ref": + out["unresolved"].append({"ref": v.get("ref", ""), "message": v["message"], **where}) + elif v["code"] in _INTEGRITY: + broken.append(v["message"]) + if broken: + more = f"等 {len(broken)} 处" if len(broken) > 1 else "" + gaps.append(f"报告「{node.title}」的文档没通过复核:{broken[0]}{more}") + + # 老前端要用的逐个数字出处,从复核通过的数字片段拼出来 + for _, unit in iter_units(doc): + for seg in unit.get("segments") or []: + if seg.get("kind") != "number" or not seg.get("ref") or seg.get("id") in flagged: + continue + cite = resolve_ref(str(seg["ref"]), catalog) + if cite["status"] != "resolved" or cite["rendered"] != seg.get("text"): + continue + entry = catalog.get(cite["alias"]) or {} + start, end = seg["span"] + hit: dict[str, Any] = {"token": seg["text"], "metric": cite["locator"].get("metric")} + if cite["kind"] == "metric": + label = " @ ".join(str(v) for v in (entry.get("caliber"), entry.get("version")) if v) + if label: + hit["caliber"] = label + else: + hit["input"] = cite["locator"].get("field") + out["matched"].append({**hit, "segment": seg["id"], "unit": unit["id"], "start": start, "end": end, + "span": [start, end], "eid": cite["eid"]}) + + claims = contract.get("claims") + policy = claims.get("policy") if isinstance(claims, dict) else claims + if policy not in (None, "", "off"): + gaps.append("契约声明了结论句检查(claims),这一版还不支持,结论句没有核对") + + mine = next((r for r in reports if r["node"].id == report_from), None) + edited = mine["edited"] if mine else [] + for name in edited: + gaps.append(f"成果字段「{name}」改动了报告,无法逐段对应") + if not (mine and mine["verbatim"]) and not edited: + gaps.append(f"成果里没有哪个字段是报告「{node.title}」的原文:读者看到的内容没有经过引用核对") + return out + + +def _declare_citations( + tier: str, cited: dict[str, Any], *, calibers: list[dict[str, str]], metrics: list[dict[str, Any]], + gaps: list[str], missing_required: list[str], missing_expected: list[str], report_from: str, + declared_at: Any, ctx: NodeContext, +) -> dict[str, Any]: + """引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。""" + report = {"node_id": report_from, "doc_artifact": cited["doc_artifact"]} + issuance = { + "mode": "citations", + "tier": tier, + "report": report, + "calibers": calibers, + "metrics_checked": len(metrics), + "missing_required": missing_required, + "missing_expected": missing_expected, + "unmatched_numbers": cited["unmatched"][:20], + "unresolved": cited["unresolved"][:20], + "matched_numbers": len(cited["matched"]), + "matched": cited["matched"][:50], + "stats": cited["stats"], + "gaps": gaps, + "declared_at": declared_at.isoformat(), + } + ctx.emit( + EventType.ISSUANCE, + mode="citations", + tier=tier, + report=report, + missing_required=missing_required, + missing_expected=missing_expected, + unmatched=len(cited["unmatched"]), + unresolved=len(cited["unresolved"]), + calibers=calibers, + gaps=gaps, + metrics_checked=len(metrics), + matched_numbers=len(cited["matched"]), + matched=cited["matched"][:50], + ) + return issuance + + async def run_transform(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """数据整形。用受限表达式把上游输出揉成下游要的形状,不用为此专门起一个 LLM。""" tctx = template_context(state) diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index b58d9e28..599aa387 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -1,10 +1,14 @@ from __future__ import annotations +import math +import re from typing import Any +from app.core.errors import describe_exception from app.core.events import EventType from app.engine.context import NodeContext, NodeError -from app.engine.expressions import ExpressionError, eval_expression +from app.engine.evidence import DEFAULT_FORMAT, FORMATS, MISSING, RenderError, is_number, render_number +from app.engine.expressions import ExpressionError, eval_expression, leaf_refs, parse_expression, substitute from app.engine.state import GraphState, template_context @@ -14,6 +18,10 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 表达式走 AST 白名单求值器——确定性、无模型参与、可复算。 下游叙述节点只许引用这里登记过的数,出具校验器按这份清单抽数字回指。 这就是"叙述层无算术权限"的机制载体:不是求模型别算,而是它算了也过不了校验。 + + 每个指标还记下它是怎么来的:用到了哪些输入、各取自哪个节点、代入后的算式、 + 拿代入式复算是否一致。整张卡落成 metric_set 工件,id 进证据台账——报告里的 + 数字点开,一路能追到这里。 """ definitions = ctx.cfg("metrics", []) or [] if not definitions: @@ -22,6 +30,11 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: tctx = template_context(state) caliber = ctx.render_str(ctx.cfg("caliber", "") or ctx.node.title, state) caliber_version = str(ctx.cfg("caliber_version", "") or "v1") + # 缺输入时怎么办。默认 fail:和以前一样整张卡失败;null 则这个指标记为空值、 + # 交给出具契约的 required / expected 去判档 + on_missing = str(ctx.cfg("on_missing", "fail") or "fail") + if on_missing not in ("fail", "null"): + raise NodeError(ctx.node.id, f"on_missing 只能是 fail 或 null,写的是 {on_missing!r}") metrics: list[dict[str, Any]] = [] errors: list[str] = [] @@ -31,6 +44,20 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if not metric_id or not expr: errors.append(f"指标定义不完整:{definition}") continue + display, problem = _display_of(definition) + if problem: + errors.append(f"{metric_id}: {problem}") + continue + try: + tree, _, _ = parse_expression(expr) + except ExpressionError as e: + errors.append(f"{metric_id}: 表达式错误({e})") + continue + paths = leaf_refs(tree) + values = {path: _value_of(path, tctx) for path in paths} + missing = [path for path in paths if values[path] is None] + + value: Any = None try: value = eval_expression(expr, tctx) except ExpressionError as e: @@ -39,9 +66,28 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: except ZeroDivisionError: errors.append(f"{metric_id}: 除以零") continue - decimals = definition.get("decimals") + except Exception as e: # noqa: BLE001 - 求值器之外的运算错误(None 参与算术、类型不对)要说人话 + if missing or "NoneType" in str(e): + if on_missing != "null": + errors.append(f"{metric_id}: " + _missing_reason(missing)) + continue + value = None + elif isinstance(e, TypeError): + errors.append(f"{metric_id}: 输入的类型不对,算不出来——" + _type_hint(paths, values)) + continue + else: + errors.append(f"{metric_id}: 算不出来({describe_exception(e)})") + continue + + if display["bad_decimals"] and isinstance(value, float): + # 旧写法里 int() 认不得的 decimals:以前走到 round(value, int(decimals)) 就崩, + # 现在照样算失败,只是说人话。值不是小数时以前根本用不到它,照旧忽略 + errors.append(f"{metric_id}: decimals 要写整数,写的是 {definition.get('decimals')!r}") + continue + decimals = display["decimals"] if isinstance(value, float) and decimals is not None: - value = round(value, int(decimals)) + value = round(value, decimals) + substituted = substitute(tree, values) metrics.append( { "id": metric_id, @@ -49,6 +95,15 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "unit": definition.get("unit") or "", "value": value, "expression": expr, + "decimals": decimals, + "format": display["format"], + "rendered": _rendered(value, definition, display), + "inputs": [_input_of(path, values[path], state, ctx) for path in paths], + "substituted": substituted, + "recompute_ok": _recompute(substituted, tctx, value, decimals), + # 值是空的就是缺输入,不论 on_missing 是哪一种:fail 模式下直接取到空值的 + # 指标(原式就是 vars.x.y 而 y 不存在)以前也是照常产出一个 None + "status": "ok" if value is not None else "missing_input", } ) @@ -57,12 +112,20 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raise NodeError(ctx.node.id, "口径卡计算失败:" + ";".join(errors)) # text 是给叙述节点 prompt 用的清单——它是叙述层唯一的数字来源 - lines = [f"- {m['id']}({m['name']})= {m['value']}{m['unit']}" for m in metrics] - result = { + lines = [ + f"- {m['id']}({m['name']})= {m['value']}{m['unit']}" if m["value"] is not None + else f"- {m['id']}({m['name']})= {MISSING}(缺输入,没有值)" + for m in metrics + ] + card = { "kind": "metric_set", "caliber": caliber, "caliber_version": caliber_version, "metrics": metrics, + } + artifact = await _store(card, caliber, caliber_version, ctx) + result = { + **card, "text": f"指标清单(口径 {caliber} @ {caliber_version}):\n" + "\n".join(lines), } ctx.emit( @@ -72,7 +135,212 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ) updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} + if artifact: + result["artifact"] = artifact + executed = sum(1 for t in state.get("trail") or [] + if t.get("node_id") == ctx.node.id and "error" not in t) + updates["evidence"] = [{ + "kind": "metric_set", + "node_id": ctx.node.id, + "exec": executed + 1, + "artifact": artifact, + "caliber": caliber, + "version": caliber_version, + "metrics": [m["id"] for m in metrics], + }] var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: result} return updates + + +#: 新写法的小数位范围。负数是取整到十位、百位(round(x, -2)) +_DECIMALS_RANGE = 15 + + +def _display_of(definition: dict[str, Any]) -> tuple[dict[str, Any], str | None]: + """指标的显示格式:{decimals, format, bad_decimals},以及写错时的一句话。 + + 写了 format 的是新写法,decimals 严格校验:-15 到 15 的整数。 + 没写 format 的是这一期之前的口径卡,旧代码能接受的照旧接受:以前只在值是小数时 + round(value, int(decimals)),所以 int() 认得的都算数("2"、-2、12);int() 认不得的 + 记成 bad_decimals,由调用方按值的类型决定——值是小数才报错(以前在这里崩), + 不是小数就忽略(以前根本用不到它)。 + """ + raw = definition.get("decimals") + strict = definition.get("format") not in (None, "") + fmt = definition.get("format") or DEFAULT_FORMAT + if fmt not in FORMATS: + return {}, f"format 只能是 {' / '.join(FORMATS)},写的是 {fmt!r}" + display: dict[str, Any] = {"decimals": None, "format": fmt, "bad_decimals": False} + if raw in (None, ""): + return display, None + if strict: + decimals = _integer(raw) + if decimals is None or not -_DECIMALS_RANGE <= decimals <= _DECIMALS_RANGE: + return {}, (f"decimals 要写 -{_DECIMALS_RANGE} 到 {_DECIMALS_RANGE} 的整数" + f"(负数表示取整到十位、百位),写的是 {raw!r}") + display["decimals"] = decimals + return display, None + try: + display["decimals"] = int(raw) + except (TypeError, ValueError, OverflowError): + display["bad_decimals"] = True + return display, None + + +def _integer(raw: Any) -> int | None: + """严格的整数:2、2.0、"2"、"-2" 算;1.5、"两位"、true 不算。""" + if isinstance(raw, bool): + return None + if isinstance(raw, int): + return raw + if isinstance(raw, float): + return int(raw) if raw.is_integer() else None + if isinstance(raw, str) and re.fullmatch(r"\s*[+-]?\d+\s*", raw): + return int(raw) + return None + + +def _rendered(value: Any, definition: dict[str, Any], display: dict[str, Any]) -> str: + try: + return render_number(value, unit=str(definition.get("unit") or ""), + decimals=display["decimals"], fmt=display["format"]) + except RenderError: + # 按规则渲染不出来的值显示「—」:inf / nan、不是 0 却会显示成 0 的(小数位不够)、 + # 位数大到不像话的。值本身照旧记着,报告里引用它会被判成解析不了,原因写清楚 + return MISSING + + +def _value_of(path: str, tctx: dict[str, Any]) -> Any: + """单独取一个引用的值。取不到(或取值本身出错)一律当作没有值。""" + try: + return eval_expression(path, tctx) + except Exception: # noqa: BLE001 + return None + + +def _missing_reason(missing: list[str]) -> str: + if not missing: + return "有输入是空值,算不出来(检查上游有没有产出这些字段)" + return f"输入 {'、'.join(missing[:4])} 没有值,算不出来(上游没有产出这个字段,或者路径写错了)" + + +def _type_hint(paths: list[str], values: dict[str, Any]) -> str: + kinds = {bool: "布尔值", str: "文本", list: "列表", dict: "对象"} + parts = [] + for path in paths: + value = values[path] + if value is None or is_number(value): + continue + label = next((name for t, name in kinds.items() if isinstance(value, t)), type(value).__name__) + sample = f"「{str(value)[:20]}」" if isinstance(value, str) else "" + parts.append(f"{path} 是{label}{sample}") + if not parts: + return "参与运算的值不是数" + return "、".join(parts[:4]) + ",不能参与数值运算" + + +_REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(.*)$", re.S) +_VIA = {"input": "input", "code": "code", "agent": "agent", "llm": "llm", "tool": "tool", + "transform": "transform", "metrics": "metric", "retrieve": "retrieval", "loop": "loop", + "human": "human", "validate": "validate", "supervisor": "team", "subgraph": "subgraph", + "memory": "memory"} + + +def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext) -> dict[str, Any]: + """一个输入的来历:{path, value, node_id, via, field?, role?, status}。 + + vars.X 要反查是谁写的:assign_to 为 X 的节点里,按执行顺序最后跑的那一个—— + 和状态里 vars 的覆盖顺序一致。入口节点把输入字段也写进 vars。 + """ + spec = ctx.run.spec + nodes = spec.node_map() + node_id: str | None = None + field = "" + head = _REF_HEAD.match(path) + if head: + root, name, rest = head.groups() + field = rest.lstrip(".") + if root == "nodes": + node_id = name if name in nodes else None + elif root == "input": + node_id = next((n.id for n in spec.entry_nodes() if str(n.type) == "input"), None) + else: + node_id = _writer_of(name, state, ctx) + producer = nodes.get(node_id) if node_id else None + entry: dict[str, Any] = { + "path": path, + "value": value if value is None or isinstance(value, (int, float, str, bool)) else _brief(value), + "node_id": node_id, + "via": _VIA.get(str(producer.type), str(producer.type)) if producer else "state", + } + if field: + entry["field"] = field + if producer is not None and str(producer.type) == "code": + # 沙箱算出来的数喂给口径卡:记下它自认是「取数」还是「计算」,治理规则按它判 + entry["role"] = str(producer.config.get("evidence_role") or "compute") + entry["status"] = "ok" if value is not None else "missing" + return entry + + +def _writer_of(var: str, state: GraphState, ctx: NodeContext) -> str | None: + nodes = ctx.run.spec.node_map() + + def writes(node_id: str) -> bool: + node = nodes.get(node_id) + if node is None: + return False + cfg = node.config + if str(cfg.get("assign_to") or "").strip() == var: + return True + if str(node.type) == "input": + return var in (state.get("input") or {}) + if str(node.type) == "loop": + item = str(cfg.get("item_var") or "item").strip() + return var in (item, f"{item}_index") + return False + + for step in reversed(state.get("trail") or []): + node_id = step.get("node_id") + if node_id and "error" not in step and writes(node_id): + return node_id + # 没有执行记录可查(比如直接调用执行器):按图里声明的写入者兜底 + return next((n.id for n in ctx.run.spec.nodes if writes(n.id)), None) + + +def _brief(value: Any) -> str: + """容器型的输入不整份抄进指标:记个大概,完整的在上游节点的产出工件里。""" + if isinstance(value, list): + return f"[{len(value)} 项]" + if isinstance(value, dict): + return f"{{{len(value)} 个字段}}" + return str(value)[:200] + + +def _recompute(substituted: str, tctx: dict[str, Any], value: Any, decimals: int | None) -> bool | None: + """拿代入式再算一遍,和结果比对。值是空的就没有可比的,返回 None。""" + if value is None: + return None + try: + again = eval_expression(substituted, tctx) + except Exception: # noqa: BLE001 - 代入式算不出来本身就是「对不上」 + return False + if isinstance(again, float) and decimals is not None: + again = round(again, decimals) + if is_number(again) and is_number(value): + return math.isclose(again, value, rel_tol=1e-12, abs_tol=0.0) or again == value + return again == value + + +async def _store(card: dict[str, Any], caliber: str, version: str, ctx: NodeContext) -> str | None: + """整张卡落成 metric_set 工件。写失败不毁掉运行,只是这张卡没法被引用。""" + from app.core.artifact_store import put_json + + try: + return await put_json(card, kind="metric_set", run_id=ctx.run.run_id, node_id=ctx.node.id, + meta={"caliber": caliber, "version": version}) + except Exception as e: # noqa: BLE001 + ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", + message=f"口径卡的指标集没能落进工件库({describe_exception(e)}),报告里没法引用它的指标") + return None diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py new file mode 100644 index 00000000..0d34c33b --- /dev/null +++ b/backend/app/engine/nodes/report.py @@ -0,0 +1,303 @@ +"""报告撰写节点:模型只写引用标记,数字由系统从口径卡取出来渲染。 + +以前「给人看的报告」是 llm 节点写的自由文本:数字是模型转写的,出处要到出口再按 +数值回头猜——同值的指标一多就猜错,模型顺手写的数也要到出口才被发现。这里把核对 +挪到写的那一刻: + +1. 建目录:收齐上游口径卡的指标和运行输入,编成 [[m:gmv]]、[[i:week]] 这样的引用 +2. 流式写作:模型写标记,StreamRenderer 边收边换成真值,流里看到的就是最终的数字 +3. 核对:compose_doc 切块、分句、切片段,找出裸数字和解析不了的引用 +4. 修复:有违规就把清单交回去重写,最多 max_repairs 次 +5. 收尾:文档落成 report_doc 工件,report.checked 事件进封存;仍然违规时按 + on_violation 失败(fail)或者照常产出、把违规片段标出来(flag) + +出口契约复核(io.py)用 report_catalog 按同一套参数重建目录、独立重算,不信这里的自查。 +""" +from __future__ import annotations + +import time +from typing import Any + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage + +from app.core.errors import describe_exception +from app.core.events import EventType +from app.db.base import SessionLocal +from app.engine.context import NodeContext, NodeError +from app.engine.evidence import ( + MARKER_RULES, + StreamRenderer, + build_catalog, + catalog_prompt, + compose_doc, + describe_violations, +) +from app.engine.nodes.llm import _invoke_streaming, _model_spec, _report_call +from app.engine.schema import GraphNode, GraphSpec, NodeType, _ancestors +from app.engine.state import GraphState, message_text, thinking_text +from app.engine.toolcalls import leaked_markup, markup_warning +from app.providers.factory import ProviderNotConfigured, get_chat_model + +ON_VIOLATION = ("fail", "flag") +NUMBERS = ("strict", "off") +#: 重写一次就是多一次整篇的模型调用,写不对的模型多给几次也大多写不对 +MAX_REPAIRS = 3 + +ROLE = ("你是报告撰写人。报告里的每个数字都由系统从证据里取出来、按口径卡的格式渲染;" + "你负责挑重点、组织结构、说清变化和原因。") + +MARKUP_NUDGE = ("你上一条回复把工具调用写成了文字。报告撰写不调用任何工具:能用的数据都在上面的" + "证据目录里,直接用引用标记写报告正文。") +MARKUP_ERROR = ("模型输出了工具调用的原始标记,而报告撰写节点不调用工具——数据已经在证据目录里。" + "换一个模型再试") + + +# -------------------------------------------------------------------------- +# 目录与放行名单:出口复核要用同一套参数重建,所以不依赖 NodeContext +# -------------------------------------------------------------------------- + + +def _setting(spec: GraphSpec, node: GraphNode, key: str, default: Any = None) -> Any: + """和 NodeContext.cfg 同一个规则:节点上没写就取图级 defaults。""" + value = node.config.get(key) + if value in (None, ""): + return spec.defaults.get(key, default) + return value + + +def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[str, Any]: + """报告节点的证据目录。 + + 出口契约复核必须用这一个函数重建:参数(metrics_from、evidence_from、祖先范围) + 差一点,alias 集合就不一样——同一个指标 id 在两张卡里时,只收一张写 m:gmv, + 全收就得写 m:east.gmv——复核会把一份好好的报告判成引用解析不了。 + """ + ledger = [e for e in state.get("evidence") or [] if isinstance(e, dict)] + sources = _setting(spec, node, "evidence_from", "ancestors") + if isinstance(sources, str) and sources != "ancestors": + sources = [sources] + if isinstance(sources, list): + # evidence_from 只管查询、检索这类证据从哪几个节点来;指标从哪来由 metrics_from 管 + keep = {str(s) for s in sources} + ledger = [e for e in ledger if e.get("kind") not in ("query", "retrieval") or e.get("node_id") in keep] + return build_catalog( + nodes=state.get("nodes") or {}, + ledger=ledger, + inputs=state.get("input") or {}, + metrics_from=_setting(spec, node, "metrics_from") or None, + allowed=_ancestors(spec, node.id), + ) + + +def report_allowance(spec: GraphSpec, node: GraphNode) -> list[Any]: + """报告自查放行的字面量:出口契约 report_from 指向它时,契约的 allow_numbers。 + + 契约放行、报告却不放行的话,写作者会为一个最终根本不算违规的数白白重写一次。 + 反过来不成立:放行名单只由契约定,报告节点没有自己的名单——出口复核是独立的裁判。 + """ + allowed: list[Any] = [] + for other in spec.nodes: + contract = other.config.get("contract") if other.type == NodeType.OUTPUT else None + if isinstance(contract, dict) and contract.get("report_from") == node.id: + extra = contract.get("allow_numbers") or [] + allowed.extend(extra if isinstance(extra, list) else [extra]) + return allowed + + +# -------------------------------------------------------------------------- +# 执行器 +# -------------------------------------------------------------------------- + + +class _RenderedStream: + """借 _invoke_streaming 的流式与回退逻辑,只在出口把 llm.token 换成渲染后的字。 + + 模型写的是 [[m:gmv]],前端要看到的是「45,678.5元」。标记会被切在两个 chunk + 中间,StreamRenderer 攒到没有未闭合的 [[ 再放;调用结束时 flush 掉剩下的。 + """ + + def __init__(self, ctx: NodeContext, catalog: dict[str, Any]) -> None: + self._ctx = ctx + self._catalog = catalog + self._renderer = StreamRenderer(catalog) + + def __getattr__(self, name: str) -> Any: + return getattr(self._ctx, name) + + def emit(self, event_type: EventType | str, **data: Any) -> None: + if event_type == EventType.LLM_TOKEN: + if text := self._renderer.feed(str(data.get("delta") or "")): + self._ctx.emit(EventType.LLM_TOKEN, delta=text) + return + if event_type == EventType.LOG and data.get("code") == "stream_fallback": + # 流断在半路、退回非流式重来:攒着的半截不再放出去,免得和重来的那份接在一起 + self._renderer = StreamRenderer(self._catalog) + self._ctx.emit(event_type, **data) + + def flush(self) -> None: + if tail := self._renderer.flush(): + self._ctx.emit(EventType.LLM_TOKEN, delta=tail) + + +async def _default_on_violation(run_id: str) -> str: + """探索运行默认标注、照常产出;正式运行默认失败——封存的发布版不能带着裸数字出具。""" + from app.db.models import Run + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + return "fail" if run is not None and run.run_class == "formal" else "flag" + + +def _options(ctx: NodeContext) -> tuple[str, int]: + numbers = str(ctx.cfg("numbers", "strict") or "strict") + if numbers not in NUMBERS: + raise NodeError(ctx.node.id, f"numbers 只能是 {' / '.join(NUMBERS)},写的是 {numbers!r}") + raw = ctx.cfg("max_repairs", 1) + try: + repairs = int(raw) + except (TypeError, ValueError): + raise NodeError(ctx.node.id, f"max_repairs 要写 0 到 {MAX_REPAIRS} 的整数,写的是 {raw!r}") from None + if isinstance(raw, bool) or not 0 <= repairs <= MAX_REPAIRS: + raise NodeError(ctx.node.id, f"max_repairs 要写 0 到 {MAX_REPAIRS} 的整数,写的是 {raw!r}") + return numbers, repairs + + +def _blocking(violations: list[dict[str, Any]], numbers: str) -> list[dict[str, Any]]: + """要让写作者改的违规。numbers=off 时裸数字只记录不拦(文档和出口复核照样看得到)。""" + return [v for v in violations if not (numbers == "off" and v.get("code") == "uncited_number")] + + +def _named(violations: list[dict[str, Any]], limit: int = 3) -> str: + """违规里点名的那几个字:「45678」「m:nope」。""" + names = [str(v.get("text") or v.get("ref") or "") for v in violations] + names = [n for n in dict.fromkeys(names) if n][:limit] + return "、".join(f"「{n}」" for n in names) + + +def _repair_request(violations: list[dict[str, Any]]) -> str: + return ( + "你写的报告没有通过系统核对,问题如下:\n" + f"{describe_violations(violations)}\n\n" + "请重写整篇报告:所有数字都用引用标记写,只引用证据目录里有的指标和输入;" + "不要解释改了什么,只输出新的报告正文。" + ) + + +def _messages(ctx: NodeContext, state: GraphState, catalog: dict[str, Any]) -> list[BaseMessage]: + system = ctx.render_str(ctx.cfg("system", ""), state) + instructions = ctx.render_str(ctx.cfg("instructions", ""), state).strip() \ + or "根据下面的证据写一份简洁的报告,先总后分。" + return [ + SystemMessage(content="\n\n".join(p for p in (system, ROLE) if p)), + HumanMessage(content=( + f"{instructions}\n\n{catalog_prompt(catalog)}\n\n{MARKER_RULES}\n\n" + "只输出报告正文(Markdown),不要写撰写说明。" + )), + ] + + +async def _store(doc: dict[str, Any], ctx: NodeContext) -> str | None: + from app.core.artifact_store import put_json + + try: + return await put_json(doc, kind="report_doc", run_id=ctx.run.run_id, node_id=ctx.node.id, + meta={"stats": doc.get("stats") or {}}) + except Exception as e: # noqa: BLE001 - 工件写失败不毁掉运行,只是这份报告点不开证据 + ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", + message=f"报告文档没能落进工件库({describe_exception(e)}),报告里的数字点不开证据") + return None + + +async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: + numbers, max_repairs = _options(ctx) + on_violation = str(ctx.cfg("on_violation", "") or "") or await _default_on_violation(ctx.run.run_id) + if on_violation not in ON_VIOLATION: + raise NodeError(ctx.node.id, f"on_violation 只能是 {' / '.join(ON_VIOLATION)},写的是 {on_violation!r}") + + async with SessionLocal() as session: + try: + model, model_id = await get_chat_model(session, _model_spec(ctx)) + except ProviderNotConfigured as e: + raise NodeError(ctx.node.id, str(e)) from e + + spec = ctx.run.spec + catalog = report_catalog(state, spec, ctx.node) + allow = report_allowance(spec, ctx.node) + if not any(entry.get("kind") == "metric" for entry in catalog.values()): + ctx.emit(EventType.LOG, level="warn", code="report_no_evidence", + message="报告撰写节点的上游没有可引用的口径卡指标:报告里的数字都会被判为没有出处。" + "在它前面接一张口径卡,或者检查 metrics_from") + + messages = _messages(ctx, state, catalog) + started = time.perf_counter() + spent: list[dict[str, Any]] = [] + nudged = False + + async def draft(payload: list[BaseMessage]) -> tuple[BaseMessage, str]: + nonlocal nudged + stream = _RenderedStream(ctx, catalog) + began = time.perf_counter() + response = await _invoke_streaming(model, payload, stream, model_id) # type: ignore[arg-type] + stream.flush() + spent.append(_report_call(ctx, response, model_id, began)) + text = message_text(response) + if snippet := leaked_markup(text): + # 写成了一段工具调用标记:纠正一次,还这样就判失败,别把标记当报告往下游送 + if nudged: + raise NodeError(ctx.node.id, MARKUP_ERROR) + nudged = True + ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet)},已提醒它重试一次") + return await draft([*payload, response, HumanMessage(content=MARKUP_NUDGE)]) + if not text and thinking_text(response): + ctx.emit(EventType.LOG, level="warn", code="empty_completion", + message="模型只输出了思考内容,报告正文为空。把 max_tokens 调大,或把 thinking 设为 off。") + return response, text + + def compose(raw: str) -> dict[str, Any]: + return compose_doc(raw, catalog, node_id=ctx.node.id, run_id=ctx.run.run_id, allow_numbers=allow) + + response, raw = await draft(messages) + doc = compose(raw) + repairs = 0 + while (blocking := _blocking(doc["violations"], numbers)) and repairs < max_repairs: + repairs += 1 + ctx.emit(EventType.LOG, level="warn", code="report_repair", + message=f"报告里有 {len(blocking)} 处没通过核对({_named(blocking)})," + f"已要求写作者重写(第 {repairs} 次)") + messages = [*messages, response, HumanMessage(content=_repair_request(blocking))] + response, raw = await draft(messages) + doc = compose(raw) + + doc_artifact = await _store(doc, ctx) + ctx.emit(EventType.REPORT_CHECKED, doc_artifact=doc_artifact, ok=not doc["violations"], + stats=doc["stats"], violations=doc["violations"][:20], repairs=repairs, + on_violation=on_violation, failed=bool(blocking) and on_violation == "fail") + if blocking and on_violation == "fail": + head = ";".join(v["message"] for v in blocking[:5]) + more = f";…另有 {len(blocking) - 5} 处" if len(blocking) > 5 else "" + raise NodeError( + ctx.node.id, + f"报告有 {len(blocking)} 处没通过核对(已要求重写 {repairs} 次):{head}{more}。" + "换一个遵从度更高的模型,或者先把 on_violation 设成 flag,把问题标注在报告里再看", + ) + + usage = {key: sum(one[key] for one in spent) for key in spent[0]} + usage["cost_usd"] = round(usage["cost_usd"], 6) + text = doc["markdown"] + result = { + "text": text, + "doc_artifact": doc_artifact, + "stats": doc["stats"], + "violations": doc["violations"][:20], + "repairs": repairs, + "model": model_id, + "duration_ms": int((time.perf_counter() - started) * 1000), + } + updates: dict[str, Any] = {"nodes": {ctx.node.id: result}, "usage": usage} + if ctx.cfg("emit_message", True): + updates["messages"] = [AIMessage(content=text or "(空)")] + var_name = ctx.cfg("assign_to", "") + if var_name: + updates["vars"] = {var_name: text} + return updates diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 1ce54d71..423280ac 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -80,6 +80,13 @@ WARN_CODES: dict[str, tuple[str, str]] = { "team_last_failed": ("team_last_failed", DEGRADED), # 校验节点的修复编出了原文没有的值,这次修复被作废 "repair_invented": ("repair_invented", DEGRADED), + # 报告撰写节点:写作者写了裸数字或引用了不存在的指标,被要求重写。重写好了的 + # 答案是核对过的,只记缺口;没好的那次按 on_violation 失败或把违规标在报告里 + "report_repair": ("report_repair", DEGRADED), + # 报告撰写节点的上游没有可引用的口径卡指标:报告里的数都没有出处 + "report_no_evidence": ("report_no_evidence", DEGRADED), + # 口径卡的指标集、报告文档没能落进工件库:答案照常,只是点不开证据 + "evidence_store_failed": ("evidence_store_failed", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 @@ -253,7 +260,14 @@ REVIEW_SCHEMA: dict[str, Any] = { } -def review_prompt(question: str, answer: str, signals: list[Signal]) -> str: +#: 答案带着逐段证据时追加给复核模型的话 +LOCKED_NOTE = ( + "\n\n这份答案带有逐段证据(报告里的每个数字都对应着核对过的出处),不能改写:" + "answer 必须留空,只在 note 里说明异常。" +) + + +def review_prompt(question: str, answer: str, signals: list[Signal], *, locked: bool = False) -> str: """拼给复核模型的请求。 只给信号清单,不给原始事件流:几百条事件塞进 prompt 既贵又没用,而信号 @@ -266,6 +280,7 @@ def review_prompt(question: str, answer: str, signals: list[Signal]) -> str: f"用户的问题:\n{question or '(未记录)'}\n\n" f"系统给出的原答案:\n{answer or '(空)'}\n\n" f"检测到的异常:\n{lines}" + + (LOCKED_NOTE if locked else "") ) @@ -296,12 +311,15 @@ class ReviewResult: async def review( - model: Any, *, question: str, answer: str, signals: list[Signal] + model: Any, *, question: str, answer: str, signals: list[Signal], locked: bool = False, ) -> ReviewResult: """叫一次模型复核。调用方负责确保 signals 非空——空信号根本不该走到这里。 复核本身失败不能让整轮挂掉:答案已经在手上了,复核只是加一层说明。 所以这里把异常全兜住,最差也要把原答案原样交出去。 + + locked:成果带 _evidence(报告撰写节点产出、逐段对应证据文档)。这种答案只能 + 加说明、不能改写——改写之后界面上点开的出处,指向的是一段已经不存在的文字。 """ severity = worst(signals) rule_retry = should_retry(signals) @@ -309,7 +327,7 @@ async def review( s.detail for s in signals ) - messages = [("system", REVIEW_SYSTEM), ("human", review_prompt(question, answer, signals))] + messages = [("system", REVIEW_SYSTEM), ("human", review_prompt(question, answer, signals, locked=locked))] try: raw = await model.with_structured_output(REVIEW_SCHEMA).ainvoke(messages) if not isinstance(raw, dict): @@ -334,7 +352,7 @@ async def review( # 白烧一次建图加一次跑图。模型没表态时按规则走 retry = rule_retry and bool(raw.get("retry", True)) - if not revised or revised == answer.strip(): + if locked or not revised or revised == answer.strip(): return ReviewResult( verdict="annotated", note=note, retry=retry, signals=signals, severity=severity, ) diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 2a03cae4..0aa2b4df 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -32,6 +32,7 @@ class NodeType(StrEnum): HUMAN = "human" # 人工介入 VALIDATE = "validate" # 结构化校验 + 自动重试 METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源 + REPORT = "report" # 报告撰写:模型只写引用标记,数字由系统从口径卡取出来渲染 # 节点类型在画布上的叫法。报错和门禁文案用它,而不是枚举值——用户在界面上从没 @@ -41,7 +42,7 @@ TYPE_LABEL: dict[str, str] = { "supervisor": "多 Agent 协作", "tool": "调用工具", "code": "沙箱代码", "branch": "条件分支", "loop": "循环", "subgraph": "子工作流", "memory": "长期记忆", "retrieve": "知识检索", "transform": "数据整形", - "human": "人工审批", "validate": "结构校验", "metrics": "口径卡", + "human": "人工审批", "validate": "结构校验", "metrics": "口径卡", "report": "报告撰写", } @@ -367,6 +368,75 @@ def _check_case_keys(node: GraphNode, cases: list[Any], result: ValidationResult seen.add(key) +def _ancestors(spec: GraphSpec, node_id: str) -> set[str]: + """沿连线往回走能到的所有节点(不含自己)。循环里的节点彼此都算上游。""" + parents: dict[str, list[str]] = defaultdict(list) + for e in spec.edges: + parents[e.target].append(e.source) + seen: set[str] = set() + stack = list(parents[node_id]) + while stack: + cur = stack.pop() + if cur not in seen: + seen.add(cur) + stack.extend(parents[cur]) + seen.discard(node_id) + return seen + + +def _check_report_sources(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None: + """报告的 metrics_from 只能指向它上游的口径卡。 + + 指错了运行时不会报错:目录里就是没有那张卡的指标,模型要么写不出数、要么被 + 判成引用不存在——在画图时说破比跑完再猜原因便宜得多。 + """ + sources = node.config.get("metrics_from") + if sources in (None, "", []): + return # 不写就取所有上游口径卡 + if isinstance(sources, str): + sources = [sources] + if not isinstance(sources, list) or not all(isinstance(s, str) and s for s in sources): + result.add("「报告撰写」的 metrics_from 要写口径卡节点的 id 列表", node_id=node.id, + field="metrics_from") + return + nodes = spec.node_map() + upstream = _ancestors(spec, node.id) + for src in sources: + target = nodes.get(src) + if target is None: + result.add(f"「报告撰写」的 metrics_from 指向 {src!r},但工作流里不存在这个节点", + node_id=node.id, field="metrics_from") + elif target.type != NodeType.METRICS: + result.add(f"「报告撰写」的 metrics_from 指向「{target.title}」({src}),它不是「口径卡」" + f"节点,而是「{type_label(target.type)}」:报告里的指标只能从口径卡来", + node_id=node.id, field="metrics_from") + elif src not in upstream: + result.add(f"「报告撰写」的 metrics_from 指向的口径卡「{target.title}」不在它的上游:报告跑的" + "时候那张卡还没算,目录里不会有它的指标。把口径卡连到报告前面", + node_id=node.id, field="metrics_from") + + +def _check_report_from(node: GraphNode, report_from: Any, spec: GraphSpec, + result: ValidationResult) -> None: + """引用模式的契约:report_from 必须是出口上游的报告撰写节点。""" + field = "contract.report_from" + if not isinstance(report_from, str): + result.add("出具契约的 report_from 要写「报告撰写」节点的 id", node_id=node.id, field=field) + return + target = spec.node_map().get(report_from) + if target is None: + result.add(f"出具契约的 report_from 指向 {report_from!r},但工作流里不存在这个节点", + node_id=node.id, field=field) + elif target.type != NodeType.REPORT: + result.add(f"出具契约的 report_from 指向「{target.title}」({report_from}),它不是「报告撰写」" + f"节点,而是「{type_label(target.type)}」:引用模式只核对报告撰写节点产出的文档", + node_id=node.id, field=field) + elif report_from not in _ancestors(spec, node.id): + result.add(f"出具契约的 report_from 指向的「{target.title}」不在这个出口的上游:出口核对的时候" + "那份报告还没写出来。把报告撰写节点连到出口前面", + node_id=node.id, field=field) + + def validate_graph(spec: GraphSpec) -> ValidationResult: """编译前的静态检查。错误会挡住运行,警告只在画布上提示。""" result = ValidationResult() @@ -427,11 +497,15 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if not d.get("id") or not d.get("expression"): result.add(f"指标定义缺 id 或 expression:{d.get('id') or '(空)'}", node_id=node.id, field=f"metrics[{i}]") + elif node.type == NodeType.REPORT: + _check_report_sources(node, spec, result) elif node.type == NodeType.OUTPUT: contract = cfg.get("contract") if contract and not contract.get("metrics_from"): result.add("出具契约缺 metrics_from(指标来自哪个「口径卡」节点)", node_id=node.id, field="contract.metrics_from") + if isinstance(contract, dict) and contract.get("report_from") not in (None, ""): + _check_report_from(node, contract["report_from"], spec, result) elif node.type == NodeType.LOOP: if not spec.outgoing(node.id): result.add("「循环」节点没有循环体", node_id=node.id) diff --git a/backend/app/engine/state.py b/backend/app/engine/state.py index 7995b29f..381b830e 100644 --- a/backend/app/engine/state.py +++ b/backend/app/engine/state.py @@ -32,6 +32,9 @@ class GraphState(TypedDict, total=False): - nodes:node_id -> 该节点的结构化输出,模板里用 {{ nodes.xxx.text }} 引用 - vars:用户显式写入的变量池 - output:output 节点收集的最终成果 + - evidence:证据台账。能被报告引用的工件(口径卡的指标集、查询快照……)按产生 + 顺序记在这里,报告节点建目录时给它们统一编号。只追加不改写;旧 checkpoint 里 + 没有这个通道,续跑时 LangGraph 按空列表起步 """ messages: Annotated[list[AnyMessage], add_messages] @@ -43,6 +46,7 @@ class GraphState(TypedDict, total=False): trail: Annotated[list[dict[str, Any]], operator.add] # 循环计数器:node_id -> 已迭代次数,防止无限循环 loops: Annotated[dict[str, Any], merge_dict] + evidence: Annotated[list[dict[str, Any]], operator.add] def template_context(state: GraphState) -> dict[str, Any]: diff --git a/backend/app/engine/variables.py b/backend/app/engine/variables.py index 76e2881f..92e1ce6c 100644 --- a/backend/app/engine/variables.py +++ b/backend/app/engine/variables.py @@ -115,7 +115,7 @@ _EXPRESSION_FIELDS = ("expression", "condition", "skip_if") _BARE_REF_RE = re.compile(r"\b(?:vars|input|nodes)\.[A-Za-z_][\w.]*") -def _iter_expressions(cfg: dict[str, Any]) -> list[tuple[str, str]]: +def _iter_expressions(cfg: dict[str, Any], node_type: str = "") -> list[tuple[str, str]]: """节点配置里那些按表达式求值的字段。""" out: list[tuple[str, str]] = [] for key in _EXPRESSION_FIELDS: @@ -126,6 +126,13 @@ def _iter_expressions(cfg: dict[str, Any]) -> list[tuple[str, str]]: cond = (case or {}).get("condition") if isinstance(cond, str) and cond.strip(): out.append((f"cases[{i}].condition", cond)) + # 口径卡的每个指标都是一条表达式。以前不扫,于是口径卡引用了谁在变量表里看不见, + # 喂给它的 vars.kpi 还被报成「产出了但没有任何地方引用」 + if node_type == "metrics": + for i, metric in enumerate(cfg.get("metrics") or []): + expr = metric.get("expression") if isinstance(metric, dict) else None + if isinstance(expr, str) and expr.strip(): + out.append((f"metrics[{i}].expression", expr)) return out @@ -320,12 +327,14 @@ def analyze(spec: GraphSpec) -> VariableReport: # 只扫模板语法的话,模板⑦里 collect 那个 # `(vars.collected or '') + str(vars.one)` 就一个引用都认不出来, # 于是 vars.one 被报成"产出了但没有任何地方引用"——而它明明在用。 - for field, expr_text in _iter_expressions(cfg): + for field, expr_text in _iter_expressions(cfg, str(node.type)): for m in _BARE_REF_RE.finditer(expr_text): path, root = _root_of(m.group(0)) if not path: continue - target = index.get(path) + # vars.kpi.gmv 是在用 vars.kpi:和模板引用一样退到能枚举的那一层 + target = index.get(path) or _resolve_fallback( + path, index, input_is_closed=input_is_closed) if target is not None: target.refs.append(VarRef( node_id=node.id, node_label=label, field=field, expr=m.group(0), diff --git a/backend/app/main.py b/backend/app/main.py index 16a5f458..29def10c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api import ( - artifacts, conversations, copilot, datasources, governance, knowledge, runs, + artifacts, conversations, copilot, datasources, evidence, governance, knowledge, runs, sandbox, settings as settings_api, tools, workflows, ) from app.api.coded import CodedHTTPException, coded_handler @@ -91,6 +91,7 @@ for router in ( workflows.router, runs.router, runs.approvals_router, + evidence.router, settings_api.router, tools.router, tools.custom_router, diff --git a/backend/app/seed.py b/backend/app/seed.py index d4ff543a..37ff61a7 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -227,6 +227,62 @@ TEMPLATES: list[dict[str, Any]] = [ _e("caliber", "narrate"), _e("narrate", "done"), ], }, + { + "name": "⑨ 可追溯周报", + "description": "报告里的每个数都点得开出处:tool 节点查库、口径卡登记指标、报告撰写节点只写引用" + "标记,数字由系统从口径卡取出来渲染,出口按引用逐段核对后三档出具。取数节点查的是示例数据源 " + "shop 的 orders 表(week、amount、refunded 三列):先接入你的库(或者上传一张表格、命名为 " + "shop),再把 SQL 换成你的口径。", + "tags": ["出具", "口径卡", "周报", "证据"], + "nodes": [ + _n("start", "input", "周期", fields=[ + {"name": "week", "default": "2026-W37", "description": "报告周期(ISO 周)"}, + {"name": "prev_week", "default": "2026-W36", "description": "对比的上一周期"}]), + # 聚合放在 SQL 里做完,口径卡只做标量运算。列的顺序就是口径卡里 rows[0][n] 的 n + _n("fetch", "tool", "取数(查库)", tool="db_query__shop", args={"sql": ( + "SELECT\n" + " SUM(CASE WHEN week = '{{ input.week }}' THEN amount ELSE 0 END) AS gmv,\n" + " SUM(CASE WHEN week = '{{ input.prev_week }}' THEN amount ELSE 0 END) AS gmv_prev,\n" + " SUM(CASE WHEN week = '{{ input.week }}' THEN 1 ELSE 0 END) AS order_cnt,\n" + " SUM(CASE WHEN week = '{{ input.week }}' AND refunded = 1 THEN 1 ELSE 0 END) AS refund_cnt\n" + "FROM orders")}), + # 查询工具交回来的是 JSON 文本 {columns, rows},解析成对象口径卡才取得到里面的数 + _n("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}", + assign_to="q"), + _n("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version="v1", + metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "vars.q.rows[0][0]"}, + {"id": "orders", "name": "订单数", "unit": "单", "format": "thousands", + "expression": "vars.q.rows[0][2]"}, + # 分母为 0 时记为空值(缺输入),交给契约按 expected 降档,而不是整张卡失败 + {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", + "expression": "round((vars.q.rows[0][0] - vars.q.rows[0][1]) / vars.q.rows[0][1] * 100, 1)" + " if vars.q.rows[0][1] else None"}, + {"id": "aov", "name": "客单价", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "round(vars.q.rows[0][0] / vars.q.rows[0][2], 2) if vars.q.rows[0][2] else None"}, + {"id": "refund_rate", "name": "退款率", "decimals": 4, "format": "percent_of_ratio", + "expression": "vars.q.rows[0][3] / vars.q.rows[0][2] if vars.q.rows[0][2] else None"}, + ]), + _n("write", "report", "报告撰写", metrics_from=["caliber"], + system="你是周报撰写人:结论先行,只写有数据支撑的判断。", + instructions="为 {{ input.week }} 写一份简短的周报:先用一两句话总结本周," + "再分点说销售额、订单、客单价和退款的变化。"), + _n("done", "output", "出具", + fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={ + "report_from": "write", + "metrics_from": ["caliber"], + "required": ["gmv", "orders"], + "expected": ["wow", "aov", "refund_rate"], + "strict": True, + }), + ], + "edges": [ + _e("start", "fetch"), _e("fetch", "parse"), _e("parse", "caliber"), + _e("caliber", "write"), _e("write", "done"), + ], + }, { "name": "⑦ 批量处理(循环)", "description": "对列表逐项处理再汇总。演示 loop 节点的 body / done 两个出口怎么接。", diff --git a/backend/tests/test_contract_citations.py b/backend/tests/test_contract_citations.py new file mode 100644 index 00000000..d3b26171 --- /dev/null +++ b/backend/tests/test_contract_citations.py @@ -0,0 +1,332 @@ +"""出具契约的引用模式:契约写了 report_from,就按报告文档逐段核对,而不是按数值回指。 + +旧的 narrative 模式把叙述里的每个数字按数值去指标集里找——同值的指标一多就找错, +而且只能说「对得上」,说不出「这个位置上的数来自哪里」。报告撰写节点已经把出处写进了 +文档;出口要做的是独立复核一遍,不信报告节点自己的统计: + +- 按 doc_artifact 取回文档(取回时复验哈希),用和报告节点同一套参数重建目录,重新 + 解析、重新渲染每个带引用的片段,逐字比对 +- 裸数字按未回指处理(strict 下不予出具),解析不了的引用同样处理 +- 文档对不上(哈希、渲染、片段)记为 gap:校验本身没法完成,不能盖「完整出具」 +- 成果字段逐字等于报告原文时标注 _evidence,不管有没有契约,界面据此能点开每个数 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core.config import settings +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +KPI = "{'gmv': 45678.5, 'gmv_prev': 42010.0, 'orders': 1234}" +STANDARD = [ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}, + {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", + "expression": "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)"}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"}, +] +CLEAN = "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。[[see:m:gmv,m:wow]]" +CONTRACT = {"metrics_from": ["caliber"], "report_from": "write", "required": ["gmv", "orders"], + "expected": ["wow"]} +REPORT_FIELD = {"name": "周报", "value": "{{ nodes.write.text }}"} + + +def weekly(*, contract=None, report=None, fields=None, card=None, between=()): + out: dict = {"fields": fields or [REPORT_FIELD]} + if contract is not None: + out["contract"] = contract + nodes = [ + node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=STANDARD, + **(card or {})), + node("write", "report", instructions="写周报", **({"on_violation": "flag"} | (report or {}))), + *between, + node("out", "output", **out), + ] + chain = ["start", "fetch", "caliber", "write", *[n["id"] for n in between], "out"] + return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(chain, chain[1:])]} + + +def script(monkeypatch, *replies): + from app.providers import mock_model + + seen: list[list] = [] + + def _decide(self, messages): + seen.append(list(messages)) + return AIMessage(content=replies[min(len(seen), len(replies)) - 1]) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + return seen + + +async def wait(run_id: str, statuses=("succeeded", "failed")) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + # 状态先提交、封存随后才写:终态要等 manifest_seq 落下来再看 + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def finish(graph) -> Run: + run = await run_manager.start(graph=graph, input_payload={}) + return await wait(run.id) + + +async def events(run_id: str, etype: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + .order_by(RunEvent.seq))).scalars()) + + +async def doc_id(run_id: str) -> str: + return (await events(run_id, "report.checked"))[0].data["doc_artifact"] + + +# -------------------------------------------------------------------------- +# 判档 +# -------------------------------------------------------------------------- + + +async def test_fully_cited_report_is_formal(monkeypatch): + script(monkeypatch, CLEAN) + row = await finish(weekly(contract={**CONTRACT, "strict": True})) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + doc = await doc_id(row.id) + + assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance + assert issuance["report"] == {"node_id": "write", "doc_artifact": doc} + assert issuance["gaps"] == [] and issuance["unmatched_numbers"] == [] and issuance["unresolved"] == [] + # 旧字段原样保留,老前端照常能显示 + assert issuance["calibers"] == [{"node": "caliber", "caliber": "周报口径", "version": "v2"}] + assert issuance["metrics_checked"] == 3 and issuance["missing_required"] == [] + assert issuance["missing_expected"] == [] and issuance["matched_numbers"] == 3 + assert issuance["declared_at"] + + text = row.output["周报"] + matched = issuance["matched"] + assert [(m["token"], m["metric"], m["caliber"]) for m in matched] == [ + ("45,678.5元", "gmv", "周报口径 @ v2"), ("8.7%", "wow", "周报口径 @ v2"), ("1,234单", "orders", "周报口径 @ v2")] + for m in matched: + assert text[m["start"]:m["end"]] == m["token"] and m["span"] == [m["start"], m["end"]] + assert m["segment"].startswith("s") and m["unit"].startswith("u") and m["eid"].startswith("ev:metric:") + + # 成果字段就是报告原文:标注出来,界面据此把这个字段画成可点的证据文档 + assert row.output["_evidence"] == {"report_node": "write", "doc_artifact": doc, "fields": ["周报"]} + event = (await events(row.id, "issuance"))[0].data + assert event["tier"] == "formal" and event["mode"] == "citations" and event["unresolved"] == 0 + + +@pytest.mark.parametrize("strict, tier", [(False, "degraded"), (True, "withheld")]) +async def test_a_bare_number_degrades_or_withholds(monkeypatch, strict, tier): + script(monkeypatch, "本周销售额 45678 元,订单 [[m:orders]]。") + row = await finish(weekly(contract={**CONTRACT, "strict": strict})) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == tier and issuance["gaps"] == [] + [bare] = issuance["unmatched_numbers"] + text = row.output["周报"] + assert bare["token"] == "45678" and text[bare["start"]:bare["end"]] == "45678" + assert bare["segment"] and bare["unit"] and "45678" in bare["context"] + assert issuance["matched_numbers"] == 1 + + +async def test_an_unresolved_reference_counts_like_an_uncited_number(monkeypatch): + script(monkeypatch, "本周销售额 [[m:gmvx]],订单 [[m:orders]]。") + row = await finish(weekly(contract={**CONTRACT, "strict": True})) + issuance = row.output["_issuance"] + assert issuance["tier"] == "withheld" and issuance["unmatched_numbers"] == [] + [miss] = issuance["unresolved"] + assert miss["ref"] == "m:gmvx" and "gmvx" in miss["message"] and miss["segment"] + + +async def test_a_tampered_document_is_a_gap_not_formal(monkeypatch): + """报告写完、出口核对之前,文档文件被改了:取回时哈希对不上,这次校验没法完成。""" + script(monkeypatch, CLEAN) + graph = weekly(contract={**CONTRACT, "strict": True}, + between=[node("gate", "human", mode="approve", title="确认")]) + run = await run_manager.start(graph=graph, input_payload={}) + await wait(run.id, ("interrupted", "failed", "succeeded")) + doc = await doc_id(run.id) + path = settings.data_dir / "artifacts" / doc[:2] / f"{doc}.json" + body = json.loads(path.read_text(encoding="utf-8")) + body["markdown"] = body["markdown"].replace("45,678.5", "54,678.5") + path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8") + + await run_manager.resume(run.id, {"approved": True}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] != "formal" + assert any("哈希" in g for g in issuance["gaps"]), issuance["gaps"] + assert issuance["matched_numbers"] == 0 + + +async def test_a_template_that_edits_the_report_is_a_gap(monkeypatch): + script(monkeypatch, CLEAN) + edited = {"name": "周报", "value": "{{ nodes.write.text }}\n\n(以上内容由系统生成)"} + row = await finish(weekly(contract={**CONTRACT, "strict": True}, fields=[edited])) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("周报" in g and "改动了报告" in g for g in issuance["gaps"]), issuance["gaps"] + assert "_evidence" not in row.output + + +async def test_fields_about_the_report_that_leave_its_text_alone_are_not_edits(monkeypatch): + """改写次数、统计、文档 id 这类字段没碰报告正文:不算改动,完整出具照旧。""" + script(monkeypatch, CLEAN) + fields = [REPORT_FIELD, + {"name": "改写次数", "value": "{{ nodes.write.repairs }}"}, + {"name": "统计", "value": "{{ nodes.write.stats | json }}"}, + {"name": "文档", "value": "{{ nodes['write']['doc_artifact'] }}"}, + {"name": "违规", "value": "{{ nodes.write.violations | length }}"}] + row = await finish(weekly(contract={**CONTRACT, "strict": True}, fields=fields)) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance + assert row.output["改写次数"] == "0" and row.output["文档"] == await doc_id(row.id) + assert row.output["_evidence"]["fields"] == ["周报"] + + +@pytest.mark.parametrize("template, report", [ + ("{{ nodes.write.text | lower }}", {}), + ("{{ nodes['write']['text'] | lower }}", {}), + ("{{ nodes.write | json }}", {}), + ("摘录:{{ vars.draft }}", {"assign_to": "draft"}), +]) +async def test_a_second_field_that_reshapes_the_report_text_is_a_gap(monkeypatch, template, report): + """另一个字段把正文换了样子放进成果:读者看到的这一份没法逐段对应,照样记 gap。""" + script(monkeypatch, CLEAN) + row = await finish(weekly(contract={**CONTRACT, "strict": True}, report=report, + fields=[REPORT_FIELD, {"name": "副本", "value": template}])) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert row.output["副本"].strip() != row.output["周报"] + assert issuance["tier"] == "degraded" + assert issuance["gaps"] == ["成果字段「副本」改动了报告,无法逐段对应"], issuance["gaps"] + assert row.output["_evidence"]["fields"] == ["周报"] + + +async def test_a_report_nobody_sees_is_a_gap(monkeypatch): + """契约核对的是报告,成果里却没有哪个字段是它:读者看到的内容没经过核对。""" + script(monkeypatch, CLEAN) + row = await finish(weekly(contract={**CONTRACT, "strict": True}, + fields=[{"name": "周期", "value": "{{ input.week }}"}])) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("没有哪个字段" in g for g in issuance["gaps"]), issuance["gaps"] + + +async def test_rebuilds_the_catalog_with_the_report_nodes_own_parameters(monkeypatch): + """两张卡里有同名指标,报告只取 east:m:gmv 在报告里就是 east.gmv。出口复核要是 + 不按报告节点的 metrics_from 建目录,alias 就成了 m:east.gmv,一份好报告被判成引用不存在。""" + script(monkeypatch, "东区销售额 [[m:gmv]]。") + graph = weekly(contract={"metrics_from": ["east"], "report_from": "write", "required": ["gmv"], + "strict": True}, + report={"metrics_from": ["east"]}) + graph["nodes"][2:3] = [ + node("east", "metrics", caliber="东区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.gmv"}]), + node("west", "metrics", caliber="西区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.orders"}]), + ] + graph["edges"] = [{"source": a, "target": b} for a, b in [ + ("start", "fetch"), ("fetch", "east"), ("fetch", "west"), ("east", "write"), ("west", "write"), + ("write", "out")]] + row = await finish(graph) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal", issuance + assert [(m["token"], m["metric"], m["caliber"]) for m in issuance["matched"]] == [("45,678.5元", "gmv", "东区 @ v1")] + + +async def test_missing_report_output_is_a_gap(monkeypatch): + script(monkeypatch, CLEAN) + row = await finish(weekly(contract={**CONTRACT, "strict": True}, + report={"skip_if": "input.week == '2026-W37'"})) + issuance = row.output["_issuance"] + assert issuance["tier"] != "formal" + assert any("write" in g and "报告文档" in g for g in issuance["gaps"]), issuance["gaps"] + + +async def test_a_required_metric_without_a_value_is_missing(monkeypatch): + """on_missing=null 让缺输入的指标记为空值、交给契约判档:必需指标是空的,就是缺。""" + script(monkeypatch, "订单 [[m:orders]]。") + metrics = [*STANDARD, {"id": "margin", "name": "毛利", "expression": "vars.kpi.cost"}] + graph = weekly(contract={**CONTRACT, "required": ["gmv", "margin"], "strict": True}, + card={"on_missing": "null"}) + graph["nodes"][2]["data"]["config"]["metrics"] = metrics + row = await finish(graph) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["tier"] == "withheld" and issuance["missing_required"] == ["margin"] + + +async def test_claim_policies_are_not_silently_skipped(monkeypatch): + """结论句检查属于后续版本。契约声明了它,这一次就没核完,照实记一条 gap。""" + script(monkeypatch, CLEAN) + row = await finish(weekly(contract={**CONTRACT, "strict": True, "claims": {"policy": "judge"}})) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("结论句" in g for g in issuance["gaps"]), issuance["gaps"] + + +# -------------------------------------------------------------------------- +# _evidence:不管有没有契约 +# -------------------------------------------------------------------------- + + +async def test_evidence_is_marked_without_a_contract(monkeypatch): + script(monkeypatch, CLEAN) + row = await finish(weekly(fields=[REPORT_FIELD, {"name": "周期", "value": "{{ input.week }}"}])) + assert row.status == "succeeded", row.error + assert "_issuance" not in row.output + assert row.output["_evidence"] == {"report_node": "write", "doc_artifact": await doc_id(row.id), + "fields": ["周报"]} + + +async def test_graphs_without_a_report_are_untouched(monkeypatch): + graph = {"nodes": [node("start", "input", fields=[{"name": "q", "default": "x"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ input.q }}"}])], + "edges": [{"source": "start", "target": "out"}]} + row = await finish(graph) + assert row.status == "succeeded", row.error + assert row.output == {"r": "x"} + + +async def test_narrative_contracts_keep_their_old_shape(monkeypatch): + """没写 report_from 的旧契约:_issuance 的键和以前一模一样,不多一个 mode。""" + graph = weekly(contract={"metrics_from": ["caliber"], "narrative": "销售额 45678.5 元", + "required": ["gmv"]}, fields=[{"name": "r", "value": "{{ input.week }}"}]) + graph["nodes"] = [n for n in graph["nodes"] if n["id"] != "write"] + graph["edges"] = [{"source": a, "target": b} for a, b in [("start", "fetch"), ("fetch", "caliber"), + ("caliber", "out")]] + row = await finish(graph) + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert set(issuance) == {"tier", "calibers", "metrics_checked", "missing_required", "missing_expected", + "unmatched_numbers", "matched_numbers", "matched", "gaps", "declared_at"} + assert issuance["tier"] == "formal" diff --git a/backend/tests/test_copilot_evidence_guide.py b/backend/tests/test_copilot_evidence_guide.py new file mode 100644 index 00000000..a015bbe9 --- /dev/null +++ b/backend/tests/test_copilot_evidence_guide.py @@ -0,0 +1,40 @@ +"""Copilot 的节点手册里要有口径卡、报告撰写和出具契约的写法,以及搭图规则。 + +手册里没有的节点,Copilot 就不会用:以前手册里连口径卡都没有,只在数据源那一节 +提了一句「取数结果要进口径卡」,于是它搭出来的图全是 agent → llm → output,叙述里 +的数字没有一个能回指。 +""" +from __future__ import annotations + +from app.api.copilot import NODE_REFERENCE, GenerateIn, _user_message +from app.engine.evidence import render_number + + +def test_node_reference_covers_calibers_reports_and_contracts(): + ref = NODE_REFERENCE + for words in ( + ["- metrics:口径卡", "expression", "decimals", "format", "on_missing"], + ["- report:报告撰写", "[[m:指标id]]", "[[i:", "metrics_from", "on_violation", "不要用 llm"], + ["report_from", "required", "strict"], + ["搭图规则", "口径卡 → report → output", "SUM / COUNT", "code 只做格式转换"], + ): + assert all(w in ref for w in words), [w for w in words if w not in ref] + # percent_of_ratio 的小数位按比率算:照「两位小数」写 2 的话 0.0235 显示成 2%,0.004 直接显示不出来 + assert "percent_of_ratio 的 decimals 按比率算:要显示两位百分比(2.35%)写 4" in ref + assert render_number(0.0235, decimals=4, fmt="percent_of_ratio") == "2.35%" + assert render_number(0.0235, decimals=2, fmt="percent_of_ratio") == "2%" + # 成果字段要原样引用报告:前后拼了字就没法逐段对应证据 + assert "{{ nodes.报告节点id.text }}" in ref + # 老规矩还在 + assert "不要写 max_steps" in ref + + +def test_report_model_follows_the_same_model_rule(): + assert "llm / agent / supervisor / report 的 config.model" in NODE_REFERENCE + + +def test_answer_branch_mentions_traceable_reports(): + text = _user_message(GenerateIn(instruction="上周销售额多少", intent="answer"), patch=False) + assert "report" in text and "口径卡" in text and "report_from" in text + # 普通问数不强加报告:本期 agent 的结论还进不了口径卡,强加只会让答案里的数全判成无出处 + assert "普通的问数照旧" in text diff --git a/backend/tests/test_evidence_api.py b/backend/tests/test_evidence_api.py new file mode 100644 index 00000000..ae08f9b8 --- /dev/null +++ b/backend/tests/test_evidence_api.py @@ -0,0 +1,386 @@ +"""证据接口:整次运行的证据图,和点开一个片段时的出处链。 + +只认封存范围内的事件(seq ≤ manifest_seq)能追到的东西:报告文档从 report.checked +找,口径卡的指标集从 node.finished.evidence 找,成果从出口节点 node.finished 的工件找。 +artifacts 表可以事后插行、封存之后追加的事件不在核对范围里,都不能当证据的来源。 + +旧运行没有报告文档:有旧契约的按位置信息把旧的 matched 标出来(legacy_contract), +什么都没有的照实说没有(none)。本期不按数值猜来源。 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core.config import settings +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.evidence import input_eid, iter_segments +from app.engine.runner import run_manager +from app.main import app + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +KPI = "{'gmv': 45678.5, 'gmv_prev': 42010.0, 'orders': 1234}" +STANDARD = [ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}, + {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", + "expression": "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)"}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"}, +] +CLEAN = "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。[[see:m:gmv,m:wow]]" +WOW = "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)" + + +def weekly(*, between=(), report=None): + nodes = [ + node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=STANDARD), + node("write", "report", instructions="写周报", **({"on_violation": "flag"} | (report or {}))), + *between, + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"metrics_from": ["caliber"], "report_from": "write", "required": ["gmv"], + "strict": True}), + ] + chain = ["start", "fetch", "caliber", "write", *[n["id"] for n in between], "out"] + return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(chain, chain[1:])]} + + +def script(monkeypatch, text): + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + + +async def wait(run_id: str, statuses=("succeeded", "failed")) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + # 状态先提交、封存随后才写:终态要等 manifest_seq 落下来再看 + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def run_graph(graph, statuses=("succeeded", "failed")) -> Run: + run = await run_manager.start(graph=graph, input_payload={}) + return await wait(run.id, statuses) + + +async def events(run_id: str, etype: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + .order_by(RunEvent.seq))).scalars()) + + +def load_doc(doc_id: str) -> dict: + return json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8")) + + +def segment_of(doc: dict, text: str) -> dict: + return next(s for s in iter_segments(doc) if s["text"] == text) + + +async def reseal(run_id: str) -> None: + """按改过之后的事件重算封存哈希:模拟「封存链完好、但台账里没有这件工件」。""" + from app.core.artifact_store import manifest_hash + + async with SessionLocal() as session: + run = await session.get(Run, run_id) + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id, RunEvent.seq <= run.manifest_seq).order_by(RunEvent.seq))] + run.manifest_hash = manifest_hash(rows) + await session.commit() + + +async def cited_run(monkeypatch, text=CLEAN, **kw) -> tuple[Run, dict]: + script(monkeypatch, text) + row = await run_graph(weekly(**kw)) + assert row.status == "succeeded", row.error + return row, load_doc(row.output["_evidence"]["doc_artifact"]) + + +# -------------------------------------------------------------------------- +# 证据图 +# -------------------------------------------------------------------------- + + +async def test_graph_of_a_cited_run(client, monkeypatch): + row, doc = await cited_run(monkeypatch) + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + + assert body["run_id"] == row.id and body["schema"] == "agentlab.evidence/1" + assert body["mode"] == "cited" + assert body["seal"] == {"sealed": True, "ok": True, "manifest_seq": row.manifest_seq, "legacy": False} + [report] = body["reports"] + assert report["node_id"] == "write" and report["doc_artifact"] == row.output["_evidence"]["doc_artifact"] + assert report["doc_sealed"] is True and report["hash_ok"] is True and report["ok"] is True + assert report["fields"] == ["周报"] and report["stats"]["numbers_cited"] == 3 + + by_alias = {e["alias"]: e for e in body["evidence"]} + card = next(e for e in await events(row.id, "node.finished") if e.node_id == "caliber").data + gmv = by_alias["m:gmv"] + assert gmv["kind"] == "metric" and gmv["node_id"] == "caliber" and gmv["report"] == "write" + assert gmv["artifact"] == card["evidence"][0]["artifact"] and gmv["sealed"] is True + assert gmv["label"] == "销售额 = 45,678.5元" and gmv["eid"].startswith("ev:metric:") + assert gmv["cited_by"] == [segment_of(doc, "45,678.5元")["id"]] + week = by_alias["i:week"] + assert week["kind"] == "input" and week["sealed"] is True + assert week["eid"] == input_eid("week", "2026-W37") + + edges = body["edges"] + wow_seg = segment_of(doc, "8.7%")["id"] + assert {"from": wow_seg, "to": "m:wow", "rel": "cites", "report": "write"} in edges + inputs = [e for e in edges if e["rel"] == "input" and e["from"] == "m:wow"] + assert [(e["to"], e["node_id"]) for e in inputs] == [("vars.kpi.gmv", "fetch"), ("vars.kpi.gmv_prev", "fetch")] + + +async def test_runs_without_a_report(client): + plain = await run_graph({ + "nodes": [node("start", "input", fields=[{"name": "q", "default": "x"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ input.q }}"}])], + "edges": [{"source": "start", "target": "out"}]}) + body = (await client.get(f"/api/runs/{plain.id}/evidence")).json() + assert body["mode"] == "none" and body["reports"] == [] and body["evidence"] == [] + assert body["note"] + miss = await client.get(f"/api/runs/{plain.id}/evidence/segments/s0") + assert miss.status_code == 404 and miss.json()["code"] == "evidence_report_not_found" + + +async def test_a_citations_contract_without_a_document_is_not_called_legacy(client, monkeypatch): + """契约是引用模式,报告却被跳过了:没有文档可看,但这不是「旧版出具」,照实说并带上缘由。""" + script(monkeypatch, CLEAN) + row = await run_graph(weekly(report={"skip_if": "input.week == '2026-W37'"})) + assert row.status == "succeeded", row.error + assert row.output["_issuance"]["mode"] == "citations" + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert body["mode"] == "none" and "legacy" not in body and body["reports"] == [] + assert "引用模式" in body["note"] and "write" in body["note"] and "报告文档" in body["note"], body["note"] + + +async def test_legacy_contract_marks_old_matches_by_position(client): + """旧契约的 matched 带着位置:按位置标出来,并认出偏移对得上的是哪个成果字段。不按数值猜。""" + story = "本周销售额 45678.5 元,订单 1234 单,另有 999 笔待核。" + row = await run_graph({ + "nodes": [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", metrics=STANDARD), + node("narr", "transform", mode="template", template=story, assign_to="story"), + # 另一个字段也是文本:偏移只在叙述那个字段上对得上,认字段靠核对位置,不靠「只有一个」 + node("out", "output", fields=[{"name": "说明", "value": "本报告由系统按周生成,数据截至周日"}, + {"name": "r", "value": "{{ vars.story }}"}], + contract={"metrics_from": ["caliber"], "narrative": "{{ vars.story }}"}), + ], + "edges": [{"source": a, "target": b} for a, b in [ + ("start", "fetch"), ("fetch", "caliber"), ("caliber", "narr"), ("narr", "out")]]}) + assert row.status == "succeeded", row.error + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert body["mode"] == "legacy_contract" and body["reports"] == [] and body["evidence"] == [] + legacy = body["legacy"] + assert "按数值匹配" in legacy["note"] and legacy["tier"] == "degraded" and legacy["field"] == "r" + assert [(m["token"], m["metric"], m["positioned"]) for m in legacy["matched"]] == [ + ("45678.5", "gmv", True), ("1234", "orders", True)] + for m in legacy["matched"] + legacy["unmatched"]: + assert story[m["start"]:m["end"]] == m["token"] + assert [u["token"] for u in legacy["unmatched"]] == ["999"] + + +# -------------------------------------------------------------------------- +# 片段 +# -------------------------------------------------------------------------- + + +async def test_a_metric_segment_opens_its_chain(client, monkeypatch): + row, doc = await cited_run(monkeypatch) + seg = segment_of(doc, "8.7%") + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}") + assert resp.status_code == 200, resp.text + body = resp.json() + + assert body["segment"]["id"] == seg["id"] and body["segment"]["text"] == "8.7%" + assert body["segment"]["state"] == "deterministic" and body["segment"]["span"] == seg["span"] + assert body["unit"]["kind"] == "claim" and "环比 8.7%" in body["unit"]["text"] + assert body["segment"]["unit"] == body["unit"]["id"] + assert body["report"] == {"node_id": "write", "doc_artifact": row.output["_evidence"]["doc_artifact"]} + + metric, *inputs = body["chain"] + assert metric["step"] == "metric" and metric["alias"] == "m:wow" and metric["name"] == "环比增幅" + assert metric["value"] == 8.7 and metric["rendered"] == "8.7%" and metric["unit"] == "%" + assert (metric["caliber"], metric["version"]) == ("周报口径", "v2") + assert metric["expression"] == WOW + assert metric["substituted"] == "round((45678.5 - 42010.0) / 42010.0 * 100, 1)" + assert metric["recompute_ok"] is True and metric["caliber_upgrade"] is None + assert metric["hash_ok"] is True and metric["eid_ok"] is True and metric["render_ok"] is True + assert metric["sealed"] is True and metric["node_id"] == "caliber" + assert [(i["step"], i["path"], i["value"], i["node_id"], i["via"]) for i in inputs] == [ + ("input", "vars.kpi.gmv", 45678.5, "fetch", "transform"), + ("input", "vars.kpi.gmv_prev", 42010.0, "fetch", "transform")] + assert body["seal"] == {"sealed": True, "ok": True, "covered": True} + assert body["redacted"] == {"columns": []} + + +async def test_an_input_segment_opens_the_run_input(client, monkeypatch): + row, doc = await cited_run(monkeypatch) + seg = segment_of(doc, "2026-W37") + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json() + [step] = body["chain"] + assert step["step"] == "run_input" and step["field"] == "week" and step["value"] == "2026-W37" + assert step["eid_ok"] is True and step["sealed"] is True and step["node_id"] == "start" + assert body["seal"]["covered"] is True + + +async def test_uncited_and_connective_segments_explain_themselves(client, monkeypatch): + row, doc = await cited_run(monkeypatch, "本周销售额 45678 元。下面分区域看。") + bare = next(s for s in iter_segments(doc) if s.get("issue") == "uncited_number") + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{bare['id']}")).json() + assert body["chain"] == [] and "没有出处" in body["note"] + assert [v["code"] for v in body["violations"]] == ["uncited_number"] + + link = next(s for s in iter_segments(doc) if s["text"] == "下面分区域看。") + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{link['id']}")).json() + assert body["unit"]["kind"] == "connective" and "连接性" in body["note"] and body["chain"] == [] + + +async def test_missing_things_are_coded_404s(client, monkeypatch): + row, _ = await cited_run(monkeypatch) + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/s9999") + assert resp.status_code == 404 and resp.json()["code"] == "evidence_segment_not_found" + assert resp.json()["detail"] + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/s0", params={"report": "nope"}) + assert resp.status_code == 404 and resp.json()["code"] == "evidence_report_not_found" + for path in ("/api/runs/no-such-run/evidence", "/api/runs/no-such-run/evidence/segments/s0"): + resp = await client.get(path) + assert resp.status_code == 404 and resp.json()["code"] == "run_not_found" + + +# -------------------------------------------------------------------------- +# 只信封存链 +# -------------------------------------------------------------------------- + + +async def test_events_appended_after_the_seal_are_ignored(client, monkeypatch): + """封存之后插进来的 report.checked,哪怕指向一份真的文档,也不能让一次没有报告的运行 + 变成「有证据」。""" + cited, _ = await cited_run(monkeypatch) + doc_id = cited.output["_evidence"]["doc_artifact"] + plain = await run_graph({ + "nodes": [node("start", "input", fields=[{"name": "q", "default": "x"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ input.q }}"}])], + "edges": [{"source": "start", "target": "out"}]}) + async with SessionLocal() as session: + session.add(RunEvent(run_id=plain.id, seq=plain.manifest_seq + 5, type="report.checked", + node_id="write", ts=0.0, data={"doc_artifact": doc_id, "ok": True})) + await session.commit() + body = (await client.get(f"/api/runs/{plain.id}/evidence")).json() + assert body["mode"] == "none" and body["reports"] == [] + assert body["seal"]["ok"] is True # 封存范围内的事件没被动过 + + +async def test_a_tampered_document_is_reported_not_served(client, monkeypatch): + row, doc = await cited_run(monkeypatch) + doc_id = row.output["_evidence"]["doc_artifact"] + path = settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json" + body = json.loads(path.read_text("utf-8")) + body["markdown"] = body["markdown"].replace("45,678.5", "54,678.5") + path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8") + + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + [report] = graph["reports"] + assert report["hash_ok"] is False and graph["evidence"] == [] + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/s0") + assert resp.status_code == 409 and resp.json()["code"] == "evidence_doc_tampered" + + +async def test_an_unsealed_run_is_marked_as_such(client, monkeypatch): + """停在审批上的运行还没封存:证据照样能看,但每一件都标「未封存」。""" + script(monkeypatch, CLEAN) + run = await run_manager.start(graph=weekly(between=[node("gate", "human", mode="approve", title="确认")]), + input_payload={}) + await wait(run.id, ("interrupted", "failed")) + body = (await client.get(f"/api/runs/{run.id}/evidence")).json() + assert body["mode"] == "cited" and body["seal"]["sealed"] is False + assert body["reports"][0]["doc_sealed"] is False and body["reports"][0]["fields"] == [] + assert all(e["sealed"] is False for e in body["evidence"]) + doc = load_doc(body["reports"][0]["doc_artifact"]) + seg = segment_of(doc, "45,678.5元") + chain = (await client.get(f"/api/runs/{run.id}/evidence/segments/{seg['id']}")).json() + assert chain["chain"][0]["step"] == "metric" and chain["seal"] == {"sealed": False, "ok": None, + "covered": False} + + +async def test_a_missing_document_file_is_not_called_tampered(client, monkeypatch): + row, _ = await cited_run(monkeypatch) + doc_id = row.output["_evidence"]["doc_artifact"] + (settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").unlink() + [report] = (await client.get(f"/api/runs/{row.id}/evidence")).json()["reports"] + assert report["hash_ok"] is None + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/s0") + assert resp.status_code == 404 and resp.json()["code"] == "evidence_doc_missing" + + +async def test_a_metric_the_sealed_ledger_does_not_know_is_not_sealed(client, monkeypatch): + """文档目录里写着指标集工件 X,但封存的事件里口径卡从没记过 X:链照样能看,却不能标「已封存」。 + 这里改掉事件里的台账后重新封存,封存核对是通过的——拦下它的只能是台账核对。""" + row, doc = await cited_run(monkeypatch) + async with SessionLocal() as session: + finished = (await session.execute(select(RunEvent).where( + RunEvent.run_id == row.id, RunEvent.type == "node.finished", RunEvent.node_id == "caliber"))).scalar_one() + finished.data = {k: v for k, v in finished.data.items() if k != "evidence"} + await session.commit() + await reseal(row.id) + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert graph["seal"]["ok"] is True + sealed = {e["alias"]: e["sealed"] for e in graph["evidence"]} + assert sealed["m:gmv"] is False and sealed["i:week"] is True + seg = segment_of(doc, "45,678.5元") + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json() + assert body["chain"][0]["sealed"] is False and body["chain"][0]["hash_ok"] is True + assert body["seal"] == {"sealed": True, "ok": True, "covered": False} + + +async def test_a_broken_seal_marks_nothing_as_sealed(client, monkeypatch): + """封存范围里随便一条事件被改过,封存链就断了:哪一件证据都不能再标「已封存」, + 哪怕它自己的那几条事件没被碰过。只看 sealed 字段的消费方也不会画出绿色。""" + row, doc = await cited_run(monkeypatch) + async with SessionLocal() as session: + started = (await session.execute(select(RunEvent).where( + RunEvent.run_id == row.id, RunEvent.type == "node.started", RunEvent.node_id == "start"))).scalar_one() + started.data = {**(started.data or {}), "note": "事后改过"} + await session.commit() + + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert graph["seal"]["sealed"] is True and graph["seal"]["ok"] is False + assert graph["mode"] == "cited" and graph["evidence"] + assert all(e["sealed"] is False for e in graph["evidence"]), graph["evidence"] + assert graph["reports"][0]["doc_sealed"] is False + for text in ("45,678.5元", "2026-W37"): + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{segment_of(doc, text)['id']}")).json() + assert body["chain"][0]["sealed"] is False, body["chain"][0] + assert body["seal"] == {"sealed": True, "ok": False, "covered": False} diff --git a/backend/tests/test_evidence_markers.py b/backend/tests/test_evidence_markers.py new file mode 100644 index 00000000..a06eefca --- /dev/null +++ b/backend/tests/test_evidence_markers.py @@ -0,0 +1,659 @@ +"""证据内核:引用标记的解析、目录、渲染、切块切句切片段、裸数字,以及独立复核。 + +这一层是纯函数,报告节点自查、出口契约复核、证据接口都用它。所以这里守的是 +「同样的输入永远得到同样的结果」和「复核不信任文档自己的说法」两件事。 +""" +from __future__ import annotations + +import copy + +import pytest + +from app.core.artifact_store import canonical_json, content_hash +from app.engine.evidence import ( + DOC_SCHEMA, + LATER_REASON, + MARKER_MAX, + RenderError, + StreamRenderer, + build_catalog, + catalog_prompt, + compose_doc, + describe_violations, + find_segment, + input_eid, + iter_segments, + make_eid, + parse_markers, + render_markers, + render_number, + verify_doc, +) + +# -------------------------------------------------------------------------- +# 夹具:一张口径卡的产出(形状同 metrics 节点),外加运行输入 +# -------------------------------------------------------------------------- + +ART = "a" * 64 + + +def metric(mid, name, value, unit="", decimals=None, fmt="thousands", status="ok"): + return {"id": mid, "name": name, "unit": unit, "value": value, "decimals": decimals, + "format": fmt, "status": status, "expression": "…"} + + +CARD = { + "kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": ART, + "metrics": [ + metric("gmv", "销售额", 45678.5, "元", 1), + metric("wow", "环比增幅", 8.7, "%", 1, "plain"), + metric("orders", "订单数", 1234, "单"), + metric("aov", "客单价", 37, "元", 1), + metric("refund_rate", "退款率", 0.0235, "", 4, "percent_of_ratio"), + metric("big", "大数", 45678.5), + metric("gone", "缺数", None, status="missing_input"), + ], +} +LEDGER = [{"kind": "metric_set", "node_id": "caliber", "exec": 1, "artifact": ART, + "caliber": "周报口径", "version": "v2", "metrics": [m["id"] for m in CARD["metrics"]]}] +NODES = {"caliber": CARD, "fetch": {"text": "上游笔记 999"}} +INPUTS = {"week": "2026-W37", "region": "华东", "rows": [1, 2]} + + +@pytest.fixture +def catalog(): + return build_catalog(nodes=NODES, ledger=LEDGER, inputs=INPUTS) + + +def texts(doc): + return [s["text"] for s in iter_segments(doc)] + + +def codes(doc): + return [v["code"] for v in doc["violations"]] + + +# -------------------------------------------------------------------------- +# 标记语法 +# -------------------------------------------------------------------------- + + +def test_markers_parse(): + text = ("销售额 [[m:gmv]],折合 [[m:gmv|万]]。[[see:m:gmv, m:wow,Q4]] 周期 [[i:week]]," + "[[v:Q4.r0.amount]] [[t:orders]] [[c:orders.amount]] [[q:K2|原话。]] " + "[[table:Q4 cols=a,b rows=0-4]] [[metric:gmv]] [[注意]]") + found = parse_markers(text) + assert [(m["kind"], m.get("ref")) for m in found] == [ + ("m", "gmv"), ("m", "gmv"), ("see", None), ("i", "week"), ("v", "Q4.r0.amount"), + ("t", "orders"), ("c", "orders.amount"), ("q", "K2"), ("table", "Q4"), ("metric", "gmv")] + assert found[1]["conv"] == "万" + assert found[2]["refs"] == ["m:gmv", "m:wow", "Q4"] + assert found[7]["quote"] == "原话。" + assert found[8]["options"] == {"cols": "a,b", "rows": "0-4"} + for m in found: + assert text[m["start"]:m["end"]] == m["raw"] + + +# -------------------------------------------------------------------------- +# 目录与 eid +# -------------------------------------------------------------------------- + + +def test_catalog_entries_and_stable_eids(catalog): + gmv = catalog["m:gmv"] + assert gmv["kind"] == "metric" and gmv["node_id"] == "caliber" and gmv["artifact"] == ART + assert gmv["locator"] == {"metric": "gmv"} and gmv["caliber"] == "周报口径" and gmv["version"] == "v2" + assert gmv["rendered"] == "45,678.5元" and gmv["label"] == "销售额 = 45,678.5元" + assert gmv["eid"] == make_eid("metric", ART, {"metric": "gmv"}) + assert gmv["eid"].startswith("ev:metric:") and len(gmv["eid"]) == len("ev:metric:") + 16 + assert catalog["i:week"]["value"] == "2026-W37" and catalog["i:week"]["kind"] == "input" + assert "i:rows" not in catalog # 容器型输入没法写进一句话 + # 同一输入,eid 一个字不差 + again = build_catalog(nodes=copy.deepcopy(NODES), ledger=copy.deepcopy(LEDGER), inputs=dict(INPUTS)) + assert {k: v["eid"] for k, v in again.items()} == {k: v["eid"] for k, v in catalog.items()} + # 内容变了,eid 跟着变 + other = build_catalog(nodes={"caliber": {**CARD, "artifact": "b" * 64}}, + ledger=[{**LEDGER[0], "artifact": "b" * 64}], inputs=INPUTS) + assert other["m:gmv"]["eid"] != gmv["eid"] + + +def test_catalog_respects_metrics_from_and_allowed(): + second = {**CARD, "caliber": "月报口径", "artifact": "c" * 64, + "metrics": [metric("gmv", "销售额", 1.0), metric("mau", "月活", 9)]} + nodes = {**NODES, "monthly": second} + ledger = [*LEDGER, {**LEDGER[0], "node_id": "monthly", "artifact": "c" * 64}] + both = build_catalog(nodes=nodes, ledger=ledger) + # 两张卡都有 gmv:不许按先来后到默认给一个,只登记带卡名的写法 + assert "m:gmv" not in both + assert both["m:caliber.gmv"]["value"] == 45678.5 and both["m:monthly.gmv"]["value"] == 1.0 + assert both["m:mau"]["node_id"] == "monthly" + only = build_catalog(nodes=nodes, ledger=ledger, metrics_from=["monthly"]) + assert only["m:gmv"]["node_id"] == "monthly" and "m:orders" not in only + upstream = build_catalog(nodes=nodes, ledger=ledger, allowed={"caliber"}) + assert "m:mau" not in upstream and upstream["m:gmv"]["node_id"] == "caliber" + + +# -------------------------------------------------------------------------- +# 渲染 +# -------------------------------------------------------------------------- + + +def test_rendering(catalog): + raw = ("本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]],客单价 [[m:aov]]。" + "[[see:m:gmv,m:wow]]\n\n退款率 [[m:refund_rate]],大数折合 [[m:big|万]],销售额折合 [[m:gmv|万]]。") + doc = compose_doc(raw, catalog, node_id="write", run_id="r1") + assert doc["schema"] == DOC_SCHEMA and doc["node_id"] == "write" and doc["run_id"] == "r1" + assert doc["markdown"] == ("本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。" + "\n\n退款率 2.35%,大数折合 4.57万,销售额折合 4.57万元。") + assert doc["violations"] == [] and doc["stats"]["numbers_cited"] == 7 + seg = next(s for s in iter_segments(doc) if s["text"] == "45,678.5元") + assert seg["kind"] == "number" and seg["state"] == "deterministic" and seg["ref"] == "m:gmv" + assert seg["cite"]["status"] == "resolved" and seg["cite"]["value"] == 45678.5 + assert seg["cite"]["eid"] == catalog["m:gmv"]["eid"] and seg["cite"]["alias"] == "m:gmv" + week = next(s for s in iter_segments(doc) if s["text"] == "2026-W37") + assert week["kind"] == "value" and week["ref"] == "i:week" + + +def _one_metric_catalog(value, unit="", decimals=None, fmt="thousands"): + card = {**CARD, "metrics": [metric("small", "小值", value, unit, decimals, fmt)]} + return build_catalog(nodes={"caliber": card}, ledger=LEDGER) + + +@pytest.mark.parametrize("value, unit, decimals, fmt, conv", [ + (29, "单", None, "thousands", "万"), # 0.0029万 → 显示成「0万单」 + (45678.5, "元", 1, "thousands", "亿"), # 0.000456785亿 → 「0亿元」 + (0.004, "", None, "thousands", "int"), + (-0.004, "", None, "thousands", ".2"), + (0.00004, "", None, "thousands", "pct"), # 0.004% 按一位小数是 0.0% + (0.00004, "", 4, "percent_of_ratio", "万"), # 比率不能换算(这条本来就解析不了) +]) +def test_conversions_never_show_a_nonzero_value_as_zero(value, unit, decimals, fmt, conv): + """系统渲染的数带着「有出处」的实线:把 29 单换算成「0万单」再标成有出处,比模型 + 自己编一个数还糟——读者没有任何理由怀疑它。显示成 0 的非零值一律算解析不了。""" + cat = _one_metric_catalog(value, unit, decimals, fmt) + doc = compose_doc(f"数值 [[m:small|{conv}]]。", cat) + seg = next(s for s in iter_segments(doc) if s.get("ref")) + assert seg["state"] == "none" and seg["issue"] == "unresolved_ref", seg + assert seg["cite"]["status"] == "unresolved" + assert codes(doc) == ["unresolved_ref"] + assert seg["text"] == f"⟦?m:small|{conv}⟧" + if fmt != "percent_of_ratio": + assert "显示为 0" in seg["cite"]["reason"] and "精度不够" in seg["cite"]["reason"] + + +def test_values_that_cannot_be_shown_are_flagged_in_the_catalog_and_prompt(): + cat = _one_metric_catalog(1e-12) + assert cat["m:small"]["rendered"] == "—" and cat["m:small"]["value"] == 1e-12 + assert "[[m:small]] 小值:按口径卡的格式显示不出来,不要引用" in catalog_prompt(cat) + doc = compose_doc("小值 [[m:small]]。", cat) + assert codes(doc) == ["unresolved_ref"] and "显示为 0" in doc["violations"][0]["message"] + + +def test_zero_guard_only_fires_for_nonzero_values(): + assert render_number(0, conv="万") == "0万" + assert render_number(0.0, conv="int") == "0" + assert render_number(-0.0, conv=".2") == "0.00" + assert render_number(50, unit="单", conv="万") == "0.01万单" # 四舍五入到 0.01,不是 0 + assert render_number(0.005, conv=".2") == "0.01" + with pytest.raises(RenderError, match="显示为 0"): + render_number(1e-12) # 不写小数位时最多留 10 位 + with pytest.raises(RenderError, match="显示为 0"): + render_number(29, decimals=-2) # 口径卡自己的小数位把它抹成 0 也一样 + + +@pytest.mark.parametrize("value, expected", [ + (1e55, f"{10 ** 55:,}"), + (9e14 ** 4, f"{6561 * 10 ** 56:,}"), + (10 ** 70 + 1, f"{10 ** 70 + 1:,}"), # 超过 60 位有效数字也不能被悄悄舍入 + (-(10 ** 65) - 7, f"{-(10 ** 65) - 7:,}"), +]) +def test_huge_values_render_exactly(value, expected): + assert render_number(value) == expected + + +def test_huge_values_with_conversions_and_decimals(): + assert render_number(10 ** 40 + 12345, conv="万") == f"{10 ** 36 + 1:,}.23万" + assert render_number(10 ** 40 + 1, conv="pct") == f"{(10 ** 40 + 1) * 100:,}.0%" + assert render_number(1e55, decimals=2) == f"{10 ** 55:,}.00" + assert render_number(12345.678, decimals=-2) == "12,300" + with pytest.raises(RenderError): + render_number(10 ** 5000) # 大到不像话:说清楚,而不是抛 InvalidOperation + with pytest.raises(RenderError): + render_number(1.5, decimals=10 ** 6) + + +def test_input_eids_are_derived_from_the_value(): + w37 = build_catalog(nodes={}, inputs={"week": "2026-W37"})["i:week"] + w38 = build_catalog(nodes={}, inputs={"week": "2026-W38"})["i:week"] + again = build_catalog(nodes={}, inputs={"week": "2026-W37"})["i:week"] + assert w37["eid"] != w38["eid"] and w37["eid"] == again["eid"] + assert w37["eid"] == input_eid("week", "2026-W37") + # 目录条目自己就能复算:值被改过,eid 就对不上 + assert w37["eid"] == make_eid("input", content_hash(canonical_json(w37["value"])), w37["locator"]) + # 同一个值换一个字段名也是另一件证据 + assert build_catalog(nodes={}, inputs={"period": "2026-W37"})["i:period"]["eid"] != w37["eid"] + + +@pytest.mark.parametrize("empty", [[], ""]) +def test_empty_metrics_from_means_every_card(empty): + """validate_graph 把 metrics_from=[] 当成「所有上游口径卡」,目录要是当成「一张都不要」, + 报告就一个数都写不出来,而画布上什么都不提示。两边必须同一个意思。""" + full = build_catalog(nodes=NODES, ledger=LEDGER, metrics_from=None) + assert build_catalog(nodes=NODES, ledger=LEDGER, metrics_from=empty) == full + assert "m:gmv" in full + + +def test_unresolved_references(catalog): + doc = compose_doc("销售额 [[m:gmvx]],缺数 [[m:gone]],单元格 [[v:Q4.r0.amount]]," + "输入 [[i:nope]],换算 [[m:wow|pct]],[[metric:gmv]]。", catalog) + assert doc["markdown"] == ("销售额 ⟦?m:gmvx⟧,缺数 —,单元格 ⟦?v:Q4.r0.amount⟧," + "输入 ⟦?i:nope⟧,换算 ⟦?m:wow|pct⟧,⟦?metric:gmv⟧。") + bad = [v for v in doc["violations"] if v["code"] == "unresolved_ref"] + assert [v["ref"] for v in bad] == ["m:gmvx", "m:gone", "v:Q4.r0.amount", "i:nope", "m:wow|pct", + "metric:gmv"] + reasons = {v["ref"]: v["message"] for v in bad} + assert "gmvx" in reasons["m:gmvx"] + assert "没有值" in reasons["m:gone"] + assert LATER_REASON in reasons["v:Q4.r0.amount"] + assert "百分数" in reasons["m:wow|pct"] + for s in iter_segments(doc): + if s.get("ref"): + assert s["state"] == "none" and s["cite"]["status"] == "unresolved" and s["issue"] == "unresolved_ref" + assert doc["stats"]["unresolved"] == 6 and doc["stats"]["numbers_cited"] == 0 + + +def test_see_refs_are_checked_but_not_rendered(catalog): + doc = compose_doc("销售额见口径卡。[[see:m:gmv,i:week,gmv,m:nope,t:refunds]]下面看退款。", catalog) + assert doc["markdown"] == "销售额见口径卡。下面看退款。" + first = doc["blocks"][0]["units"][0] + assert [c["alias"] for c in first["see"] if c["status"] == "resolved"] == ["m:gmv", "i:week", "m:gmv"] + assert first["cites"] == ["m:gmv", "i:week"] + bad = {v["ref"]: v["message"] for v in doc["violations"]} + assert set(bad) == {"m:nope", "t:refunds"} and LATER_REASON in bad["t:refunds"] + assert doc["blocks"][0]["units"][1]["cites"] == [] + + +# -------------------------------------------------------------------------- +# 裸数字 +# -------------------------------------------------------------------------- + + +def test_bare_numbers_are_violations_with_offsets(catalog): + doc = compose_doc("销售额 [[m:gmv]],增长 12%,客户两千五百家。", catalog) + bare = [v for v in doc["violations"] if v["code"] == "uncited_number"] + assert [v["text"] for v in bare] == ["12%", "两千五百"] + for v in bare: + assert doc["markdown"][v["span"][0]:v["span"][1]] == v["text"] + assert v["unit"] == "u0" and v["segment"] + seg = next(s for s in iter_segments(doc) if s["text"] == "12%") + assert seg["kind"] == "number" and seg["state"] == "none" and seg["issue"] == "uncited_number" + assert "ref" not in seg + assert doc["stats"]["uncited_numbers"] == 2 and doc["stats"]["numbers"] == 3 + + +def test_dates_weeks_structural_and_allowed_numbers_pass(catalog): + doc = compose_doc("2026-09-27 发布,覆盖 2026-W37,2026年9月的数据。前 3 名、第 2 季度、Top 5、" + "从 3 个方面看,时间 10:30。客单价 37 元,编号 120。原因有两点:1、价格(2)渠道。", + catalog, allow_numbers=["37", "120"]) + assert codes(doc) == [], doc["violations"] + # 结构性数字限 20 以内:前 30 名就得有出处 + doc = compose_doc("前 30 名。", catalog) + assert [v["text"] for v in doc["violations"]] == ["30"] + + +def test_numbers_in_structure_do_not_count_and_cannot_hide(catalog): + """行首序号按出具校验的规则放过(1–2 位);再大的序号前端照样显示成
        , + 不能借「这是列表语法」躲过裸数字检查。""" + raw = ("1. 第一项 [[m:orders]]\n2. 第二项 7 单\n\n| 区域 | 金额 |\n| --- | --- |\n| 甲 | 8 |\n\n" + "100. 编号从这里开始的列表\n101. 下一项") + doc = compose_doc(raw, catalog) + assert [v["text"] for v in doc["violations"]] == ["7", "8", "100", "101"] + assert doc["blocks"][-1]["start"] == 100 + assert not INTEGRITY & set(codes(doc)) + # 违规指向的是那个序号所在的结构片段,偏移切得回原字 + for v in doc["violations"][2:]: + assert doc["markdown"][v["span"][0]:v["span"][1]] == v["text"] + seg = find_segment(doc, v["segment"])[2] + assert seg["kind"] == "structural" and v["text"] in seg["text"] + + +@pytest.mark.parametrize("raw, bare", [ + ("45678. 本周销售额创新高", ["45678"]), + ("段落\n\n98765) 订单", ["98765"]), + ("12. 序号照旧放过\n13. 下一项", []), + (" 3) 缩进的序号", []), + ("```45678\nx\n```", ["45678"]), # 代码块的语言标签显示在块头上 + ("```py3\nx\n```", []), # 和正文同一条规则:v2 / py3 这类标识符不算数字 + ("```\nx\n```", []), + ("1234567890. 很长的序号", ["1234567890"]), # 切块认它是列表,复核也得认它是语法 + # 表格第一格不在行首:竖线涂白后不能让「12.」沾行首序号免检的光 + ("| 序号 | 区域 |\n| --- | --- |\n| 12. | 华东 |", ["12"]), + ("> 3. 引用里的序号", []), +]) +def test_ordinals_and_fence_labels_follow_the_prose_rules(catalog, raw, bare): + doc = compose_doc(raw, catalog) + assert [v["text"] for v in doc["violations"] if v["code"] == "uncited_number"] == bare + assert not INTEGRITY & set(codes(doc)), doc["violations"] + again = verify_doc(copy.deepcopy(doc), catalog) + assert again["violations"] == doc["violations"] + + +# -------------------------------------------------------------------------- +# 切块、切句、切片段 +# -------------------------------------------------------------------------- + +REPORT = """## 本周概览 +本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]]。[[see:m:gmv,m:wow]]增长主要来自新客首单。下面看退款情况。 + +- 订单 [[m:orders]] + (含预售) +- 客单价 **[[m:aov]]** + +| 指标 | 值 | +| --- | :-: | +| 退款率 | [[m:refund_rate]] | +| 大数 | [[m:big|万]] | + +> 口径说明:[[i:region]]。 +> 第二行。 + +``` +code 块 +``` + +---""" + + +def test_segments_tile_the_markdown(catalog): + doc = compose_doc(REPORT, catalog) + joined = "".join(texts(doc)) + assert joined == doc["markdown"] + pos = 0 + for seg in iter_segments(doc): + assert seg["span"][0] == pos and doc["markdown"][seg["span"][0]:seg["span"][1]] == seg["text"] + pos = seg["span"][1] + assert pos == len(doc["markdown"]) + assert doc["violations"] == [], doc["violations"] + + +def test_doc_is_json_and_content_addressable(catalog): + """报告节点要把文档 put_json 成 report_doc 工件:必须能序列化,同样的原文得到同样的哈希。""" + from app.core.artifact_store import canonical_json, content_hash + + first = content_hash(canonical_json(compose_doc(REPORT, catalog, node_id="write", run_id="r1"))) + fresh = build_catalog(nodes=copy.deepcopy(NODES), ledger=copy.deepcopy(LEDGER), inputs=dict(INPUTS)) + assert content_hash(canonical_json(compose_doc(REPORT, fresh, node_id="write", run_id="r1"))) == first + + +def test_blocks_units_and_kinds(catalog): + doc = compose_doc(REPORT, catalog) + blocks = doc["blocks"] + assert [b["type"] for b in blocks] == ["heading", "paragraph", "list", "table", "quote", "code", "hr"] + assert [b["id"] for b in blocks] == [f"b{i}" for i in range(7)] + heading, para, lst, table, quote, code, hr = blocks + assert heading["level"] == 2 and heading["units"][0]["kind"] == "heading" + h_unit = heading["units"][0] + assert doc["markdown"][h_unit["span"][0]:h_unit["span"][1]] == "本周概览" + # 句子按句号切;[[see:]] 挂在它前面那一句;连接性的话单独成一句 + sentences = [doc["markdown"][u["span"][0]:u["span"][1]] for u in para["units"]] + assert sentences == ["本周(2026-W37)销售额 45,678.5元,环比 8.7%。", "增长主要来自新客首单。", + "下面看退款情况。"] + assert [u["kind"] for u in para["units"]] == ["claim", "claim", "connective"] + assert para["units"][0]["cites"] == ["i:week", "m:gmv", "m:wow"] + # 列表每项一个 unit,续行接在同一项里 + assert lst["ordered"] is False and len(lst["units"]) == 2 + first_item = "".join(s["text"] for s in lst["units"][0]["segments"] if s["kind"] != "structural") + assert first_item == "订单 1,234单\n(含预售)" + # **[[m:aov]]** 归一成 strong,正文里不留星号 + aov = next(s for s in iter_segments(doc) if s.get("ref") == "m:aov") + assert aov["strong"] is True and "**" not in doc["markdown"] + # 表格每格一个 unit,带行列;表头 row = -1 + locs = [(u["loc"]["row"], u["loc"]["col"]) for u in table["units"]] + assert locs == [(-1, 0), (-1, 1), (0, 0), (0, 1), (1, 0), (1, 1)] + assert any(s.get("ref") == "m:big|万" for s in table["units"][5]["segments"]) + assert [u["kind"] for u in quote["units"]] == ["claim", "connective"] + assert code["units"][0]["kind"] == "code" and hr["units"][0]["kind"] == "connective" + # 编号全局连续 + unit_ids = [u["id"] for b in blocks for u in b["units"]] + assert unit_ids == [f"u{i}" for i in range(len(unit_ids))] + seg_ids = [s["id"] for s in iter_segments(doc)] + assert seg_ids == [f"s{i}" for i in range(len(seg_ids))] + block, unit, seg = find_segment(doc, aov["id"]) + assert block["id"] == "b2" and unit["id"] == lst["units"][1]["id"] and seg is aov + + +def test_bold_around_markers_becomes_strong_segments(catalog): + """**销售额 [[m:gmv]]**:数字切成单独的片段后,两边各剩半对星号,前端只能原样显示。 + 星号去掉、里面的每一段都标 strong;不含标记的粗体原样留在文字里。""" + doc = compose_doc("前面 **销售额 [[m:gmv]]** 后面,__[[m:refund_rate]]__,**重点** 不动。", catalog) + assert doc["markdown"] == "前面 销售额 45,678.5元 后面,2.35%,**重点** 不动。" + flags = [(s["text"], bool(s.get("strong"))) for s in iter_segments(doc)] + assert flags == [("前面 ", False), ("销售额 ", True), ("45,678.5元", True), (" 后面,", False), + ("2.35%", True), (",**重点** 不动。", False)] + assert doc["violations"] == [] + + +def test_sentence_ends_inside_code_links_bold_and_markers_do_not_split(catalog): + doc = compose_doc("看 `x. y` 和 [链接](http://a.b/?q=1) 还有 **重点。强调** 以及 [[q:K2|原话。]] 结束。第二句。", + catalog) + sentences = [doc["markdown"][u["span"][0]:u["span"][1]] for u in doc["blocks"][0]["units"]] + assert sentences == ["看 `x. y` 和 [链接](http://a.b/?q=1) 还有 **重点。强调** 以及 ⟦?q:K2|原话。⟧ 结束。", + "第二句。"] + # 链接地址以问号结尾:问号在链接里,不能因为它后面紧跟链接的右括号就在链接后面断句 + doc = compose_doc("见[文档](http://a.b/?) 然后继续。", catalog) + assert len(doc["blocks"][0]["units"]) == 1 + + +def test_offsets_are_code_points(catalog): + doc = compose_doc("😀🎉 销售额 [[m:gmv]],另有 7 单。", catalog) + seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:gmv") + assert seg["span"] == [7, 16] and doc["markdown"][7:16] == "45,678.5元" + assert doc["violations"][0]["span"] == [20, 21] and doc["markdown"][20] == "7" + + +def test_pipes_inside_markers_do_not_split_table_cells(catalog): + doc = compose_doc("| a | b |\n| - | - |\n| [[m:gmv|万]] | x |", catalog) + cells = doc["blocks"][0]["units"] + assert [u["loc"] for u in cells][2:] == [{"row": 0, "col": 0}, {"row": 0, "col": 1}] + assert doc["markdown"] == "| a | b |\n| - | - |\n| 4.57万元 | x |" + + +# -------------------------------------------------------------------------- +# verify_doc:独立复核,不信任文档自己的说法 +# -------------------------------------------------------------------------- + + +def test_verify_doc_agrees_with_compose(catalog): + doc = compose_doc(REPORT + "\n\n另有 7 单。", catalog) + again = verify_doc(copy.deepcopy(doc), catalog) + assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"] + assert again["ok"] is False and [v["code"] for v in again["violations"]] == ["uncited_number"] + + +def test_verify_doc_catches_tampering(catalog): + doc = compose_doc("销售额 [[m:gmv]],订单 [[m:orders]]。", catalog) + assert verify_doc(doc, catalog)["ok"] is True + + # 1) 数字片段的字被改了:重渲染对不上 + forged = copy.deepcopy(doc) + seg = next(s for s in iter_segments(forged) if s.get("ref") == "m:gmv") + shift = len("99,999.9元") - len(seg["text"]) + old_end = seg["span"][1] + forged["markdown"] = forged["markdown"].replace("45,678.5元", "99,999.9元") + seg["text"], seg["span"][1] = "99,999.9元", seg["span"][1] + shift + for s in iter_segments(forged): + if s["span"][0] >= old_end: + s["span"] = [s["span"][0] + shift, s["span"][1] + shift] + assert "render_mismatch" in [v["code"] for v in verify_doc(forged, catalog)["violations"]] + + # 2) 把裸数字塞进文字片段、又不标出来:复核自己去正文里找 + hidden = copy.deepcopy(doc) + last = list(iter_segments(hidden))[-1] + last["text"] += "另有 7 单" + hidden["markdown"] += "另有 7 单" + last["span"][1] = len(hidden["markdown"]) + assert [v["code"] for v in verify_doc(hidden, catalog)["violations"]] == ["uncited_number"] + + # 3) 片段接不上正文 + broken = copy.deepcopy(doc) + broken["markdown"] = broken["markdown"] + "尾巴" + assert "segment_mismatch" in [v["code"] for v in verify_doc(broken, catalog)["violations"]] + + # 3b) 中间一段的字和它记的位置对不上 / 少了一段 + swapped = copy.deepcopy(doc) + next(s for s in iter_segments(swapped) if s["kind"] == "text")["text"] = "换掉的" + assert "segment_mismatch" in [v["code"] for v in verify_doc(swapped, catalog)["violations"]] + dropped = copy.deepcopy(doc) + dropped["blocks"][0]["units"][0]["segments"].pop(1) + assert "segment_mismatch" in [v["code"] for v in verify_doc(dropped, catalog)["violations"]] + + # 4) 结构片段里夹带数字 + smuggled = copy.deepcopy(doc) + first = next(iter_segments(smuggled)) + smuggled["markdown"] = "12345" + smuggled["markdown"] + for s in iter_segments(smuggled): + s["span"] = [s["span"][0] + 5 if s is not first else 0, s["span"][1] + 5] + first["text"] = "12345" + first["text"] + first["kind"] = "structural" + assert "structural_text" in [v["code"] for v in verify_doc(smuggled, catalog)["violations"]] + + # 5) 目录里的 eid 被换了 + fake = copy.deepcopy(doc) + next(s for s in iter_segments(fake) if s.get("ref") == "m:gmv")["cite"]["eid"] = "ev:metric:0000000000000000" + assert "eid_mismatch" in [v["code"] for v in verify_doc(fake, catalog)["violations"]] + + # 6) 状态标错:没有出处的地方标成「有出处」,界面就会画成确定的实线 + lying = copy.deepcopy(compose_doc("销售额 [[m:gmv]],另有 7 单。", catalog)) + bare = next(s for s in iter_segments(lying) if s.get("issue") == "uncited_number") + bare["state"] = "deterministic" + next(s for s in iter_segments(lying) if s["kind"] == "text")["state"] = "deterministic" + assert sorted(v["code"] for v in verify_doc(lying, catalog)["violations"]) \ + == ["state_mismatch", "state_mismatch", "uncited_number"] + + # 7) 不认识的文档 + assert [v["code"] for v in verify_doc({**doc, "schema": "x"}, catalog)["violations"]][:1] == ["bad_schema"] + + +def test_verify_doc_uses_the_catalog_it_is_given(catalog): + """出口复核拿自己重建的目录:口径卡的值变了,文档里的旧数字就对不上。""" + doc = compose_doc("销售额 [[m:gmv]]。", catalog) + changed = build_catalog( + nodes={"caliber": {**CARD, "metrics": [metric("gmv", "销售额", 1.5, "元", 1)]}}, + ledger=LEDGER, inputs=INPUTS) + assert [v["code"] for v in verify_doc(doc, changed)["violations"]] == ["render_mismatch"] + assert [v["code"] for v in verify_doc(doc, {})["violations"]] == ["unresolved_ref"] + + +# -------------------------------------------------------------------------- +# 流式渲染 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cut", range(1, 12)) +def test_stream_renderer_never_leaks_raw_markers(catalog, cut): + raw = "销售额 [[m:gmv]],折合 [[m:gmv|万]]。[[see:m:gmv]] 未知 [[m:x]] 字面 [[ 和 [ 结束" + chunks = [raw[i:i + cut] for i in range(0, len(raw), cut)] + renderer = StreamRenderer(catalog) + out = [renderer.feed(c) for c in chunks] + [renderer.flush()] + for piece in out: + assert "[[m:" not in piece and "[[see:" not in piece, out + assert "".join(out) == render_markers(raw, catalog) + assert "".join(out) == "销售额 45,678.5元,折合 4.57万元。 未知 ⟦?m:x⟧ 字面 [[ 和 [ 结束" + + +@pytest.mark.parametrize("body", [ + "x" * 170, # 比能缓冲的还长:两边都当普通文字 + "x" * (MARKER_MAX - len("[[m:]]")), # 恰好到上限:两边都当标记 + "x" * (MARKER_MAX - len("[[m:]]") + 1), # 超出一个字 + "gmv|万" + " " * (MARKER_MAX - len("[[m:gmv|万]]")), +]) +def test_stream_output_equals_the_full_render_for_markers_of_any_length(catalog, body): + raw = f"a [[m:{body}]] b [[see:{body}]] c" + full = render_markers(raw, catalog) + for size in (1, 2, 7, 50, len(raw)): + renderer = StreamRenderer(catalog) + out = [renderer.feed(raw[i:i + size]) for i in range(0, len(raw), size)] + [renderer.flush()] + assert "".join(out) == full, (size, out) + over = len(f"[[m:{body}]]") > MARKER_MAX + assert (f"[[m:{body}]]" in full) is over # 超长的不是标记,原样留着 + assert all(len(m["raw"]) <= MARKER_MAX for m in parse_markers(raw)) + + +def _stream_corpus(n: int) -> list[str]: + import random + + rng = random.Random(20260929) + pieces = ["[[m:gmv]]", "[[m:gmv|万]]", "[[see:m:wow]]", "[[", "]]", "[", "]", "\n", "文字", " ", + "[[m:" + "y" * 150, "[[see:" + "z" * 160 + "]]", "[[i:week]]", "[[m:x" + "q" * 155 + "]]"] + return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 12))) for _ in range(n)] + + +@pytest.mark.parametrize("raw", _stream_corpus(120)) +def test_stream_renderer_matches_render_markers_on_random_input(catalog, raw): + import random + + rng = random.Random(raw) + for _ in range(3): + renderer, out, i = StreamRenderer(catalog), [], 0 + while i < len(raw): + step = rng.randint(1, 40) + out.append(renderer.feed(raw[i:i + step])) + i += step + out.append(renderer.flush()) + assert "".join(out) == render_markers(raw, catalog) + + +def test_stream_renderer_releases_a_long_unclosed_bracket(catalog): + renderer = StreamRenderer(catalog) + first = renderer.feed("开头 [[" + "很长的正文" * 40) + assert first.startswith("开头 [[") # 不会一直憋着不发 + assert renderer.feed("\n下一行") and renderer.flush() == "" + + +# -------------------------------------------------------------------------- +# 给报告节点用的辅助 +# -------------------------------------------------------------------------- + + +def test_catalog_prompt_and_violation_text(catalog): + prompt = catalog_prompt(catalog) + assert "[[m:gmv]]" in prompt and "45,678.5元" in prompt and "周报口径 @ v2" in prompt + assert "[[i:week]]" in prompt and "2026-W37" in prompt + assert "[[m:gone]]" in prompt and "没有值" in prompt + doc = compose_doc("增长 12%,[[m:gmvx]]。", catalog) + text = describe_violations(doc["violations"]) + assert "12%" in text and "gmvx" in text + + +# -------------------------------------------------------------------------- +# 性质:什么样的原文进来,片段都铺满正文,组装和复核的结论一致,自己不制造完整性问题 +# -------------------------------------------------------------------------- + +AWKWARD = [ + "", " \n\n ", "[[see:m:gmv]]", "---", "```py\nx = 1", "```\n```", "| a | |\n|---|---|\n| | [[m:gmv]] |", + "- a\n - b [[m:orders]]\n 续行\n- c", "3. 三\n4. 四", "# 标题 [[m:gmv]] ", "## ", "[[m:orders]]. 第一句。", + "a\r\nb\r\n", "开头 [[ 没闭合", "**加粗 [[m:gmv]] 里面**。", "> a\n>\n> b [[m:gmv]]", "[[see:m:gmv]] 开头。", + "e.g. 3.5 和 v2. 结束", "表格 | 不是 | 表格", "| x |\n| y |", "__[[m:gmv]]__ 与 ** [[m:wow]] **", + "`code 12` 和 [link](http://x.y/12)", "多个\n\n\n\n段落", "[[m:gmv|万]]|[[m:gmv]]", "1) 一\n2) 二", +] + + +def _random_corpus(n: int) -> list[str]: + import random + + rng = random.Random(20260928) + alphabet = [*"我们。!?\n- *#>|`[]:0123456789 .,,、()_", "[[m:gmv]]", "[[see:m:wow]]", "[[i:week]]", + "[[m:refund_rate]]", "**", "__", "```", "---", "| a |", "\n\n", "[[v:Q1.r0.x]]"] + return ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 40))) for _ in range(n)] + + +INTEGRITY = {"segment_mismatch", "structural_text", "state_mismatch", "render_mismatch", "eid_mismatch", "bad_schema"} + + +@pytest.mark.parametrize("raw", AWKWARD + _random_corpus(400)) +def test_composition_invariants(catalog, raw): + doc = compose_doc(raw, catalog) + assert "".join(texts(doc)) == doc["markdown"] + again = verify_doc(copy.deepcopy(doc), catalog) + assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"] + assert not INTEGRITY & set(codes(doc)), doc["violations"] diff --git a/backend/tests/test_issuance_matched.py b/backend/tests/test_issuance_matched.py index 1a4d17fc..51a638b5 100644 --- a/backend/tests/test_issuance_matched.py +++ b/backend/tests/test_issuance_matched.py @@ -52,8 +52,9 @@ async def test_each_traced_number_names_its_metric_and_caliber(): break assert row.status == "succeeded", row.error - expected = [{"token": "120", "metric": "orders", "caliber": "销售口径 @ v2"}, - {"token": "35.5", "metric": "aov", "caliber": "销售口径 @ v2"}] + # start / end 是 token 在叙述里的位置:同一个数出现两次时,界面按位置标,不再按字符串全局匹配 + expected = [{"token": "120", "metric": "orders", "caliber": "销售口径 @ v2", "start": 5, "end": 8}, + {"token": "35.5", "metric": "aov", "caliber": "销售口径 @ v2", "start": 15, "end": 19}] assert row.output["_issuance"]["matched"] == expected assert row.output["_issuance"]["matched_numbers"] == 2 async with SessionLocal() as session: diff --git a/backend/tests/test_issuance_positions.py b/backend/tests/test_issuance_positions.py new file mode 100644 index 00000000..ef568d4c --- /dev/null +++ b/backend/tests/test_issuance_positions.py @@ -0,0 +1,87 @@ +"""旧契约(按数值回指)的止血:每个数字记下位置,同值的多个指标不再默认算给第一个。 + +真实反例:code 节点解析失败后 13 个数值全部兜底成 0,叙述里 56 个「命中」全部算给了 +同一个指标——`next(...)` 取第一个命中的。界面上那 56 个数都标着「来自指标 A」, +其实哪个都说不清。位置也没记:同一个 token 出现多次共用一个标记,只能按字符串全局 +匹配,标错地方是必然的。 +""" +from __future__ import annotations + +import pytest + +from app.engine.issuance import decide_tier, trace_numbers + +ZEROS = [{"id": f"k{i}", "value": 0.0} for i in range(5)] + + +def test_same_value_metrics_are_ambiguous_not_first_wins(): + narrative = "订单 0.0 单,退款 0.0 单,投诉 0.0 起,复购 0.0 次,流失 0.0 户" + report = trace_numbers(narrative, ZEROS) + assert len(report.matched) == 5 and not report.unmatched + for hit in report.matched: + assert hit["metric"] is None, hit + assert hit["ambiguous"] is True + assert hit["candidates"] == ["k0", "k1", "k2", "k3", "k4"] + + +def test_every_number_carries_its_offsets(): + narrative = "本周订单 1,204 单,客单价 35.5 元,另有 7 单;复购 1,204 单" + report = trace_numbers(narrative, [{"id": "orders", "value": 1204}, {"id": "aov", "value": 35.5}]) + assert [h["token"] for h in report.matched] == ["1,204", "35.5", "1,204"] + for hit in [*report.matched, *report.unmatched]: + assert narrative[hit["start"]:hit["end"]] == hit["token"], hit + # 同一个 token 出现两次,位置各是各的 + assert report.matched[0]["start"] != report.matched[2]["start"] + assert report.unmatched == [{"token": "7", "context": report.unmatched[0]["context"], + "start": narrative.index("7 单"), "end": narrative.index("7 单") + 1}] + + +def test_offsets_for_chinese_numerals_and_suffixes(): + narrative = "客户两千五百家,营收 3.5万,增长三成" + report = trace_numbers(narrative, [{"id": "c", "value": 2500}, {"id": "r", "value": 35000}, + {"id": "g", "value": 0.3}]) + assert [narrative[h["start"]:h["end"]] for h in report.matched] == ["两千五百", "3.5万", "三成"] + + +def test_unique_hit_keeps_the_old_shape(): + """只有一个指标对得上时,形状和以前一样(多了位置),老前端照常能读。""" + report = trace_numbers("订单 120 单", [{"id": "orders", "value": 120}, {"id": "aov", "value": 35.5}]) + assert report.matched == [{"token": "120", "metric": "orders", "start": 3, "end": 6}] + + +def test_same_metric_id_from_two_calibers_is_not_ambiguous(): + """两张口径卡都定义了 orders:出处的指标是同一个,不算出处不唯一。""" + report = trace_numbers("订单 120 单", [{"id": "orders", "value": 120}, {"id": "orders", "value": 120}]) + assert report.matched == [{"token": "120", "metric": "orders", "start": 3, "end": 6}] + + +# -------------------------------------------------------------------------- +# decide_tier 的新参数:默认都是空,旧调用一个字不改、结果不变 +# -------------------------------------------------------------------------- + +BASE = {"missing_required": [], "missing_expected": [], "unmatched": [], "strict": False} + + +def test_old_calls_are_unchanged(): + assert decide_tier(**BASE) == "formal" + assert decide_tier(**{**BASE, "gaps": ["x"]}) == "degraded" + assert decide_tier(**{**BASE, "unmatched": [{"token": "1"}], "strict": True}) == "withheld" + + +@pytest.mark.parametrize("strict, tier", [(False, "degraded"), (True, "withheld")]) +def test_unresolved_references_count_like_unmatched_numbers(strict, tier): + assert decide_tier(**{**BASE, "strict": strict}, unresolved=[{"ref": "m:gmvx"}]) == tier + + +def test_claims_are_ignored_without_a_policy(): + assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], uncited_claims=3) == "formal" + assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy="off") == "formal" + + +def test_claims_policy_degrades_or_withholds(): + assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy="judge") == "degraded" + assert decide_tier(**BASE, uncited_claims=2, claims_policy="require_citation") == "degraded" + policy = {"policy": "judge", "on_unsupported": "withhold", "on_uncited": "ignore"} + assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy=policy) == "withheld" + assert decide_tier(**BASE, uncited_claims=[{"unit": "u2"}], claims_policy=policy) == "formal" + assert decide_tier(**BASE, unsupported=[], uncited_claims=0, claims_policy="judge") == "formal" diff --git a/backend/tests/test_metric_lineage.py b/backend/tests/test_metric_lineage.py new file mode 100644 index 00000000..2ae24905 --- /dev/null +++ b/backend/tests/test_metric_lineage.py @@ -0,0 +1,407 @@ +"""口径卡记下每个数是怎么来的:输入取自哪个节点、代入式长什么样、复算对不对得上。 + +以前口径卡只产出 {id, name, unit, value, expression}:报告里一个「8.7%」点开,最多能看到 +一条原式,看不到当时代入的是哪两个数、它们来自哪一步。任何一个输入是 None 时, +TypeError 也没人接,整个节点带着一句「unsupported operand type(s)」死掉。 +""" +from __future__ import annotations + +import ast +import asyncio +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.core import artifact_store +from app.db.base import SessionLocal +from app.db.models import Artifact, Run, RunEvent +from app.engine.expressions import eval_expression, leaf_refs, parse_expression, substitute +from app.engine.runner import run_manager + +# -------------------------------------------------------------------------- +# leaf_refs / substitute:纯函数 +# -------------------------------------------------------------------------- + + +def tree(expr: str) -> ast.Expression: + return parse_expression(expr)[0] + + +@pytest.mark.parametrize("expr, refs", [ + ("round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)", + ["vars.kpi.gmv", "vars.kpi.gmv_prev"]), + ("nodes.fetch.data['amount'] + input.extra", ["nodes.fetch.data['amount']", "input.extra"]), + # 下标里的引用也是输入:它决定取哪一格 + ("vars.rows[vars.i]", ["vars.rows[vars.i]", "vars.i"]), + ("len(vars.items) + 1", ["vars.items"]), + ("str(1) + 'x'", []), + ("gate == 'ok'", []), # 不在根名单里的名字不是引用 + ("{{ vars.x }} * 2", ["vars.x"]), # 模板写法按 vars.x 理解 + ("last_message", ["last_message"]), +]) +def test_leaf_refs(expr, refs): + assert leaf_refs(tree(expr)) == refs + + +def test_substitute_inlines_scalars_and_recomputes(): + expr = "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)" + values = {"vars.kpi.gmv": 45678.5, "vars.kpi.gmv_prev": 42010.0} + text = substitute(tree(expr), values) + assert text == "round((45678.5 - 42010.0) / 42010.0 * 100, 1)" + ctx = {"vars": {"kpi": {"gmv": 45678.5, "gmv_prev": 42010.0}}} + assert eval_expression(text, {}) == eval_expression(expr, ctx) == 8.7 + + +def test_substitute_leaves_containers_as_paths(): + """整张表代进去既看不懂、也可能超过表达式的长度上限:容器留原路径,复算时照样取得到。""" + ctx = {"vars": {"items": [1, 2, 3], "n": -3}} + text = substitute(tree("len(vars.items) + vars.n ** 2"), + {"vars.items": [1, 2, 3], "vars.n": -3}) + assert text == "len(vars.items) + (-3) ** 2" + assert eval_expression(text, ctx) == eval_expression("len(vars.items) + vars.n ** 2", ctx) == 12 + + +@pytest.mark.parametrize("value", ["a{{b}}c", "x}}", "{{", "长" * 81]) +def test_substitute_keeps_paths_for_strings_it_cannot_inline(value): + """含 {{ }} 的字符串代进去,parse_expression 会拒绝整条代入式,复算就成了假的「对不上」; + 上游模型写的长文本整段抄进代入式和工件也没人看得懂。这两种都留原路径。""" + ctx = {"vars": {"s": value}} + text = substitute(tree("len(vars.s) + 1"), {"vars.s": value}) + assert text == "len(vars.s) + 1" + assert eval_expression(text, ctx) == len(value) + 1 + + +def test_substitute_still_inlines_short_plain_strings(): + text = substitute(tree("len(vars.s) + 1"), {"vars.s": "abc{x}"}) + assert text == "len('abc{x}') + 1" and eval_expression(text, {}) == 7 + assert substitute(tree("len(vars.s)"), {"vars.s": "长" * 80}) == f"len('{'长' * 80}')" + + +def test_substitute_does_not_touch_the_original_tree(): + t = tree("vars.a + 1") + substitute(t, {"vars.a": 5}) + assert ast.unparse(t) == "vars.a + 1" + + +# -------------------------------------------------------------------------- +# 口径卡节点:真跑一遍 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +KPI = "{'gmv': 45678.5, 'gmv_prev': 42010.0, 'orders': 1234, 'refunds': 29, 'name': 'abc'}" +WOW = "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)" + + +def kpi_graph(metrics, **card): + return { + "nodes": [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("card", "metrics", caliber="周报口径", caliber_version="v2", assign_to="m", + metrics=metrics, **card), + node("out", "output", fields=[{"name": "清单", "value": "{{ nodes.card.text }}"}]), + ], + "edges": [{"source": "start", "target": "fetch"}, {"source": "fetch", "target": "card"}, + {"source": "card", "target": "out"}], + } + + +async def _finish(run_id: str, statuses=("succeeded", "failed")) -> Run: + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in statuses: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def _events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def _card_of(run_id: str) -> dict: + finished = await _events(run_id, "node.finished", "card") + assert len(finished) == 1 + artifact = finished[0].data["artifact"] + return artifact_store.load(artifact) + + +STANDARD = [ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}, + {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", "expression": WOW}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"}, + {"id": "refund_rate", "name": "退款率", "decimals": 4, "format": "percent_of_ratio", + "expression": "vars.kpi.refunds / vars.kpi.orders"}, +] + + +async def test_each_metric_records_its_inputs_substitution_and_rendering(engine_up): + run = await run_manager.start(graph=kpi_graph(STANDARD), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + card = await _card_of(run.id) + by_id = {m["id"]: m for m in card["metrics"]} + + wow = by_id["wow"] + assert wow["value"] == 8.7 and wow["status"] == "ok" + assert wow["substituted"] == "round((45678.5 - 42010.0) / 42010.0 * 100, 1)" + assert wow["recompute_ok"] is True + assert wow["inputs"] == [ + {"path": "vars.kpi.gmv", "value": 45678.5, "node_id": "fetch", "via": "transform", + "field": "gmv", "status": "ok"}, + {"path": "vars.kpi.gmv_prev", "value": 42010.0, "node_id": "fetch", "via": "transform", + "field": "gmv_prev", "status": "ok"}, + ] + assert (wow["decimals"], wow["format"], wow["rendered"]) == (1, "plain", "8.7%") + assert by_id["gmv"]["rendered"] == "45,678.5元" + # 没写 format 就是千分位;没写 decimals 就按值本来的样子 + assert (by_id["orders"]["format"], by_id["orders"]["decimals"], by_id["orders"]["rendered"]) \ + == ("thousands", None, "1,234单") + assert by_id["refund_rate"]["value"] == 0.0235 and by_id["refund_rate"]["rendered"] == "2.35%" + # 旧字段一个不少,text 照旧——模板 ⑧ 的叙述 prompt 靠它 + assert {"id", "name", "unit", "value", "expression"} <= set(wow) + output = row.output["清单"] + assert "- wow(环比增幅)= 8.7%" in output and "- orders(订单数)= 1234单" in output + + +async def test_metric_set_artifact_is_in_the_ledger_and_node_finished(engine_up): + run = await run_manager.start(graph=kpi_graph(STANDARD), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + finished = (await _events(run.id, "node.finished", "card"))[0].data + ledger = finished["evidence"] + assert len(ledger) == 1 + entry = ledger[0] + assert entry["kind"] == "metric_set" and entry["node_id"] == "card" and entry["exec"] == 1 + assert entry["caliber"] == "周报口径" and entry["version"] == "v2" + assert entry["metrics"] == ["gmv", "wow", "orders", "refund_rate"] + + # 工件取回时复验哈希;内容就是整张卡,id 也写进了节点产出 + card = artifact_store.load(entry["artifact"]) + assert card["kind"] == "metric_set" and card["caliber_version"] == "v2" + assert [m["id"] for m in card["metrics"]] == entry["metrics"] + node_output = artifact_store.load(finished["artifact"]) + assert node_output["artifact"] == entry["artifact"] + async with SessionLocal() as session: + stored = await session.get(Artifact, (entry["artifact"], run.id)) + assert stored is not None and stored.kind == "metric_set" and stored.node_id == "card" + + # 不产出证据的节点,node.finished 和以前一模一样:不多一个空的 evidence 键 + for other in await _events(run.id, "node.finished"): + if other.node_id != "card": + assert "evidence" not in other.data, other.data + + +MISSING = [ + {"id": "gmv", "expression": "vars.kpi.gmv"}, + {"id": "margin", "name": "毛利率", "expression": "vars.kpi.cost / vars.kpi.gmv * 100"}, +] + + +async def test_missing_input_fails_by_default_with_a_readable_reason(engine_up): + """on_missing 默认 fail:和以前一样整个节点失败,只是报错要说人话。""" + run = await run_manager.start(graph=kpi_graph(MISSING), input_payload={}) + row = await _finish(run.id) + assert row.status == "failed" + assert "margin" in row.error and "vars.kpi.cost" in row.error and "没有值" in row.error, row.error + for raw in ("TypeError", "NoneType", "unsupported operand"): + assert raw not in row.error, row.error + + +async def test_missing_input_becomes_null_when_asked(engine_up): + run = await run_manager.start(graph=kpi_graph(MISSING, on_missing="null"), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + by_id = {m["id"]: m for m in (await _card_of(run.id))["metrics"]} + margin = by_id["margin"] + assert margin["value"] is None and margin["status"] == "missing_input" + assert margin["rendered"] == "—" and margin["recompute_ok"] is None + assert [i["path"] for i in margin["inputs"] if i["status"] == "missing"] == ["vars.kpi.cost"] + assert by_id["gmv"]["status"] == "ok" and by_id["gmv"]["value"] == 45678.5 + + +async def test_type_errors_are_explained_not_crashed_on(engine_up): + run = await run_manager.start( + graph=kpi_graph([{"id": "x", "expression": "vars.kpi.name * 1.5"}], on_missing="null"), + input_payload={}) + row = await _finish(run.id) + # 类型错误不是缺输入:null 模式也不替它兜成空值 + assert row.status == "failed" + assert "x" in row.error and "vars.kpi.name" in row.error and "类型" in row.error, row.error + for raw in ("TypeError", "can't multiply", "执行出错"): + assert raw not in row.error, row.error + + +@pytest.mark.parametrize("bad, words", [ + ({"format": "percent"}, ["format", "plain", "thousands", "percent_of_ratio"]), + ({"decimals": "两位"}, ["decimals", "整数"]), +]) +async def test_bad_display_config_is_reported(engine_up, bad, words): + run = await run_manager.start( + graph=kpi_graph([{"id": "gmv", "expression": "vars.kpi.gmv", **bad}]), input_payload={}) + row = await _finish(run.id) + assert row.status == "failed" + assert all(w in row.error for w in words), row.error + + +async def test_brace_strings_do_not_fake_a_recompute_mismatch(engine_up): + run = await run_manager.start(graph=kpi_graph([{"id": "n", "expression": "len(input.memo) + 1"}]), + input_payload={"memo": "a{{b}}c"}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + n = (await _card_of(run.id))["metrics"][0] + assert n["value"] == 8 and n["recompute_ok"] is True, n + assert n["substituted"] == "len(input.memo) + 1" + + +# -------------------------------------------------------------------------- +# 旧工作流:以前能跑的口径卡,现在照样能跑,值一个不差 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("definition, value, rendered", [ + # 负的小数位是 round 到十位、百位——以前 round(value, int(decimals)) 就支持 + ({"decimals": -2, "unit": "元"}, 45700.0, "45,700元"), + ({"decimals": "-2", "unit": "元"}, 45700.0, "45,700元"), + ({"decimals": 12}, 45678.5, "45,678.500000000000"), + ({"decimals": "2"}, 45678.5, "45,678.50"), +]) +async def test_legacy_decimals_keep_working(engine_up, definition, value, rendered): + run = await run_manager.start( + graph=kpi_graph([{"id": "gmv", "expression": "vars.kpi.gmv", **definition}]), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + gmv = (await _card_of(run.id))["metrics"][0] + assert gmv["value"] == value and gmv["rendered"] == rendered and gmv["status"] == "ok" + + +async def test_legacy_non_integer_decimals_on_an_integer_value_are_ignored_as_before(engine_up): + """以前只有值是小数时才用到 decimals,整数值上写错了也照样跑完。""" + run = await run_manager.start( + graph=kpi_graph([{"id": "orders", "unit": "单", "expression": "vars.kpi.orders", + "decimals": "两位"}]), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + orders = (await _card_of(run.id))["metrics"][0] + assert orders["value"] == 1234 and orders["decimals"] is None and orders["rendered"] == "1,234单" + + +@pytest.mark.parametrize("definition, words", [ + ({"format": "thousands", "decimals": 16}, ["decimals", "-15", "15"]), + ({"format": "plain", "decimals": 1.5}, ["decimals", "整数"]), + ({"format": "plain", "decimals": True}, ["decimals", "整数"]), +]) +async def test_new_style_decimals_are_checked_strictly(engine_up, definition, words): + run = await run_manager.start( + graph=kpi_graph([{"id": "gmv", "expression": "vars.kpi.gmv", **definition}]), input_payload={}) + row = await _finish(run.id) + assert row.status == "failed" + assert all(w in row.error for w in words), row.error + + +async def test_huge_values_do_not_crash_the_card(engine_up): + """以前能算出 4.6e54 的口径卡照样成功;渲染出来是完整的数字,不是崩在 InvalidOperation 上。 + (超过 64 位的整数进不了断点存档,这一点新旧代码一样,不在这里测。)""" + run = await run_manager.start( + graph=kpi_graph([{"id": "big", "expression": "vars.kpi.gmv * 1e50"}, + {"id": "tiny", "expression": "vars.kpi.gmv * 1e-20"}]), input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + by_id = {m["id"]: m for m in (await _card_of(run.id))["metrics"]} + big = by_id["big"] + assert big["rendered"] == f"{int(Decimal(repr(big['value']))):,}" and big["rendered"].startswith("4,567,85") + # 小到按默认 10 位小数显示成 0 的:不给假的 0,显示「—」,值照旧记着 + assert by_id["tiny"]["rendered"] == "—" and by_id["tiny"]["value"] == 45678.5 * 1e-20 + + +# -------------------------------------------------------------------------- +# 旧 checkpoint 里没有 evidence 通道:续跑时视为空 +# -------------------------------------------------------------------------- + + +async def test_resume_from_a_checkpoint_without_the_evidence_channel(engine_up): + graph = { + "nodes": [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("first", "metrics", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]), + node("gate", "human", mode="approve", title="确认"), + node("second", "metrics", metrics=[{"id": "orders", "expression": "vars.kpi.orders"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.second.text }}"}]), + ], + "edges": [{"source": a, "target": b} for a, b in [ + ("start", "fetch"), ("fetch", "first"), ("first", "gate"), ("gate", "second"), + ("second", "out")]], + } + run = await run_manager.start(graph=graph, input_payload={}) + await _finish(run.id, ("interrupted", "failed", "succeeded")) + + # 把断点改写成旧版引擎写出来的样子:通道表里没有 evidence + saver = run_manager.checkpointer + config = {"configurable": {"thread_id": run.id}} + tup = await saver.aget_tuple(config) + assert tup.checkpoint["channel_values"].get("evidence"), "前一张口径卡的台账应该已经在断点里" + checkpoint = {**tup.checkpoint, + "channel_values": {k: v for k, v in tup.checkpoint["channel_values"].items() + if k != "evidence"}, + "channel_versions": {k: v for k, v in tup.checkpoint["channel_versions"].items() + if k != "evidence"}} + await saver.aput(tup.parent_config, checkpoint, tup.metadata, {}) + reread = await saver.aget_tuple(config) + assert "evidence" not in reread.checkpoint["channel_values"] + + await run_manager.resume(run.id, {"approved": True}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + second = (await _events(run.id, "node.finished", "second"))[0].data + assert [e["node_id"] for e in second["evidence"]] == ["second"] + assert second["evidence"][0]["exec"] == 1 + # 断点里丢掉的那条不会凭空回来:通道从空列表起步,之后照常追加 + assert [e["node_id"] for e in await _ledger(run.id)] == ["second"] + + +async def _ledger(run_id: str) -> list[dict]: + tup = await run_manager.checkpointer.aget_tuple({"configurable": {"thread_id": run_id}}) + return list(tup.checkpoint["channel_values"].get("evidence") or []) + + +async def test_ledger_accumulates_across_nodes_and_parallel_branches(engine_up): + """台账只追加:两张口径卡并行跑、之后再跑一张,三条都在,顺序就是写入顺序。""" + graph = { + "nodes": [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"), + node("east", "metrics", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]), + node("west", "metrics", metrics=[{"id": "orders", "expression": "vars.kpi.orders"}]), + node("total", "metrics", metrics=[{"id": "refunds", "expression": "vars.kpi.refunds"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.total.text }}"}]), + ], + "edges": [{"source": a, "target": b} for a, b in [ + ("start", "fetch"), ("fetch", "east"), ("fetch", "west"), ("east", "total"), + ("west", "total"), ("total", "out")]], + } + run = await run_manager.start(graph=graph, input_payload={}) + row = await _finish(run.id) + assert row.status == "succeeded", row.error + ledger = await _ledger(run.id) + assert sorted(e["node_id"] for e in ledger[:2]) == ["east", "west"] + assert [e["node_id"] for e in ledger[2:]] == ["total"] + assert all(e["kind"] == "metric_set" and e["artifact"] for e in ledger) diff --git a/backend/tests/test_report_node.py b/backend/tests/test_report_node.py new file mode 100644 index 00000000..6d698055 --- /dev/null +++ b/backend/tests/test_report_node.py @@ -0,0 +1,450 @@ +"""报告撰写节点:模型只写引用标记,数字由系统从口径卡取出来渲染。 + +以前叙述是 llm 节点写的自由文本,出具时再按数值回头猜每个数来自哪个指标—— +5 个指标都是 0.0 时,报告里所有的 0 全算给第一个;模型顺手写的「45678」没人拦, +直到出口才被判成「未回指」。报告节点把这两件事都挪到写的那一刻: + +- 每个数字带着它自己的出处(按 id 引用,同值不同源也各归各的) +- 裸数字、引用不存在当场判违规,让写作者重写一次;还不行,按 on_violation 失败或标注 +- 流里看到的就是最终的数字,没有 [[m:gmv]] 这种原始标记 +- 自查结果(report.checked)落在封存范围内,证据接口从它出发找文档 +""" +from __future__ import annotations + +import asyncio + +import pytest +from langchain_core.messages import AIMessage, HumanMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.core.events import EventType +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import runner as runner_mod +from app.engine.evidence import DOC_SCHEMA, iter_segments, make_eid +from app.engine.runner import run_manager, verify_manifest + +DSML = ('<||DSML|| calls>\n<||DSML|| invoke name="db_query__shop">\n' + '<||DSML|| parameter name="sql">SELECT 1\n' + '\n') + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +KPI = "{'gmv': 45678.5, 'gmv_prev': 42010.0, 'orders': 1234}" +STANDARD = [ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}, + {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", + "expression": "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)"}, + {"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"}, +] +CLEAN = "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。[[see:m:gmv,m:wow]]" + + +def weekly(report=None, *, kpi=KPI, metrics=STANDARD, contract=None, between=(), extra_edges=()): + """input → 取数 → 口径卡 → 报告 → 出口。between 里的节点插在报告和出口之间。""" + out = {"fields": [{"name": "周报", "value": "{{ nodes.write.text }}"}]} + if contract is not None: + out["contract"] = contract + nodes = [ + node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]), + node("fetch", "transform", mode="expression", expression=kpi, assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=metrics), + node("write", "report", instructions="为 {{ input.week }} 写周报", **(report or {})), + *between, + node("out", "output", **out), + ] + chain = ["start", "fetch", "caliber", "write", *[n["id"] for n in between], "out"] + return {"nodes": nodes, + "edges": [{"source": a, "target": b} for a, b in zip(chain, chain[1:])] + + [{"source": a, "target": b} for a, b in extra_edges]} + + +def script(monkeypatch, *replies): + """模型依次回这几段,用完了一直回最后一段。返回每次调用收到的消息。""" + from app.providers import mock_model + + seen: list[list] = [] + + def _decide(self, messages): + seen.append(list(messages)) + return AIMessage(content=replies[min(len(seen), len(replies)) - 1]) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + return seen + + +def spy_tokens(monkeypatch) -> list: + """llm.token 不落库,只广播:在广播口上截下来。""" + captured: list = [] + original = runner_mod.bus.publish + + async def spy(event): + if str(event.type) == EventType.LLM_TOKEN: + captured.append(event) + await original(event) + + monkeypatch.setattr(runner_mod.bus, "publish", spy) + return captured + + +async def finish(graph, *, run_class="exploratory", statuses=("succeeded", "failed")) -> Run: + run = await run_manager.start(graph=graph, input_payload={}, run_class=run_class) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + # 状态先提交、封存随后才写:终态要等 manifest_seq 落下来再看 + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + return row + raise AssertionError(f"run {run.id} 没跑完:{row.status}") + + +async def events(run_id: str, etype: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id) + if etype: + q = q.where(RunEvent.type == etype) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def report_of(run_id: str) -> tuple[dict, dict]: + """(report.checked 的载荷, 取回的文档)。""" + checked = await events(run_id, "report.checked") + assert len(checked) == 1, [e.data for e in checked] + return checked[0].data, artifact_store.load(checked[0].data["doc_artifact"]) + + +def numbers(doc: dict) -> list[tuple[str, str | None, str]]: + return [(s["text"], (s.get("cite") or {}).get("locator", {}).get("metric"), s["state"]) + for s in iter_segments(doc) if s["kind"] == "number"] + + +# -------------------------------------------------------------------------- +# 各归各的 +# -------------------------------------------------------------------------- + + +async def test_five_zero_metrics_each_keep_their_own_source(monkeypatch): + """旧契约按数值回指:5 个 0.0 全算给第一个指标。按 id 引用就没有这个问题。""" + zeros = [{"id": k, "name": f"指标{k.upper()}", "unit": "元", "decimals": 1, + "expression": f"vars.kpi.{k}"} for k in "abcde"] + script(monkeypatch, "各项指标:A [[m:a]],B [[m:b]],C [[m:c]],D [[m:d]],E [[m:e]]。" + "[[see:m:a,m:b,m:c,m:d,m:e]]") + row = await finish(weekly(kpi="{'a': 0.0, 'b': 0.0, 'c': 0.0, 'd': 0.0, 'e': 0.0}", metrics=zeros)) + assert row.status == "succeeded", row.error + checked, doc = await report_of(row.id) + assert numbers(doc) == [("0.0元", k, "deterministic") for k in "abcde"] + + card = (await events(row.id, "node.finished")) + card_artifact = next(e.data["evidence"][0]["artifact"] for e in card if e.node_id == "caliber") + eids = [s["cite"]["eid"] for s in iter_segments(doc) if s["kind"] == "number"] + assert eids == [make_eid("metric", card_artifact, {"metric": k}) for k in "abcde"] + assert len(set(eids)) == 5 + assert checked["ok"] is True and checked["violations"] == [] and checked["repairs"] == 0 + assert checked["stats"]["numbers_cited"] == 5 and checked["stats"]["uncited_numbers"] == 0 + + +async def test_output_shape_and_stored_document(monkeypatch): + script(monkeypatch, CLEAN) + graph = weekly({"assign_to": "report"}) + row = await finish(graph) + assert row.status == "succeeded", row.error + checked, doc = await report_of(row.id) + + assert doc["schema"] == DOC_SCHEMA and doc["run_id"] == row.id and doc["node_id"] == "write" + text = doc["markdown"] + assert text == "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单。" + assert row.output["周报"] == text + assert numbers(doc) == [("45,678.5元", "gmv", "deterministic"), ("8.7%", "wow", "deterministic"), + ("1,234单", "orders", "deterministic")] + + finished = next(e for e in await events(row.id, "node.finished") if e.node_id == "write") + payload = artifact_store.load(finished.data["artifact"]) + assert payload["text"] == text and payload["doc_artifact"] == checked["doc_artifact"] + assert payload["stats"] == checked["stats"] == doc["stats"] + assert payload["violations"] == [] and payload["repairs"] == 0 + assert payload["model"] and isinstance(payload["duration_ms"], int) + # assign_to 拿到的是渲染后的正文:下游模板引用它,看到的就是报告本身 + state = await run_manager.checkpointer.aget_tuple({"configurable": {"thread_id": row.id}}) + assert state.checkpoint["channel_values"]["vars"]["report"] == text + + +async def test_prompt_carries_the_catalog_and_writing_rules(monkeypatch): + seen = script(monkeypatch, CLEAN) + row = await finish(weekly({"system": "你是周报撰写人"})) + assert row.status == "succeeded", row.error + prompt = "\n".join(str(m.content) for m in seen[0]) + assert "你是周报撰写人" in prompt and "为 2026-W37 写周报" in prompt + assert "[[m:gmv]] 销售额 = 45,678.5元" in prompt and "[[i:week]] = 2026-W37" in prompt + assert "任何数字都只能用引用标记写" in prompt + + +async def test_metrics_from_limits_the_catalog_to_the_named_card(monkeypatch): + """两张上游口径卡里有同名指标:只写 east 时,m:gmv 就是 east 的那一个,不必写卡名。""" + seen = script(monkeypatch, "东区销售额 [[m:gmv]]。") + graph = weekly({"metrics_from": ["east"]}, between=()) + graph["nodes"][2:3] = [ + node("east", "metrics", caliber="东区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.gmv"}]), + node("west", "metrics", caliber="西区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.orders"}]), + ] + graph["edges"] = [{"source": a, "target": b} for a, b in [ + ("start", "fetch"), ("fetch", "east"), ("fetch", "west"), ("east", "write"), ("west", "write"), + ("write", "out")]] + row = await finish(graph) + assert row.status == "succeeded", row.error + prompt = "\n".join(str(m.content) for m in seen[0]) + assert "东区" in prompt and "西区" not in prompt + _, doc = await report_of(row.id) + assert numbers(doc) == [("45,678.5元", "gmv", "deterministic")] + assert doc["catalog"]["m:gmv"]["node_id"] == "east" + + +# -------------------------------------------------------------------------- +# 流:看到的就是最终的数字 +# -------------------------------------------------------------------------- + + +async def test_llm_tokens_carry_real_numbers_not_markup(monkeypatch): + """mock 按 6 个字一块吐,标记必然被切在两块中间——渲染器要攒齐了再放。""" + script(monkeypatch, CLEAN + "\n\n引用错了的也不漏标记:[[m:gmvx]]。") + tokens = spy_tokens(monkeypatch) + row = await finish(weekly({"max_repairs": 0})) # 只看一遍流:修复会再流一遍 + assert row.status == "succeeded", row.error + streamed = "".join(t.data["delta"] for t in tokens if t.node_id == "write") + assert "[[" not in streamed and "]]" not in streamed and "m:gmv" not in streamed.replace("?m:gmvx", "") + assert "45,678.5元" in streamed and "8.7%" in streamed and "2026-W37" in streamed + assert "⟦?m:gmvx⟧" in streamed # 解析不了的引用在流里就是占位,和文档里一样 + _, doc = await report_of(row.id) + assert streamed.strip() == doc["markdown"] + + +# -------------------------------------------------------------------------- +# 违规:修复一次;还不行按 on_violation +# -------------------------------------------------------------------------- + + +async def test_a_stream_that_breaks_mid_marker_leaks_nothing(monkeypatch): + """流断在一个标记的半截(「[[m:g」),_invoke_streaming 退回非流式重来。渲染器攒着的半截 + 得扔掉:不然收尾 flush 时它会原样漏进 llm.token,和重来的那份拼在一起。""" + from langchain_core.messages import AIMessageChunk + from langchain_core.outputs import ChatGenerationChunk + + from app.providers import mock_model + + script(monkeypatch, CLEAN) + + async def broken(self, messages, stop=None, run_manager=None, **kwargs): + for piece in ("本周([[i:week]])销售额 ", "[[m:g"): + yield ChatGenerationChunk(message=AIMessageChunk(content=piece)) + raise RuntimeError("网关断开") + + monkeypatch.setattr(mock_model.MockChatModel, "_astream", broken) + tokens = spy_tokens(monkeypatch) + row = await finish(weekly({"on_violation": "fail"})) + assert row.status == "succeeded", row.error + assert [e.data["code"] for e in await events(row.id, "log") if e.data.get("code") == "stream_fallback"] \ + == ["stream_fallback"] + streamed = "".join(t.data["delta"] for t in tokens if t.node_id == "write") + # 断之前放出去的是渲染好的字;半截标记一个字符都不能出去 + assert streamed == "本周(2026-W37)销售额 ", streamed + checked, doc = await report_of(row.id) + assert checked["ok"] is True and checked["repairs"] == 0 + assert doc["markdown"] == "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单。" + assert row.output["周报"] == doc["markdown"] + + +async def test_a_bare_number_is_repaired_exactly_once(monkeypatch): + seen = script(monkeypatch, "本周销售额 45678 元,订单 [[m:orders]]。", CLEAN) + row = await finish(weekly()) + assert row.status == "succeeded", row.error + assert len(seen) == 2, "违规之后应当恰好重写一次" + repair = seen[1][-1] + assert isinstance(repair, HumanMessage) and "45678" in str(repair.content) + assert isinstance(seen[1][-2], AIMessage) and "45678" in str(seen[1][-2].content) + warns = [e.data for e in await events(row.id, "log") if e.data.get("code") == "report_repair"] + assert len(warns) == 1 and warns[0]["level"] == "warn" and "45678" in warns[0]["message"] + checked, doc = await report_of(row.id) + assert checked["repairs"] == 1 and checked["ok"] is True and doc["violations"] == [] + assert "45678" not in doc["markdown"] + + +async def test_persistent_violation_fails_in_fail_mode(monkeypatch): + seen = script(monkeypatch, "本周销售额 45678 元,引用 [[m:nope]]。") + row = await finish(weekly({"on_violation": "fail"})) + assert row.status == "failed" + assert row.error_node_id == "write" + assert "45678" in row.error and "nope" in row.error, row.error + assert len(seen) == 2 # 默认修复一次 + # 失败的那份也要留底:自查结果照样进封存,事后看得到它哪里不对 + checked, doc = await report_of(row.id) + assert checked["ok"] is False and checked["repairs"] == 1 and checked["failed"] is True + assert {v["code"] for v in doc["violations"]} == {"uncited_number", "unresolved_ref"} + + +async def test_max_repairs_zero_means_no_rewrite(monkeypatch): + seen = script(monkeypatch, "本周销售额 45678 元。") + row = await finish(weekly({"on_violation": "fail", "max_repairs": 0})) + assert row.status == "failed" and len(seen) == 1 + + +async def test_persistent_violation_is_flagged_in_flag_mode(monkeypatch): + script(monkeypatch, "本周销售额 45678 元,订单 [[m:orders]]。") + row = await finish(weekly({"on_violation": "flag"})) + assert row.status == "succeeded", row.error + checked, doc = await report_of(row.id) + assert checked["ok"] is False and checked["repairs"] == 1 and checked["failed"] is False + assert row.output["周报"] == doc["markdown"] + bare = [s for s in iter_segments(doc) if s.get("issue") == "uncited_number"] + assert [(s["text"], s["state"]) for s in bare] == [("45678", "none")] + finished = next(e for e in await events(row.id, "node.finished") if e.node_id == "write") + assert [v["code"] for v in artifact_store.load(finished.data["artifact"])["violations"]] \ + == ["uncited_number"] + + +async def test_on_violation_defaults_follow_the_run_class(monkeypatch): + """探索运行默认标注、照常产出;正式运行默认失败。""" + script(monkeypatch, "本周销售额 45678 元。") + explore = await finish(weekly()) + assert explore.status == "succeeded", explore.error + formal = await finish(weekly(), run_class="formal") + assert formal.status == "failed" and "45678" in formal.error + + +async def test_the_default_follows_the_run_record_in_subgraphs_and_after_resume(monkeypatch): + """默认值按运行记录上的 run_class 取:子图的线程 id 是「运行 id:节点 id」,审批之后续跑 + 是另起的执行上下文——两种情况下正式运行都不能悄悄退成 flag。""" + from app.db.models import Workflow + + script(monkeypatch, "本周销售额 45678 元。") + async with SessionLocal() as session: + wf = Workflow(name="周报子流程", graph=weekly()) + session.add(wf) + await session.commit() + wf_id = wf.id + parent = {"nodes": [node("start", "input", fields=[]), node("sub", "subgraph", workflow_id=wf_id), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.周报 }}"}])], + "edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "out"}]} + explore = await finish(parent) + assert explore.status == "succeeded", explore.error + assert "45678" in explore.output["r"] + formal = await finish(parent, run_class="formal") + assert formal.status == "failed" and "45678" in formal.error, formal.error + + gated = weekly() + gated["nodes"].insert(3, node("gate", "human", mode="approve", title="确认口径")) + chain = ["start", "fetch", "caliber", "gate", "write", "out"] + gated["edges"] = [{"source": a, "target": b} for a, b in zip(chain, chain[1:])] + paused = await finish(gated, run_class="formal", statuses=("interrupted", "failed")) + assert paused.status == "interrupted", paused.error + await run_manager.resume(paused.id, {"approved": True}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + resumed = await session.get(Run, paused.id) + if resumed.status in ("succeeded", "failed") and resumed.manifest_seq is not None: + break + assert resumed.status == "failed" and "45678" in resumed.error, resumed.error + [checked] = [e.data for e in await events(paused.id, "report.checked")] + assert checked["on_violation"] == "fail" + + +async def test_numbers_off_does_not_rewrite_but_still_records(monkeypatch): + seen = script(monkeypatch, "本周销售额 45678 元。") + row = await finish(weekly({"numbers": "off", "on_violation": "fail"})) + assert row.status == "succeeded", row.error + assert len(seen) == 1 + checked, doc = await report_of(row.id) + # 不拦不等于不记:文档和出口复核照样看得到这个数没有出处 + assert [v["code"] for v in doc["violations"]] == ["uncited_number"] + assert checked["ok"] is False + + +async def test_numbers_the_contract_allows_are_not_repaired(monkeypatch): + """出口契约放行的字面量(allow_numbers),报告自查也放行,不必为它白白重写一次。""" + seen = script(monkeypatch, "共分 37 个门店统计,销售额 [[m:gmv]]。") + row = await finish(weekly(contract={"metrics_from": ["caliber"], "report_from": "write", + "allow_numbers": ["37"]})) + assert row.status == "succeeded", row.error + assert len(seen) == 1 + checked, _ = await report_of(row.id) + assert checked["ok"] is True + + +@pytest.mark.parametrize("value", ["sometimes", 3]) +async def test_bad_on_violation_is_rejected(monkeypatch, value): + script(monkeypatch, CLEAN) + row = await finish(weekly({"on_violation": value})) + assert row.status == "failed" and "on_violation" in row.error + + +async def test_tool_markup_is_nudged_once(monkeypatch): + seen = script(monkeypatch, DSML, CLEAN) + row = await finish(weekly()) + assert row.status == "succeeded", row.error + assert len(seen) == 2 + assert "tool_markup_leak" in [e.data.get("code") for e in await events(row.id, "log")] + _, doc = await report_of(row.id) + assert "DSML" not in doc["markdown"] + + +# -------------------------------------------------------------------------- +# 封存 +# -------------------------------------------------------------------------- + + +async def test_report_checked_is_inside_the_seal(monkeypatch): + script(monkeypatch, CLEAN) + row = await finish(weekly()) + assert row.status == "succeeded", row.error + checked = (await events(row.id, "report.checked"))[0] + assert checked.node_id == "write" + assert row.manifest_seq is not None and checked.seq <= row.manifest_seq + assert (await verify_manifest(row.id))["ok"] is True + # 文档取回时复验哈希;事件里的 id 就是它的内容哈希 + assert artifact_store.load(checked.data["doc_artifact"])["node_id"] == "write" + + +# -------------------------------------------------------------------------- +# 目录参数:出口复核要用同一个函数重建,这里单独钉住它的语义 +# -------------------------------------------------------------------------- + + +def test_report_catalog_honours_evidence_from_and_ancestry(): + """evidence_from 只管查询、检索这类证据从哪几个节点来;不在上游的节点什么都不收。""" + from app.engine.nodes.report import report_catalog + from app.engine.schema import GraphSpec + + spec = GraphSpec.model_validate({ + "nodes": [node("a", "tool", tool="x"), node("b", "tool", tool="x"), node("late", "tool", tool="x"), + node("card", "metrics", metrics=[{"id": "gmv", "expression": "1"}]), + node("write", "report", evidence_from=["a"])], + "edges": [{"source": s, "target": t} for s, t in [("a", "card"), ("b", "card"), ("card", "write"), + ("write", "late")]]}) + state = { + "nodes": {"card": {"kind": "metric_set", "caliber": "c", "caliber_version": "v1", "artifact": "f" * 64, + "metrics": [{"id": "gmv", "name": "销售额", "value": 1, "unit": ""}]}}, + "evidence": [{"kind": "query", "node_id": "a", "artifact": "a" * 64, "rows": 2}, + {"kind": "query", "node_id": "b", "artifact": "b" * 64, "rows": 3}, + {"kind": "query", "node_id": "late", "artifact": "c" * 64, "rows": 4}], + "input": {"week": "2026-W37", "items": [1, 2]}, + } + write = spec.node_map()["write"] + catalog = report_catalog(state, spec, write) + assert set(catalog) == {"m:gmv", "Q1", "i:week"} + assert catalog["Q1"]["node_id"] == "a" + + write.config["evidence_from"] = "ancestors" + assert {k: v.get("node_id") for k, v in report_catalog(state, spec, write).items() if k.startswith("Q")} \ + == {"Q1": "a", "Q2": "b"} diff --git a/backend/tests/test_report_schema.py b/backend/tests/test_report_schema.py new file mode 100644 index 00000000..a3c01ed7 --- /dev/null +++ b/backend/tests/test_report_schema.py @@ -0,0 +1,125 @@ +"""报告撰写节点的类型登记,以及搭图时就能认出来的两种错接。 + +报告节点从哪几张口径卡取指标(metrics_from)、出口契约核对哪个报告节点 +(report_from),都是按节点 id 写死的引用。指到一个不是口径卡的节点、或者指到 +下游,运行时只会得到一个空目录、一份无从核对的报告——而这在画图时就看得出来。 +""" +from __future__ import annotations + +import pytest + +from app.core.events import EventType +from app.engine.schema import TYPE_LABEL, GraphSpec, NodeType, type_label, validate_graph +from app.engine.variables import analyze + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def spec(nodes, edges): + return GraphSpec.model_validate({ + "nodes": nodes, "edges": [{"source": a, "target": b} for a, b in edges]}) + + +def issues(graph: GraphSpec, field: str) -> list[tuple[str, str]]: + return [(i.level, i.message) for i in validate_graph(graph).issues if i.field == field] + + +def test_report_type_is_registered(): + assert NodeType("report") is NodeType.REPORT + assert TYPE_LABEL["report"] == "报告撰写" == type_label(NodeType.REPORT) + assert EventType("report.checked") is EventType.REPORT_CHECKED + + +def report_graph(metrics_from=None, *, report_from="write", extra=(), edges=None, **contract): + cfg = {"instructions": "写周报", "model": "mock"} + if metrics_from is not None: + cfg["metrics_from"] = metrics_from + nodes = [ + node("start", "input"), + node("fetch", "transform", mode="expression", expression="{'gmv': 1}", assign_to="kpi"), + node("caliber", "metrics", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]), + node("write", "report", **cfg), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"metrics_from": ["caliber"], "report_from": report_from, **contract}), + *extra, + ] + return spec(nodes, edges or [("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"), + ("write", "out")]) + + +def test_a_well_formed_report_graph_has_no_new_errors(): + graph = report_graph(["caliber"]) + assert issues(graph, "metrics_from") == [] + assert issues(graph, "contract.report_from") == [] + assert issues(report_graph("caliber"), "metrics_from") == [] # 单个 id 写成字符串也认 + + +@pytest.mark.parametrize("metrics_from, words", [ + (["nope"], ["nope", "不存在"]), + (["fetch"], ["fetch", "不是「口径卡」"]), +]) +def test_metrics_from_must_name_a_caliber(metrics_from, words): + found = issues(report_graph(metrics_from), "metrics_from") + assert len(found) == 1 and found[0][0] == "error", found + assert all(w in found[0][1] for w in words), found + + +def test_metrics_from_must_be_upstream(): + """口径卡接在报告后面:报告跑的时候它还没算,目录里一个指标都没有。""" + graph = report_graph(["late"], extra=[node("late", "metrics", metrics=[{"id": "x", "expression": "1"}])], + edges=[("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"), + ("write", "late"), ("late", "out")]) + found = issues(graph, "metrics_from") + assert len(found) == 1 and found[0][0] == "error" and "上游" in found[0][1], found + + +@pytest.mark.parametrize("report_from, words", [ + ("nope", ["nope", "不存在"]), + ("caliber", ["caliber", "不是「报告撰写」"]), +]) +def test_report_from_must_name_a_report_node(report_from, words): + found = issues(report_graph(["caliber"], report_from=report_from), "contract.report_from") + assert len(found) == 1 and found[0][0] == "error", found + assert all(w in found[0][1] for w in words), found + + +def test_report_from_must_be_upstream_of_the_output(): + graph = report_graph(["caliber"], report_from="side", extra=[node("side", "report", instructions="x")], + edges=[("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"), + ("write", "out"), ("caliber", "side")]) + found = issues(graph, "contract.report_from") + assert len(found) == 1 and found[0][0] == "error" and "上游" in found[0][1], found + + +def test_old_contracts_are_untouched(): + """没写 report_from 的旧契约:不多一条报错,缺 metrics_from 照旧报。""" + graph = spec([node("start", "input"), + node("out", "output", contract={"narrative": "x"})], [("start", "out")]) + assert issues(graph, "contract.report_from") == [] + assert [lv for lv, _ in issues(graph, "contract.metrics_from")] == ["error"] + + +# -------------------------------------------------------------------------- +# 变量分析扫口径卡的表达式 +# -------------------------------------------------------------------------- + + +def test_caliber_expressions_count_as_references(): + """口径卡对上游的引用以前在变量表里看不见:vars.kpi 被报成「产出了但没有任何地方引用」, + 画布上也画不出口径卡依赖谁。""" + graph = spec([ + node("start", "input"), + node("fetch", "transform", mode="expression", expression="{'gmv': 1, 'prev': 1}", assign_to="kpi"), + node("caliber", "metrics", metrics=[ + {"id": "gmv", "expression": "vars.kpi.gmv"}, + {"id": "wow", "expression": "(vars.kpi.gmv - vars.kpi.prev) / vars.kpi.prev"}]), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.caliber.text }}"}]), + ], [("start", "fetch"), ("fetch", "caliber"), ("caliber", "out")]) + report = analyze(graph) + kpi = next(v for v in report.variables if v.path == "vars.kpi") + assert {(r.node_id, r.field) for r in kpi.refs} == {("caliber", "metrics[0].expression"), + ("caliber", "metrics[1].expression")} + assert not [i for i in report.issues if i.path == "vars.kpi"], report.issues diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index 64364d18..d7a5e466 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -348,3 +348,56 @@ async def test_review_endpoint_reports_signals(client, monkeypatch): async def test_review_endpoint_404(client): resp = await client.post("/api/copilot/review", json={"run_id": "没这个", "question": "q"}) assert resp.status_code == 404 + + +# -------------------------------------------------------------------------- +# 带逐段证据的答案:只加说明,不改写 +# -------------------------------------------------------------------------- + + +class RecordingModel(FakeModel): + def __init__(self, reply): + super().__init__(reply) + self.seen: list = [] + + async def ainvoke(self, messages): + self.seen.append(messages) + return await super().ainvoke(messages) + + +@pytest.mark.asyncio +async def test_review_never_rewrites_an_answer_that_carries_evidence(): + """报告节点产出的答案,每个数字都按位置对应着证据文档。复核把它整段改写, + 文档和答案就对不上了——界面上点开的出处指向一段已经不存在的文字。""" + model = RecordingModel({"note": "报告被要求重写过一次。", "answer": "改写后的周报,销售额 45,678.5元。", + "retry": False}) + result = await rv.review(model, question="q", answer="周报:销售额 45,678.5元。", signals=SIGNALS, + locked=True) + assert result.verdict == "annotated" + assert result.answer is None and result.original is None + assert result.note == "报告被要求重写过一次。" + prompt = "\n".join(str(text) for _, text in model.seen[0]) + assert "不能改写" in prompt + + +def test_report_repair_is_a_degraded_signal(): + signals = rv.scan([warn("报告里有 1 处没通过核对(「45678」),已要求写作者重写(第 1 次)", + "report_repair")], OK_OUTPUT) + assert [(s.kind, s.severity) for s in signals] == [("report_repair", rv.DEGRADED)] + + +@pytest.mark.asyncio +async def test_review_endpoint_keeps_answers_with_evidence(client, monkeypatch): + model = FakeModel({"note": "报告被要求重写过一次。", "answer": "整段改写过的周报。", "retry": False}) + + async def fake_model(*a, **k): + return model, "fake" + + monkeypatch.setattr("app.api.copilot.get_chat_model", fake_model) + output = {"周报": "周报:销售额 45,678.5元。", + "_evidence": {"report_node": "write", "doc_artifact": "a" * 64, "fields": ["周报"]}} + run_id = await _make_run(client, output, [warn("报告里有 1 处没通过核对,已要求写作者重写", "report_repair")]) + body = (await client.post("/api/copilot/review", json={"run_id": run_id, "question": "q"})).json() + assert model.calls == 1 + assert body["verdict"] == "annotated" and body["answer"] is None + assert body["note"] == "报告被要求重写过一次。" diff --git a/backend/tests/test_template_traceable.py b/backend/tests/test_template_traceable.py new file mode 100644 index 00000000..a7013dc1 --- /dev/null +++ b/backend/tests/test_template_traceable.py @@ -0,0 +1,102 @@ +"""内置模板「⑨ 可追溯周报」:tool 查库 → 解析 → 口径卡 → 报告撰写 → 出具(引用模式)。 + +模板既是示范也是活文档:它要能通过 validate_graph,接上一个真实的库(这里用 +SQLite 建一个示例 shop 库)要能从头跑到尾、完整出具,报告里每个数都点得开。 +模板里只许出现通用示例名。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import DataSource, Run +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph +from app.seed import TEMPLATES + +NAME = "⑨ 可追溯周报" + + +def template() -> dict: + return next(t for t in TEMPLATES if t["name"] == NAME) + + +def graph() -> dict: + t = template() + return {"nodes": t["nodes"], "edges": t["edges"]} + + +def test_template_passes_validation(): + report = validate_graph(GraphSpec.model_validate(graph())) + assert report.ok, [i.message for i in report.issues if i.level == "error"] + types = {n["id"]: n["type"] for n in template()["nodes"]} + assert list(types.values()).count("report") == 1 and "metrics" in types.values() + out = next(n for n in template()["nodes"] if n["type"] == "output") + contract = out["data"]["config"]["contract"] + report_id = next(k for k, v in types.items() if v == "report") + assert contract["report_from"] == report_id and contract["strict"] is True + # 成果字段原样引用报告正文,才能逐段对应证据 + assert {"name": "周报", "value": f"{{{{ nodes.{report_id}.text }}}}"} in out["data"]["config"]["fields"] + + +def test_template_is_listed_once_and_keeps_template_eight(): + names = [t["name"] for t in TEMPLATES] + assert names.count(NAME) == 1 + assert any(n.startswith("⑧ 周报") for n in names) # 老模板不改,免得覆盖用户复制过的那一份 + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def shop(tmp_path): + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL, refunded INTEGER);") + rows = [(i, "2026-W37", 100.0 + i, 1 if i % 10 == 0 else 0) for i in range(1, 41)] + rows += [(100 + i, "2026-W36", 90.0 + i, 0) for i in range(1, 31)] + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", rows) + db.commit() + db.close() + async with SessionLocal() as session: + existing = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none() + if existing is None: + session.add(DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)) + else: + existing.database, existing.kind, existing.enabled = str(path), "sqlite", True + await session.commit() + yield + + +async def test_template_runs_end_to_end_and_issues_formally(engine_up, shop, monkeypatch): + from app.providers import mock_model + + text = ("## 本周概览\n\n[[i:week]] 销售额 [[m:gmv]],环比 [[m:wow]]。[[see:m:gmv,m:wow]]\n\n" + "- 订单 [[m:orders]],客单价 [[m:aov]]。[[see:m:orders,m:aov]]\n" + "- 退款率 [[m:refund_rate]]。[[see:m:refund_rate]]") + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + run = await run_manager.start(graph=graph(), input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "succeeded", row.error + issuance = row.output["_issuance"] + assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance + # 40 单,金额 101..140 → 4820;上周 30 单 91..120 → 3165;退款 4 单 + report = row.output["周报"] + for shown in ("2026-W37", "4,820.00元", "52.3%", "40单", "120.50元", "10.00%"): + assert shown in report, report + assert row.output["_evidence"]["fields"] == ["周报"] + assert issuance["matched_numbers"] == 5 and issuance["unmatched_numbers"] == [] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 89c55f52..74a050f7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import type { - Approval, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, GraphSpec, - KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolChange, ToolInfo, - ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, + Approval, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, + EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, + Skill, ToolChange, ToolInfo, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, } from '../types' import { localActor } from '../lib/actor' import { VALIDATION_TITLE, describeValidation } from '../lib/validation' @@ -667,6 +667,22 @@ export const api = { }, artifact: (id: string) => get<{ id: string; content: any }>(`/artifacts/${id}`), + // ---- 可点击证据 ---- + // 只认封存范围内的事件追得到的工件(后端 api/evidence.py)。老后端没有这两个接口,调用方按 404 降级 + evidence: { + /** 整次运行的证据图:模式(cited / legacy_contract / none)、封存状态、报告和证据清单 */ + graph: (runId: string, opts?: RequestOptions) => + get(`/runs/${encodeURIComponent(runId)}/evidence`, opts), + /** + * 点开一个片段:所在的句子、指标步骤(原式、代入式、输入、复算)、封存状态。一次运行 + * 里有好几份报告时 report 给报告节点 id(片段 id 每份文档各自从 s0 数起),不给取成果标注的那份 + */ + segment: (runId: string, segmentId: string, opts?: RequestOptions & { report?: string }) => + get( + `/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}${qs({ report: opts?.report })}`, + opts), + }, + // ---- 会话 ---- conversations: { /** includeArchived:连回收站里的一起取(每行的 archived 区分),回收站视图要它 */ diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 18befb35..1d92c7b9 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -102,6 +102,53 @@ function ToolsInput({ id, single, value, invalid, aria, onChange }: { /** 不需要某张目录表的字段拿到的都是这一个空数组:选择器结果不变,就不重渲染 */ const NO_ROWS: never[] = [] +/** + * 从上游某一类节点里多选(报告的 metrics_from 只收上游口径卡)。 + * + * 只列上游的:下游的卡在报告跑的时候还没算,选了也是空的(后端 validate 会报 error)。 + * 已经选了、后来被挪到下游或删掉的照样显示成芯片,好让人看见并移掉。 + * 全部移掉写回 undefined 而不是 []:后端两者都当「全部上游」,配置里不留一个空数组 + */ +function NodeRefsInput({ nodeId, type, value, onChange }: { + nodeId: string; type?: string; value: any; onChange: (v: any) => void +}) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const options = useMemo(() => { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(nodeId) ?? [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id) || id === nodeId) continue + seen.add(id) + stack.push(...(parents.get(id) ?? [])) + } + return nodes + .filter((n) => seen.has(n.id) && (!type || n.data?.nodeType === type)) + .map((n) => { + const cfg = (n.data?.config ?? {}) as Record + const label = String(n.data?.label || n.id) + const hint = type === 'metrics' + ? [cfg.caliber && `${cfg.caliber} ${cfg.caliber_version || ''}`.trim(), `${(cfg.metrics ?? []).length} 个指标`] + .filter(Boolean).join(' · ') + : undefined + return { value: n.id, label: label === n.id ? label : `${label}(${n.id})`, hint } + }) + }, [nodes, edges, nodeId, type]) + const list: string[] = Array.isArray(value) ? value.map(String) : typeof value === 'string' && value ? [value] : [] + return ( + onChange(v.length ? v : undefined)} + addLabel={type === 'metrics' ? '选择口径卡' : '选择节点'} + empty={type === 'metrics' ? '上游还没有口径卡:先在这个节点前面接一张口径卡' : '上游没有可选的节点'} + /> + ) +} + /** 属性面板。所有字段都由 nodeDefs 的声明驱动渲染,加节点类型不用改这里。 */ export function Inspector() { const selectedId = useStudio((s) => s.selectedId) @@ -524,6 +571,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ case 'metricsList': return + case 'nodeRefs': + return + case 'agents': return diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 37e5df17..7a6f4d53 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -12,6 +12,7 @@ import { LiveClock, stillClock, TeamMatrix, teamBrief, type TeamEnding } from '. import { edgeKey, topology } from '../run/derive' import { api } from '../api/client' import { isComposing, StatusBadge, toast } from '../components/ui' +import { matchedSource } from '../lib/evidence' import { explainRunError } from '../lib/explain' import { formatDuration, formatLapse, formatNumber, formatTokens, NONE } from '../lib/format' import { statusMeta } from '../lib/status' @@ -66,6 +67,10 @@ function summarize(type: NodeType, config: Record): string { return 'JSON Schema 校验' + (config.repair_with_llm ? ' · 失败自动返工' : '') case 'metrics': return `${config.caliber || '口径'}@${config.caliber_version || 'v1'} · ${(config.metrics ?? []).length} 个指标` + case 'report': { + const from = Array.isArray(config.metrics_from) ? config.metrics_from.length : 0 + return `${first(config.instructions) || '未填写作要求'} · ${from ? `${from} 张口径卡` : '上游全部口径卡'}` + } case 'transform': return first(config.expression, config.template) || '未配置' case 'subgraph': @@ -164,9 +169,10 @@ function matchedLines(matched: unknown): string { if (!Array.isArray(matched) || !matched.length) return '' const lines = matched.slice(0, 5).map((m: any) => { const token = String(m?.token ?? '').trim() - const metric = String(m?.metric ?? '').trim() + // 出处不唯一的(metric 为 null、带候选)照实写,不能写成空箭头 + const source = matchedSource(m).text const caliber = typeof m?.caliber === 'string' && m.caliber.trim() ? `(${m.caliber.trim()})` : '' - return token ? ` ${token}${metric ? ` → ${metric}` : ''}${caliber}` : '' + return token ? ` ${token}${source ? ` → ${source}` : ''}${caliber}` : '' }).filter(Boolean) if (!lines.length) return '' const more = matched.length > lines.length ? `\n …另有 ${matched.length - lines.length} 个` : '' diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 179c0564..a96bc825 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -1,5 +1,5 @@ import { - Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput, + Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput, FileText, Gauge, GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench, } from 'lucide-react' import { APPROVAL_POLICY_LABEL, NODE_TYPE_LABEL } from '../lib/terms' @@ -8,7 +8,7 @@ import type { NodeType } from '../types' export type FieldType = | 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch' | 'json' | 'model' | 'tools' | 'skills' | 'collection' - | 'ioFields' | 'cases' | 'agents' | 'metricsList' + | 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' /** * 这个字段里写的是什么语法。 @@ -36,6 +36,8 @@ export interface FieldDef { step?: number /** 只在满足条件时显示,避免面板一次糊一屏用不上的选项 */ when?: (config: Record) => boolean + /** nodeRefs:只能选这一类的上游节点(报告的 metrics_from 只收口径卡) */ + refType?: NodeType advanced?: boolean } @@ -470,6 +472,47 @@ export const NODE_DEFS: Record = { schema: { type: 'object', properties: {}, required: [] }, }, }, + report: { + type: 'report', label: NODE_TYPE_LABEL.report, category: '模型', icon: FileText, + description: '带引用的报告:模型只写引用标记,数字由系统从口径卡取出来渲染,每个数字都能点开看出处', + hasTarget: true, sources: [{ id: 'out', label: '' }], + fields: [ + { + key: 'instructions', label: '写作要求', type: 'prompt', placeholder: '为 {{ input.week }} 写周报,先总后分', + help: '写什么、怎么写。上游口径卡的指标和运行输入会自动整理成证据目录交给模型,不用在这里手抄', + }, + { + key: 'metrics_from', label: '指标来自', type: 'nodeRefs', refType: 'metrics', + help: '留空 = 用上游全部口径卡。报告里的数字只能引用这些卡里的指标', + }, + { + // 后端只认 strict / off(report.py 的 NUMBERS) + key: 'numbers', label: '没写引用的数字', type: 'select', + options: [ + { value: 'strict', label: '算违规,要求重写(默认)' }, + { value: 'off', label: '只标出来,不要求重写' }, + ], + help: '报告里每个数字都该写成引用标记,由系统取值渲染。模型自己写的数字核对不到出处', + }, + { + // 留空不是「不处理」:后端按运行类别取默认,探索运行照常产出、正式运行判失败 + key: 'on_violation', label: '重写后仍有违规时', type: 'select', + options: [ + { value: '', label: '按运行类别:探索运行照常产出,正式运行判失败' }, + { value: 'flag', label: '照常产出,把违规的地方标出来' }, + { value: 'fail', label: '判为失败,报告不往下交' }, + ], + }, + { + key: 'max_repairs', label: '最多重写几次', type: 'number', min: 0, max: 3, placeholder: '1', + help: '有违规时把清单交回去让模型重写。每次都是一整篇的调用,写不对的模型多给几次也大多写不对', + }, + ...MODEL_FIELDS, + { key: 'system', label: '角色设定', type: 'textarea', advanced: true, placeholder: '你是…' }, + ...COMMON_TAIL, + ], + defaults: { instructions: '', numbers: 'strict', max_repairs: 1 }, + }, metrics: { type: 'metrics', label: NODE_TYPE_LABEL.metrics, category: '把关', icon: Gauge, description: '受控指标集:所有算术在这里发生,叙述层只能引用', @@ -478,6 +521,11 @@ export const NODE_DEFS: Record = { { key: 'caliber', label: '口径名称', type: 'text', syntax: 'template', placeholder: '周报口径' }, { key: 'caliber_version', label: '口径版本', type: 'text', placeholder: 'v1' }, { key: 'metrics', label: '指标定义', type: 'metricsList' }, + { + key: 'on_missing', label: '缺输入时', type: 'select', advanced: true, + options: [{ value: '', label: '整个节点失败' }, { value: 'null', label: '记为空值,交给出具契约判档' }], + help: '上游没给出某个指标要的数时怎么办。记为空值的指标在报告里显示「—」,出具契约按必需 / 期望项降档', + }, { key: 'assign_to', label: '结果存为变量', type: 'text', help: '叙述节点用 {{ nodes.节点id.text }} 引用指标清单', diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 81a7c9b8..1e5032c6 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -10,6 +10,7 @@ import { ToastHost } from '../components/ui' import type { RunEvent } from '../types' import fixtures from '../run/__tests__/fixtures.json' import mdSamples from '../run/__tests__/markdown-samples.json' +import evidenceFixture from '../run/__tests__/evidence-doc.json' import { CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop, markupRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun, @@ -204,9 +205,40 @@ const LONG_TURN: StreamTurn = { steps: [], output: { result: longReport() }, } +/** + * 带逐段证据的一轮:口径卡 → 报告撰写 → 成果(_evidence 标注了「周报」字段)。报告文档和 + * 片段接口由 check-stream 用 page.route 伪造(/api/artifacts/、…/evidence/segments/…)。 + * 后面跟一轮同样的答案、但没有 _evidence:旧运行就是这样,渲染必须和普通 Markdown 一模一样 + */ +function evidenceTurns(): StreamTurn[] { + const fx = evidenceFixture + const E = (seq: number, type: string, node_id: string | null, data: Record = {}): RunEvent => + ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const events = [ + E(1, 'run.started', null, { total: 3 }), + E(2, 'node.started', 'caliber', { node_type: 'metrics', label: '周报口径' }), + E(3, 'node.finished', 'caliber', { duration_ms: 12, preview: { kind: 'metric_set', caliber: '周报口径' } }), + E(4, 'node.started', 'write', { node_type: 'report', label: '写周报' }), + E(5, 'report.checked', 'write', fx.report_checked), + E(6, 'node.finished', 'write', { duration_ms: 2300 }), + E(7, 'node.started', 'out', { node_type: 'output', label: '成果' }), + E(8, 'node.finished', 'out', { duration_ms: 3 }), + E(9, 'run.finished', null, { output: {} }), + ] + const text = fx.output['周报'] + return [ + { id: 'evidence', question: '写一份本周周报', phase: 'done', status: '已完成', runClass: 'exploratory', + steps: decodeRun(events, { status: 'succeeded' }), output: fx.output, runId: fx.run_id }, + { id: 'evidence-legacy', question: '写一份本周周报(旧运行)', phase: 'done', status: '已完成', + steps: [], output: { 周报: text }, runId: 'syn-evidence-legacy' }, + ] +} + /** 合成场景:新后端才有的事件,老库里导不出来 */ function synthetic(name: string): StreamTurn[] { switch (name) { + case 'evidence': + return evidenceTurns() case 'team-live': { const ev = rebase(teamRun('members')) return [{ id: 'team-live', question: '比较三家供应商的交期风险', phase: 'running', status: '运行中', @@ -468,7 +500,13 @@ function Preview() { onFollowUp={(q) => { (window as any).__followUp = q }} /> ) - return dense ?
        {stream}
        : stream + // 证据场景另摆一份同样文字的普通 Markdown:没有 _evidence 的那一轮必须和它长得一模一样 + const baseline = syn === 'evidence' && ( + + ) + return dense + ?
        {stream}{baseline}
        + : <>{stream}{baseline} } if (params.get('long') === '1') { return diff --git a/frontend/src/dev/ui-harness.tsx b/frontend/src/dev/ui-harness.tsx index d241cf60..169930e7 100644 --- a/frontend/src/dev/ui-harness.tsx +++ b/frontend/src/dev/ui-harness.tsx @@ -21,6 +21,12 @@ import * as explain from '../lib/explain' import * as health from '../lib/health' import * as actor from '../lib/actor' import * as validation from '../lib/validation' +import * as evidence from '../lib/evidence' +import { EvidenceDoc, EvidenceField } from '../run/EvidenceDoc' +import { Markdown } from '../run/Markdown' +import evidenceFixture from '../run/__tests__/evidence-doc.json' +import mdPinned from '../run/__tests__/markdown-pinned.json' +import type { EvidenceDocData } from '../types' import '../index.css' /** @@ -178,6 +184,108 @@ function DatasourceConsumers() { return } +const EV_DOC = evidenceFixture.doc as unknown as EvidenceDocData +const EV_LEGACY = evidenceFixture.legacy +/** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */ +const LEGACY_LOOSE = { + matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m), + unmatched_numbers: EV_LEGACY.unmatched.map(({ start: _s, end: _e, ...u }) => u), +} + +/** + * 长报告:把夹具的块重复 n 遍(块、句、片段的 id 各自加后缀),stats 和违规清单去掉, + * 计数从片段自己数。用来看块级 content-visibility、超长折叠、跳转进折叠区时自动展开 + */ +function longDoc(doc: EvidenceDocData, times: number): EvidenceDocData { + const blocks = Array.from({ length: times }, (_, k) => doc.blocks.map((b) => ({ + ...b, id: `${b.id}-${k}`, + units: b.units.map((u) => ({ ...u, id: `${u.id}-${k}`, segments: u.segments.map((s) => ({ ...s, id: `${s.id}-${k}` })) })), + }))).flat() + return { ...doc, blocks, stats: undefined, violations: undefined, markdown: undefined } +} +const EV_LONG = longDoc(EV_DOC, 16) + +/** + * 可点击证据的演示:宽栏(侧边面板 / 窄屏时底部抽屉)、360px 窄栏(栏内展开)、旧契约的 + * 按位置标记。文档是后端 compose_doc 真跑出来的夹具(evidence-doc.json,只用通用名); + * runId 是假的,片段接口由 check-evidence 用 page.route 伪造,直接打开时面板照实说取不到 + */ +function EvidenceDemo() { + return ( +
        +
        + +
        +
        + +
        +
        +
        +
        旧契约 · 按位置标(日期里的 15 不画)
        + +
        +
        +
        老运行 · 没有位置,按字符串标
        + +
        +
        +
        + ) +} + +/** ?evidence=1:只摆证据演示,360px 宽度的检查和截图不被别的演示撑宽 */ +function EvidenceOnly() { + const [theme, setTheme] = useState<'dark' | 'light'>(() => { + const t = new URLSearchParams(location.search).get('theme') === 'light' ? 'light' : 'dark' + document.documentElement.setAttribute('data-theme', t) + return t + }) + const flip = (t: 'dark' | 'light') => { setTheme(t); document.documentElement.setAttribute('data-theme', t) } + return ( +
        +
        +
        +

        可点击证据

        +
        + + +
        +
        + +
        +
        长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠
        + +
        + {/* + 成果字段按工件 id 取文档。check-evidence 把这几件工件伪造成:正文这份(能画)、 + 另一次运行写的(run_id 不同)、没记正文的(缺 markdown)——后两种都得退回普通文本 + */} +
        + {[['fx-field-ok', '正文这份'], ['fx-field-other-run', '另一次运行写的'], ['fx-field-no-markdown', '没记正文']].map(([id, name]) => ( +
        +
        成果字段 · {name}
        + +
        + ))} +
        + {/* + 没有 _evidence 的旧回答:同一批文字用现在的 Markdown 渲染,check-evidence 拿它和 + Markdown.tsx 改动前(HEAD)渲染出来的 outerHTML(markdown-pinned.json)逐字比对 + */} +
        +
        旧回答的 Markdown:和改动前逐字比对
        + {(mdPinned.samples as { id: string; text: string; dense: boolean; marks?: any[] }[]).map((p) => ( +
        + +
        + ))} +
        +
        +
        + ) +} + function Harness() { const [theme, setTheme] = useState<'dark' | 'light'>(() => { // 没有显式主题时会跟随系统;预览要确定是哪一套,开场就钉住 @@ -403,6 +511,10 @@ function Harness() { + + + + setDatasourceConsumers(n), // 检查脚本直接拿应用同一份模块实例测 lib:instanceof ApiError 才靠得住 - lib: { format, status, keys, terms, errors, explain, health, actor, validation }, + lib: { format, status, keys, terms, errors, explain, health, actor, validation, evidence }, + evidenceFixture, } createRoot(document.getElementById('root')!).render( - + {new URLSearchParams(location.search).get('evidence') === '1' ? : } , ) diff --git a/frontend/src/index.css b/frontend/src/index.css index 35f1ec1b..78df8d47 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -81,6 +81,7 @@ --nt-human: #ce91e6; --nt-validate: #ebb1fd; --nt-metrics: #cbaddb; + --nt-report: #a9b0ec; /* 检索通路的分类色:只说「这一分是哪一路检索贡献的」,不表示状态,所以 不借 accent / ok / warn。两色按 dataviz 校验过:色觉异常下也分得开, @@ -138,6 +139,7 @@ --nt-human: #6f3d81; --nt-validate: #885398; --nt-metrics: #6e557b; + --nt-report: #4f5694; --via-semantic: #0a7ea4; --via-keyword: #c2447a; } @@ -193,6 +195,7 @@ --nt-human: #6f3d81; --nt-validate: #885398; --nt-metrics: #6e557b; + --nt-report: #4f5694; --via-semantic: #0a7ea4; --via-keyword: #c2447a; } @@ -399,6 +402,7 @@ .nt-transform { --nt: var(--nt-transform); } .nt-subgraph { --nt: var(--nt-subgraph); } .nt-metrics { --nt: var(--nt-metrics); } +.nt-report { --nt: var(--nt-report); } /* -------------------------------------------------------------------------- React Flow 覆盖 @@ -1562,6 +1566,55 @@ animation: sheet-in var(--dur-base) var(--ease-out); } +/* -------------------------------------------------------------------------- + 可点击证据(run/EvidenceDoc)。报告里有出处、没出处的片段是原生 button,外观 + 和正文一样;状态靠下划线的线型和颜色,外观数据在 lib/evidence.ts(颜色、淡底 + 由组件写成 --ev-line / --ev-soft),这里只管线型和交互态。 + 不画整句下划线:一份报告满屏都是线就什么都看不出来了。 + -------------------------------------------------------------------------- */ +.ev-seg { + display: inline; + margin: 0; + padding: 0; + border: 0; + border-radius: 2px; + background: none; + font: inherit; + color: inherit; + letter-spacing: inherit; + line-height: inherit; + text-align: inherit; + white-space: inherit; + cursor: pointer; + text-decoration-line: underline; + text-decoration-color: var(--ev-line, currentColor); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +.ev-seg[data-ev-line="solid"] { text-decoration-style: solid; } +/* 点状线比实线细碎,同样 1px 在小字号下几乎看不见 */ +.ev-seg[data-ev-line="dotted"] { text-decoration-style: dotted; text-decoration-thickness: 1.5px; } +.ev-seg:hover, +.ev-seg[aria-expanded="true"] { background: var(--ev-soft, transparent); } +.ev-seg:focus-visible, +.ev-seg[data-flash] { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +/* 句末的「无证据」小标签:字形加文字,去掉颜色也读得出 */ +.ev-tag { + margin: 0 3px; + font-size: 10px; + line-height: 1; + white-space: nowrap; + vertical-align: 1px; + color: var(--st-waiting); +} +.ev-panel-side { animation: sheet-in var(--dur-base) var(--ease-out); } +.ev-panel-rise { animation: fade-up var(--dur-base) var(--ease-out); } + /* 例句卡片依次入场。--i 是序号,错开 40ms 就够读出"一条条出现", 再多就开始等 */ @keyframes rise-in { @@ -1649,6 +1702,11 @@ .nc-tenth { display: none; } + /* 证据面板直接出现,不滑入:滑入停在 0.01ms 的最后一帧虽然看不出来,但它仍然是一段动画 */ + .ev-panel-side, + .ev-panel-rise { + animation: none !important; + } } /* -------------------------------------------------------------------------- diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts new file mode 100644 index 00000000..9ab43f9d --- /dev/null +++ b/frontend/src/lib/evidence.ts @@ -0,0 +1,428 @@ +/** + * 可点击证据:片段状态的外观,和几件证据相关的纯函数。 + * + * 和 lib/status 一个路子,每种状态同时有四条通道:线型、字形、颜色、文字。去掉颜色 + * 还能靠线型和字形分(灰度打印、色弱),去掉线型还能读字(读屏的 aria-label 就是文字 + * 那一条)。颜色只用 --st-* 令牌:概率性的「有依据」借 --st-running(强调色), + * 「未裁判」借 --st-cancelled(暗字色),「猜测」借 --st-idle(最淡的字色)—— + * 绝不用确定性的绿,模型的判断不能长得像系统核对过的事实。 + * + * 本期(数字层)实际只会出现两种:确定性(引用解析成功)和无证据(裸数字、引用 + * 不存在)。其余几种先把外观定下,后续期接上时不用再各处补。 + */ + +import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, evidenceTally } from './terms' +import type { + EvidenceBlock, EvidenceDocData, EvidenceFieldRef, EvidenceGraph, EvidenceSeal, EvidenceSegment, + EvidenceSegmentDetail, EvidenceStep, EvidenceUnit, EvidenceViolation, ReviewResult, +} from '../types' +import type { MarkSpec } from '../run/Markdown' + +export type EvidenceStateCode = keyof typeof EVIDENCE_STATE_LABEL + +/** + * 正文里的线型。solid / dotted 画在片段下面;badge 是句末的小徽标——结论句的状态 + * 不画整句下划线,否则一份报告满屏都是线;none 不装饰 + */ +export type EvidenceLine = 'solid' | 'dotted' | 'badge' | 'none' + +export interface EvidenceStateMeta { + code: EvidenceStateCode + /** 文字:aria-label、面板标题、图例 */ + label: string + line: EvidenceLine + /** 字形。'' 表示不画——确定性的数字就是正文,不另加记号 */ + glyph: string + /** 前景色(字形、面板标题),只用 --st-* */ + color: string + /** 下划线颜色 */ + decoration: string + /** 淡底:悬停、打开面板时那一段的底色 */ + soft: string + /** 异常态:正常态安静,只有它们该醒目、该进 n / N 的跳转 */ + alert: boolean + /** 从哪一期开始会出现 */ + phase: 1 | 3 | 4 + /** 一句话说明,放 title 和面板 */ + hint: string +} + +const mix = (token: string, pct: number) => `color-mix(in srgb, var(${token}) ${pct}%, transparent)` + +export const EVIDENCE_STATE: Record = { + deterministic: { + code: 'deterministic', label: EVIDENCE_STATE_LABEL.deterministic, line: 'solid', glyph: '', + color: 'var(--st-done)', decoration: mix('--st-done', 60), soft: 'var(--st-done-soft)', + alert: false, phase: 1, + hint: '数字由系统从口径卡取值、按口径卡的格式渲染,不是模型写的', + }, + supported: { + code: 'supported', label: EVIDENCE_STATE_LABEL.supported, line: 'badge', glyph: '◆', + color: 'var(--st-running)', decoration: 'transparent', soft: 'var(--st-running-soft)', + alert: false, phase: 4, + hint: '裁判模型认为引用的证据支持这句话。这是模型的判断,不是系统核对', + }, + partial: { + code: 'partial', label: EVIDENCE_STATE_LABEL.partial, line: 'badge', glyph: '◇', + color: 'var(--st-waiting)', decoration: 'transparent', soft: 'var(--st-waiting-soft)', + alert: true, phase: 4, + hint: '裁判模型认为证据只支持这句话的一部分', + }, + unsupported: { + code: 'unsupported', label: EVIDENCE_STATE_LABEL.unsupported, line: 'badge', glyph: '!', + color: 'var(--st-failed)', decoration: 'transparent', soft: 'var(--st-failed-soft)', + alert: true, phase: 4, + hint: '裁判模型认为引用的证据不支持这句话', + }, + unjudged: { + code: 'unjudged', label: EVIDENCE_STATE_LABEL.unjudged, line: 'badge', glyph: '?', + color: 'var(--st-cancelled)', decoration: 'transparent', soft: 'var(--st-cancelled-soft)', + alert: false, phase: 4, + hint: '这句话带了引用,还没请模型判断引用支不支持它', + }, + none: { + code: 'none', label: EVIDENCE_STATE_LABEL.none, line: 'dotted', glyph: '?', + color: 'var(--st-waiting)', decoration: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', + alert: true, phase: 1, + hint: '没写成引用标记的数字,或者引用解析不了:系统核对不到它从哪来', + }, + connective: { + code: 'connective', label: EVIDENCE_STATE_LABEL.connective, line: 'none', glyph: '', + color: 'var(--st-idle)', decoration: 'transparent', soft: 'var(--st-idle-soft)', + alert: false, phase: 4, + hint: '过渡、组织结构的话,不陈述数据事实,所以不需要证据', + }, + candidate: { + code: 'candidate', label: EVIDENCE_STATE_LABEL.candidate, line: 'dotted', glyph: '~', + color: 'var(--st-idle)', decoration: mix('--st-idle', 70), soft: 'var(--st-idle-soft)', + alert: false, phase: 3, + hint: '旧运行按数值猜的可能来源,不能当证据', + }, +} + +/** 图例、检查脚本按这个顺序列 */ +export const EVIDENCE_STATES = Object.keys(EVIDENCE_STATE) as EvidenceStateCode[] + +/** + * 片段在正文里的状态。文字、结构片段(行首符号、表格竖线)是 null:不画线、不进键盘顺序。 + * 后端的 probabilistic 在裁判给出结论之前按「未裁判」画 + */ +export function segmentState(seg: Pick): EvidenceStateCode | null { + if (seg.kind === 'structural') return null + switch (seg.state) { + case 'deterministic': return 'deterministic' + case 'none': return 'none' + case 'probabilistic': return 'unjudged' + case 'candidate': return 'candidate' + default: return null + } +} + +/** 一个句子(单元格、列表项)的正文:结构片段不算 */ +export function unitText(unit: Pick): string { + return (unit.segments ?? []).filter((s) => s.kind !== 'structural').map((s) => s.text).join('') +} + +/** 片段的来源说一句:「口径卡指标 环比增幅」「运行输入 week」 */ +export function sourceOf(seg: EvidenceSegment, doc?: Pick | null): string { + const cite = seg.cite + if (!cite) return '' + const entry = cite.alias ? doc?.catalog?.[cite.alias] : undefined + if (cite.kind === 'metric' || entry?.kind === 'metric') { + const name = entry?.name ?? cite.locator?.metric ?? cite.ref ?? '' + return name ? `口径卡指标 ${name}` : '口径卡指标' + } + if (cite.kind === 'input' || entry?.kind === 'input') { + return `运行输入 ${entry?.locator?.field ?? cite.locator?.field ?? cite.ref ?? ''}`.trim() + } + return cite.alias ?? cite.ref ?? '' +} + +/** 无证据的原因:裸数字,还是引用解析不了(带后端给的人话原因) */ +export function reasonOf(seg: EvidenceSegment): string { + if (seg.issue === 'uncited_number' || (!seg.cite && seg.kind === 'number')) return EVIDENCE_TEXT.uncited + return seg.cite?.reason ?? (seg.ref ? `引用 ${seg.ref} 解析不了` : '') +} + +/** + * 片段的 aria-label:字、状态、出处都写全。「8.7%,有出处:口径卡指标 环比增幅」 + * 「12,无证据:这个数字没有写成引用标记…」。读屏用户靠它,不靠颜色和线型 + */ +export function segmentLabel(seg: EvidenceSegment, doc?: Pick | null): string { + const state = segmentState(seg) + if (!state) return seg.text + const why = state === 'none' ? reasonOf(seg) : sourceOf(seg, doc) + return `${seg.text},${EVIDENCE_STATE[state].label}${why ? `:${why}` : ''}` +} + +/** 在正文里画得出线的违规:指向的片段存在,而且不是结构片段 */ +export function locatable(doc: EvidenceDocData, v: EvidenceViolation): EvidenceSegment | null { + if (!v.segment) return null + for (const block of doc.blocks ?? []) { + for (const unit of block.units ?? []) { + for (const seg of unit.segments ?? []) { + if (seg.id === v.segment) return seg.kind === 'structural' ? null : seg + } + } + } + return null +} + +export interface EvidenceTally { + /** 数字总数:带引用的数字片段 + 裸数字(含列表序号、代码块标签里的) */ + total: number + cited: number + /** 没有出处的数字:总数 − 有出处(裸数字、解析不了的数字引用)。横幅上「无证据 M」的 M */ + none: number + /** 不是数字、解析不了的引用:[[v:]] 值、句末 [[see:]] 依据。另起一句说,不混进 none */ + other: number + /** 画不了线、只能在清单里找的:structural + noSegment */ + hidden: number + /** 其中在结构片段里的(列表序号、代码块标签里的数字):违规指着片段,片段是 Markdown 语法 */ + structural: number + /** 其中在正文里没有对应字的(句末依据里写错的引用):违规不指着任何片段 */ + noSegment: number +} + +/** 缺陷类违规:完整性问题(render_mismatch 这些)不在这里数,它们说的是文档本身靠不住 */ +const GAP_CODES = new Set(['uncited_number', 'unresolved_ref']) + +const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) + +/** + * 文档的计数。stats 是后端 verify_doc 算的,有就用它;老文档、别的版本缺字段时 + * 从片段自己数,数出来的和 stats 是同一个口径。 + * + * 数字那半句要算得平:none = total − cited。不是数字的解析不了的引用(other)按违规 + * 清单逐条认(指着的片段不是数字,或者根本不指片段);没有清单时用 stats 反推 + * (uncited + unresolved − none),再没有就从片段的状态数 + */ +export function docTally(doc: EvidenceDocData): EvidenceTally { + const segs = segmentsOf(doc.blocks ?? []) + const byId = new Map(segs.map((s) => [s.id, s])) + const all = doc.violations ?? [] + const gaps = all.filter((v) => GAP_CODES.has(v.code)) + const hidden = gaps.filter((v) => !locatable(doc, v)) + const structural = hidden.filter((v) => !!v.segment).length + const st = doc.stats ?? {} + const cited = num(st.numbers_cited) ?? segs.filter((s) => s.kind === 'number' && s.state === 'deterministic').length + const total = num(st.numbers) + ?? segs.filter((s) => s.kind === 'number').length + hidden.filter((v) => v.code === 'uncited_number').length + const none = Math.max(0, total - cited) + const uncited = num(st.uncited_numbers) + const unresolved = num(st.unresolved) + const other = all.length + ? gaps.filter((v) => v.code === 'unresolved_ref' && byId.get(v.segment ?? '')?.kind !== 'number').length + : uncited != null && unresolved != null + ? Math.max(0, uncited + unresolved - none) + : segs.filter((s) => segmentState(s) === 'none' && s.kind !== 'number').length + return { total, cited, none, other, hidden: hidden.length, structural, noSegment: hidden.length - structural } +} + +/** + * 只有 stats 的时候(report.checked 事件的载荷,没有文档):同一种算法。other 用 + * uncited + unresolved − none 反推——数字引用渲染对不上(render_mismatch,文档本身 + * 靠不住的那类)时会少数几处,这类文档另有完整性违规兜着 + */ +export function statsTally(stats: EvidenceDocData['stats'] | null | undefined): + Pick | null { + const total = num(stats?.numbers) + const cited = num(stats?.numbers_cited) + if (total == null || cited == null) return null + const none = Math.max(0, total - cited) + const other = Math.max(0, (num(stats?.uncited_numbers) ?? 0) + (num(stats?.unresolved) ?? 0) - none) + return { total, cited, none, other } +} + +/** 读屏摘要:计数、画不了线的两种各在哪、按键说明 */ +export function tallySummary(t: EvidenceTally, keys: boolean): string { + return [ + evidenceTally(t.cited, t.total, t.other), + t.structural ? EVIDENCE_TEXT.structuralCount(t.structural) : '', + t.noSegment ? EVIDENCE_TEXT.noSegmentCount(t.noSegment) : '', + keys ? EVIDENCE_TEXT.keysHint : '', + ].filter(Boolean).join('。') +} + +/** + * 证据接口答的是不是正文这份报告。 + * + * 正文是按成果上 _evidence.doc_artifact 取的——成果是可以改写的一列,工件表可以事后插行, + * 都不可信;片段接口只从封存范围内的 report.checked 找文档。两边不是同一份时,接口给的 + * 封存状态和证据链说的是另一份报告,挂到正文上就成了「已封存 · 核对一致」地证明一段 + * 别的字。对得上的条件:接口报的文档工件 id、报告节点、片段 id、这个位置的字都和正文一致 + * (接口没给的那一项不比)。 + */ +export function docForeign( + detail: EvidenceSegmentDetail | undefined, + mine: { artifact?: string; node?: string; seg: Pick }, +): { sealedText?: string } | null { + if (!detail) return null + const theirs = detail.report?.doc_artifact + const node = detail.report?.node_id + const text = detail.segment?.text + const id = detail.segment?.id + const differs = (a: unknown, b: unknown) => typeof a === 'string' && !!a && typeof b === 'string' && !!b && a !== b + const textOff = typeof text === 'string' && text !== mine.seg.text + if (differs(theirs, mine.artifact) || differs(node, mine.node) || differs(id, mine.seg.id) || textOff) { + return textOff ? { sealedText: text } : {} + } + return null +} + +/** + * 证据图(片段接口取不到时的兜底)里有没有正文这份报告。有 reports 列表、却找不到这个 + * 工件:正文不是封存的那一份。找到了但哈希对不上:文档被改过。不知道正文的工件 id、 + * 证据图没有列表时不下结论 + */ +export function graphDoc( + graph: EvidenceGraph | undefined, artifact: string | undefined, +): 'ok' | 'foreign' | 'tampered' | null { + if (!graph || !artifact || !Array.isArray(graph.reports)) return null + const hit = graph.reports.find((r) => r?.doc_artifact === artifact) + if (!hit) return graph.reports.length ? 'foreign' : null + return hit.hash_ok === false ? 'tampered' : 'ok' +} + +export type SealStatus = 'idle' | 'waiting' | 'failed' | 'done' + +/** + * 封存那一行说什么。顺序有讲究:后端的 covered = 封存核对通过 && 每步都在台账里, + * 所以核对失败(sealed 且 ok=false)的时候 covered 一定也是 false——先看 covered 的话, + * 被改过的封存永远显示成琥珀色的「不在封存范围内」,红色的「核对不一致」一次都画不出来。 + * + * - foreign:正文不是封存的那份报告,封存链再完好也证明不了屏幕上的字 → 失败 + * - 没拿到封存状态 → 灰(正在取 / 没有运行 / 拿不到) + * - 没封存 → 灰「尚未封存」 + * - 封存核对没通过 → 红「核对不一致」 + * - 这件证据不在封存范围内 → 琥珀 + * - 都过了 → 绿;没有证据的片段落到文档上(docOnly),链没取到的落到「只核对了文档」(viaGraph) + */ +export function sealVerdict(seal: EvidenceSeal | undefined, opts: { + foreign?: boolean; pending?: boolean; noRun?: boolean; docOnly?: boolean; viaGraph?: boolean +} = {}): { status: SealStatus; label: string } { + if (opts.foreign) return { status: 'failed', label: EVIDENCE_TEXT.sealForeign } + if (!seal) { + return { status: 'idle', label: opts.pending ? EVIDENCE_TEXT.chainPending : opts.noRun ? EVIDENCE_TEXT.noRun + : EVIDENCE_TEXT.sealUnknown } + } + if (!seal.sealed) return { status: 'idle', label: EVIDENCE_TEXT.sealOpen } + if (seal.ok === false) return { status: 'failed', label: EVIDENCE_TEXT.sealBad } + if (seal.covered === false) return { status: 'waiting', label: EVIDENCE_TEXT.sealOutside } + return { status: 'done', label: opts.docOnly ? EVIDENCE_TEXT.sealDocOk + : opts.viaGraph ? EVIDENCE_TEXT.sealDocChain : EVIDENCE_TEXT.sealOk } +} + +/** + * 证据链一步的逐项复核里,哪几项明确没过。「在不在封存范围内」只在封存完好时单独说: + * 没封存、封存核对失败时后端把每一步的 sealed 都记成 false,那是封存那一行已经说清的 + * 状态,再报一条「事后补进来的」就是冤枉它 + */ +export function integrityFailures( + step: Pick | undefined, + seal: EvidenceSeal | undefined, +): ('eid' | 'hash' | 'render' | 'sealed')[] { + if (!step) return [] + const sealTrusted = !!seal?.sealed && seal.ok !== false + return ([ + ['eid', step.eid_ok], ['hash', step.hash_ok], ['render', step.render_ok], + ...(sealTrusted ? [['sealed', step.sealed] as const] : []), + ] as const).filter(([, ok]) => ok === false).map(([k]) => k) +} + +/** + * 成果上的证据标注:字段名 → 它逐字等于的那份报告。最外层是契约 report_from 指着的那份, + * others 里是别的报告。缺了取文档要的 doc_artifact、没列字段的条目当没有 + */ +export function evidenceFields( + output: Record | null | undefined, +): Map { + const out = new Map() + const ev = output?._evidence + if (!ev || typeof ev !== 'object') return out + for (const r of [ev, ...(Array.isArray(ev.others) ? ev.others : [])] as EvidenceFieldRef[]) { + if (!r || typeof r.doc_artifact !== 'string' || !r.doc_artifact || !Array.isArray(r.fields)) continue + for (const f of r.fields) { + if (!out.has(String(f))) out.set(String(f), { report: r.report_node, artifact: r.doc_artifact }) + } + } + return out +} + +/** 成果带不带逐段证据 */ +export const hasEvidence = (output: Record | null | undefined): boolean => + evidenceFields(output).size > 0 + +/** + * 成果带逐段证据时,复核只能加说明、不能改写。改写会把整个成果换成 {answer: 改写后的 + * 文本},报告文档的每个片段就都对不上了——而证据正是这个答案最值钱的部分。后端 + * review.py 也拦,这里再拦一道:老后端、别的入口回来的改写都不能落到界面上 + */ +export function guardReview( + result: ReviewResult | null, output: Record | null | undefined, +): ReviewResult | null { + if (!result?.answer || !hasEvidence(output)) return result + return { ...result, answer: null, original: null, verdict: result.verdict === 'rewritten' ? 'annotated' : result.verdict } +} + +/** + * 旧契约(按数值回指)里一条 matched 的出处。出处不唯一的(metric 为 null、带 + * candidates)照实写「出处不唯一:候选 a、b」,不能再写成「来自口径卡指标「」」 + */ +export function matchedSource(m: any): { text: string; ambiguous: boolean } { + const candidates: string[] = Array.isArray(m?.candidates) ? m.candidates.map(String).filter(Boolean) : [] + if (m?.ambiguous || (m?.metric == null && candidates.length)) { + return { text: EVIDENCE_TEXT.ambiguous(candidates.length ? candidates : ['—']), ambiguous: true } + } + return { text: String(m?.metric ?? '').trim(), ambiguous: false } +} + +/** + * 旧契约(按数值回指)在正文上画的标记:回指不上的、回指上的、出处不唯一的。 + * 带 start / end 的只标那一处(Markdown 按位置核对,对不上再退回按字符串标) + */ +export function issuanceMarks(issuance: any): MarkSpec[] | undefined { + const unmatched: any[] = Array.isArray(issuance?.unmatched_numbers) ? issuance.unmatched_numbers : [] + const matched: any[] = Array.isArray(issuance?.matched) ? issuance.matched : [] + const where = (x: any) => (typeof x?.start === 'number' && typeof x?.end === 'number' + ? { start: x.start, end: x.end } : {}) + const marks: MarkSpec[] = [ + ...unmatched.map((u) => ({ + token: String(u?.token ?? u), + tone: 'warn' as const, + title: `这个数字在口径卡里找不到来源${u?.context ? `:「${u.context}」` : ''}`, + ...where(u), + })), + ...matched.map((m) => { + const src = matchedSource(m) + return { + token: String(m?.token ?? ''), + tone: src.ambiguous ? 'ambiguous' as const : 'ok' as const, + title: src.ambiguous ? src.text : `来自口径卡指标「${src.text}」${m?.caliber ? ` · ${m.caliber}` : ''}`, + ...where(m), + } + }), + ].filter((m) => m.token) + return marks.length ? marks : undefined +} + +/** + * 码点偏移 → UTF-16 下标。后端的偏移是 Python 的 str 下标(按码点),JS 的字符串下标 + * 按 UTF-16:遇到 BMP 之外的字符(emoji)两边就差一位。没有这类字符时原样返回 + */ +export function codePointIndex(text: string): (cp: number) => number { + if (!/[\uD800-\uDBFF]/.test(text)) return (cp) => cp + const at: number[] = [] + let i = 0 + for (const ch of text) { + at.push(i) + i += ch.length + } + at.push(i) + return (cp) => (cp >= 0 && cp < at.length ? at[cp] : -1) +} + +/** 文档里的全部片段,按正文顺序 */ +export function segmentsOf(blocks: EvidenceBlock[]): EvidenceSegment[] { + return blocks.flatMap((b) => (b.units ?? []).flatMap((u) => u.segments ?? [])) +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8bb5f3e0..9b659706 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -18,6 +18,7 @@ */ import type { NodeType } from '../types' +import { formatNumber } from './format' export const TERMS = { workflow: '工作流', @@ -108,6 +109,7 @@ export const NODE_TYPE_LABEL: Record = { human: '人工审批', validate: '结构校验', metrics: '口径卡', + report: '报告撰写', } /** 与 decode.ts 里旧名字同名的出口,第二波改成从这里导入时不用改调用处 */ @@ -125,3 +127,107 @@ export const APPROVAL_POLICY_LABEL: Record<'dangerous' | 'always' | 'never', str always: '每次调用都审批', never: '全部自动放行', } + +/** + * 可点击证据:片段状态的叫法。正文里片段的 aria-label、面板标题、图例都用这一份。 + * 外观(线型、字形、颜色)在 lib/evidence.ts,和这里一一对应。 + * + * 本期(数字层)实际只会出现「有出处」和「无证据」;其余几种是后续期的,先把叫法定下, + * 免得到时候各处各起一个名字。 + */ +export const EVIDENCE_STATE_LABEL = { + deterministic: '有出处', + supported: '模型判断:有依据', + partial: '模型判断:部分有依据', + unsupported: '模型判断:证据不支持', + unjudged: '有引用,未裁判', + none: '无证据', + connective: '连接性文字', + candidate: '猜测的来源', +} as const + +/** + * 「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」:出具横幅、报告核对那一行、证据条、 + * 读屏摘要共用一种说法。 + * + * 前半句只数数字,要让人一眼算得平:无证据 = 总数 − 有出处(12 − 7 = 5)。不是数字的 + * 引用([[v:]] 值、句末 [[see:]] 依据)解析不了的另起一句——以前把它们也算进「无证据」, + * 12 − 7 却写成 6,读的人以为算错了。一个数字都没有、也没有别的问题时返回空串,调用方 + * 自己决定说什么 + */ +export function evidenceTally(cited: number, total: number, other = 0): string { + const gaps = Math.max(0, total - cited) + const parts: string[] = [] + if (total > 0) { + parts.push(gaps + ? `${formatNumber(cited)}/${formatNumber(total)} 数字有出处 · 无证据 ${formatNumber(gaps)}` + : `${formatNumber(total)} 个数字都有出处`) + } + if (other > 0) parts.push(`${total > 0 ? '另有 ' : ''}${formatNumber(other)} 处引用解析不了`) + return parts.join(' · ') +} + +/** 证据面板和证据条的文案 */ +export const EVIDENCE_TEXT = { + allCited: (n: number) => `${formatNumber(n)} 个数字都有出处`, + locateNext: '定位下一处', + /** 读屏摘要末尾的操作说明 */ + keysHint: '用左右方向键逐个查看,上下键按句子走,n 跳到下一处无证据,回车打开证据,Esc 关闭', + panelTitle: '证据', + close: '关闭证据', + back: '回到正文', + sentence: '所在的句子', + metric: '口径卡指标', + expression: '原式', + substituted: '代入式', + inputs: '输入', + recompute: '复算', + recomputeOk: '复算一致', + recomputeBad: '复算不一致:代入式算出来和记录的值对不上', + recomputeNone: '没法复算', + input: '运行输入', + seal: '封存', + sealOk: '已封存 · 核对一致', + sealDocOk: '报告文档已封存 · 核对一致(这个数字本身没有证据可封存)', + /** 片段接口取不到、封存状态是从整次运行的证据图查的:只核对了文档在封存范围内,这个数的链没逐项核对 */ + sealDocChain: '报告文档已封存 · 核对一致(这个数字的证据链这次没取到,没有逐项核对)', + sealBad: '已封存 · 核对不一致', + sealOpen: '尚未封存', + sealOutside: '不在封存范围内', + /** 正文这份报告不是封存的那一份:封存链再完好,也证明不了屏幕上这些字 */ + sealForeign: '正文不是封存的那份报告,封存证明不了这里的字', + sealUnknown: '封存状态拿不到', + violations: '违规清单', + violationsHint: '报告撰写节点核对出来的全部问题。列表序号、代码块标签里的数字在正文里画不了线,只能在这里看', + structural: '这个数字在列表序号、代码块标签这类 Markdown 语法里,正文里画不了线', + noSegment: '这一条在正文里没有对应的字,比如句末依据里写错的引用', + /** 读屏摘要:画不了线的两种,分开说在哪(和违规清单里每条的 structural / noSegment 同一个意思) */ + structuralCount: (n: number) => `其中 ${formatNumber(n)} 个数字在列表序号、代码块标签这类 Markdown 语法里,正文里画不了线,只在违规清单里列出`, + noSegmentCount: (n: number) => `${formatNumber(n)} 处在正文里没有对应的字(比如句末依据里写错的引用),只在违规清单里列出`, + /** 出具横幅:回指不上的数字。有报告文档时点状线只画得出一部分(列表序号里的画不了),不能说「已用虚线标出」 */ + unmatchedInDoc: '无法回指的数字(逐条见报告的违规清单):', + unmatchedPlain: '无法回指的数字:', + uncited: '这个数字没有写成引用标记,系统核对不到它从哪来', + missingValue: '缺输入:口径卡这次没有算出这个指标的值', + unshowable: (value: string) => `有值(${value}),但按口径卡的格式显示不出来`, + chainPending: '正在取算式和输入…', + /** 证据接口逐项复核出来的问题。正常时不说,出问题才醒目 */ + integrity: { + eid: '证据标识对不上:目录里记的和按工件重算的不一样', + hash: '口径卡工件取回时哈希对不上,或者卡里找不到这个指标', + render: '按口径卡重新渲染出来的字和报告上的不一样', + sealed: '这件证据不在封存范围内:事后补进来的,不能当证据', + /** 证据接口答的是封存范围内那份报告,和正文这份不是同一份(工件 id 或这个位置的字对不上) */ + doc: '正文这份报告不是封存范围内的那一份:下面的出处是正文自己记的,没有经过封存核对', + /** 证据图里正文这份报告的哈希对不上 */ + docHash: '正文这份报告和它的哈希对不上,疑似被改过', + sealedText: (text: string) => `封存的那一份在这个位置写的是「${text}」`, + }, + chainMissing: '算式、代入式和输入要从证据接口取,这一次没取到', + chainForeign: '证据接口给的是封存的那份报告的算式,和正文这份对不上,不在这里展示', + noRun: '这份报告不属于某次运行,只能看文档里记下的出处', + docMissing: '证据文档取不到,按普通文本显示', + docMismatch: '成果字段和报告文档对不上,按普通文本显示', + docOtherRun: '证据文档不是这次运行写的,按普通文本显示', + ambiguous: (candidates: string[]) => `出处不唯一:候选 ${candidates.join('、')}`, +} as const diff --git a/frontend/src/pages/runs/ArtifactsPane.tsx b/frontend/src/pages/runs/ArtifactsPane.tsx index cd98f0f4..e4bd87bf 100644 --- a/frontend/src/pages/runs/ArtifactsPane.tsx +++ b/frontend/src/pages/runs/ArtifactsPane.tsx @@ -9,7 +9,7 @@ import { ArtifactViewer } from '../../run/AssistantStream' import { topology } from '../../run/derive' import type { Trace } from '../../run/trace' import type { GraphSpec } from '../../types' -import { artifactKindLabel, isEvidence, type RunArtifact } from './model' +import { artifactDescription, artifactKindLabel, isEvidence, type RunArtifact } from './model' // ------------------------------------------------------------------------- // 数据 @@ -244,7 +244,7 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen {kind} - {describe(a.kind)} + {artifactDescription(a.kind, a.meta)} {numbered && #{n}} {attempt > 1 && · 第 {attempt} 次尝试} @@ -269,13 +269,3 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen ) } - -function describe(kind: string): string { - switch (kind) { - case 'query_snapshot': return '查询:SQL 和结果集' - case 'tool_snapshot': return '工具调用:参数和返回' - case 'retrieval_snapshot': return '知识检索:命中的片段' - case 'node_output': return '这一次执行的产出' - default: return '工件' - } -} diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index ff94e7b7..6fb39c7b 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -481,6 +481,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: { fix: explain.fix, fixTo: explain.fixTo, fixFirst: explain.fixFirst, } satisfies TurnFailure : undefined, runClass: run.run_class, + // 成果里的报告点开片段时要按运行号取证据链;不给 runId(详情头上已经有运行号) + outputRun: run.id, statusCode: code, // 轮次头的计时和详情头同一个口径:跑完写墙钟,跑着从航迹的起点实时算。 // 停下来过的(等过审批、失败后接着跑)例外,只数执行的部分:从第一次开始算, diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 25892703..48fa2155 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -326,9 +326,26 @@ export const ARTIFACT_KIND_LABEL: Record = { tool_snapshot: '工具快照', retrieval_snapshot: '检索快照', node_output: '节点产出', + metric_set: '口径卡指标集', + report_doc: '报告文档', } export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind +/** 工件行上的一句说明:这件工件里装的是什么 */ +export function artifactDescription(kind: string, meta?: Record | null): string { + switch (kind) { + case 'query_snapshot': return '查询:SQL 和结果集' + case 'tool_snapshot': return '工具调用:参数和返回' + case 'retrieval_snapshot': return '知识检索:命中的片段' + case 'node_output': return '这一次执行的产出' + // 口径卡每跑一次落一件:哪张卡、哪一版(工件行的 meta 里有 {caliber, version}) + case 'metric_set': return meta?.caliber + ? `口径卡「${meta.caliber}」${meta.version ?? ''}` : '口径卡的指标、算式和输入' + case 'report_doc': return '报告:逐段的片段和每个数字的出处' + default: return '工件' + } +} + /** 证据:查询、工具、检索的快照。节点产出是过程里的中间值,一次循环就是几十件 */ export const isEvidence = (kind: string): boolean => kind !== 'node_output' diff --git a/frontend/src/run/AssistantPanel.tsx b/frontend/src/run/AssistantPanel.tsx index 926efb2e..375a5017 100644 --- a/frontend/src/run/AssistantPanel.tsx +++ b/frontend/src/run/AssistantPanel.tsx @@ -587,8 +587,9 @@ function RunView({ onBack, reveal, onRevealed }: { ? { error: String(failedEvent?.data?.error ?? run.error ?? '运行失败'), ...(failedEvent?.data?.detail ? { detail: String(failedEvent.data.detail) } : {}) } : undefined, - // #runId 已经在栏头上了(可点,去运行记录),卡片里不再重复 + // #runId 已经在栏头上了(可点,去运行记录),卡片里不再重复;成果里的报告取证据链照样要它 runClass: run.run_class, + outputRun: run.id, startedAt: trace.startedAt, // 栏头只在不跑的时候写它;跑着的时候耗时细条按各步自己的耗时比 elapsedMs: phase === 'waiting' || isSettled(phase) ? stoppedActiveMs(run.usage, trace) : undefined, diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index 70817027..555feaeb 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -5,7 +5,7 @@ import { import { Link } from 'react-router-dom' import { AlertCircle, AlertTriangle, ArrowDown, Brain, ChevronRight, CircleCheck, CircleDot, CornerDownRight, - Database, Download, ExternalLink, FileCode, FileDown, GitBranch, Hand, Info, Play, Settings2, + Database, Download, ExternalLink, FileCode, FileDown, GitBranch, Hand, Info, ListChecks, Play, Settings2, ShieldCheck, Sparkles, Table2, Terminal, UserX, Users, Wrench, XCircle, } from 'lucide-react' import clsx from 'clsx' @@ -15,13 +15,16 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format' import { statusLabel } from '../lib/status' -import { issuanceLabel, nodeTypeLabel } from '../lib/terms' +import { EVIDENCE_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' import { childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf, type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx, type TeamRun, } from './decode' import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown' +import { EvidenceField, type EvidenceDocHandle } from './EvidenceDoc' +import { docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence' +import type { EvidenceDocData } from '../types' import { useRunClock } from './useRunClock' import type { ReviewResult } from '../types' @@ -81,6 +84,11 @@ export interface StreamTurn { */ queries?: number runId?: string + /** + * 成果出自哪次运行:成果里的报告点开片段时按它取证据链。不给就用 runId。运行页、画布 + * 右栏的头上已经有运行号、不给 runId(给了会在轮次头再挂一个运行链接),就给它 + */ + outputRun?: string /** formal / exploratory。出具横幅要据此标注"不进正式归档" */ runClass?: string /** 这一轮建出来的图,可展开看、可放到画布 */ @@ -510,7 +518,7 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { {turn.output && ( + question={turn.question} onFollowUp={onFollowUp} runId={turn.outputRun ?? turn.runId} /> )} {turn.rawOutput && ( @@ -519,7 +527,7 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { 复核改写过这个答案,看改写前的原文
        - +
        )} @@ -1897,17 +1905,29 @@ function unwrapArtifact(content: unknown): { table: Table | null; text: string; // 成果 // ------------------------------------------------------------------------- -function Output({ output, runClass, broken, question, onFollowUp }: { +function Output({ output, runClass, broken, question, onFollowUp, runId }: { output: Record; runClass?: string; broken?: boolean question?: string; onFollowUp?: (text: string) => void + /** 这份成果出自哪次运行:带证据的字段点开片段时按它取证据链 */ + runId?: string }) { + const { dense } = useContext(Ctx) // _issuance 这类下划线开头的是内部字段,不是给人看的成果。 // 空值也要滤掉:output 里留一个 {"result": ""} 很常见(图跑通了但出口 // 没接上),它会渲染出一条什么都没有的分隔线——用户只会以为界面坏了 const entries = Object.entries(output) .filter(([k, v]) => !k.startsWith('_') && !isBlank(v)) const issuance = (output as any)._issuance - const marks = useMemo(() => marksOf(issuance), [issuance]) + const marks = useMemo(() => issuanceMarks(issuance), [issuance]) + // 逐字等于报告撰写节点原文的字段:换成可逐段点开的文档,其余照旧。没有 _evidence 的 + // 成果(旧运行、没有报告节点的图)一个字段都不会进这里,渲染和以前完全一样 + const evidence = useMemo(() => evidenceFields(output), [output]) + const firstEvidence = entries.find(([k, v]) => evidence.has(k) && typeof v === 'string')?.[0] + const docRef = useRef(null) + // 第一份报告文档取到之后,出具横幅多一行「N/N 数字有出处 · 无证据 M」 + const [tallyDoc, setTallyDoc] = useState(null) + const tally = useMemo(() => (tallyDoc ? docTally(tallyDoc) : null), [tallyDoc]) + const banner = !!issuance?.tier const follow = useMemo(() => (onFollowUp ? followUpsOf(entries.map(([, v]) => v)) : []), // eslint-disable-next-line react-hooks/exhaustive-deps [output, onFollowUp]) @@ -1915,7 +1935,16 @@ function Output({ output, runClass, broken, question, onFollowUp }: { return (
        - {issuance && } + {issuance && ( + // 成果里有报告字段就告诉横幅:正文换成了报告文档,回指不上的数字不再是 Markdown 上的 + // 虚线(列表序号里的画不了线),而是在报告的违规清单里。文档取到后再给计数和两个动作 + docRef.current?.next() : undefined, + onList: tallyDoc?.violations?.length ? () => docRef.current?.list() : undefined, + } : undefined} /> + )} {broken && ( // 复核判了不可信:答案照常给(可能有参考价值),但先说清楚它不能当结论。 // 不降透明度——那会让它更难读,而不是更不可信 @@ -1936,7 +1965,16 @@ function Output({ output, runClass, broken, question, onFollowUp }: { {entries.length > 1 && (
        {key}
        )} - + {evidence.has(key) && typeof value === 'string' + ? ( + + ) + : }
        ))} @@ -2030,26 +2068,6 @@ function followUpsOf(values: unknown[]): string[] { return [] } -/** 出具里无法回指的数字,在正文原句上画出来 */ -function marksOf(issuance: any): MarkSpec[] | undefined { - const unmatched: any[] = issuance?.unmatched_numbers ?? [] - const matched: any[] = Array.isArray(issuance?.matched) ? issuance.matched : [] - const marks: MarkSpec[] = [ - ...unmatched.map((u) => ({ - token: String(u?.token ?? u), - tone: 'warn' as const, - title: `这个数字在口径卡里找不到来源${u?.context ? `:「${u.context}」` : ''}`, - })), - // 后端给了逐个数字的回指(matched: [{token, metric}])就标出来源;目前只给计数 - ...matched.map((m) => ({ - token: String(m?.token ?? ''), - tone: 'ok' as const, - title: `来自口径卡指标「${m?.metric ?? ''}」${m?.caliber ? ` · ${m.caliber}` : ''}`, - })), - ].filter((m) => m.token) - return marks.length ? marks : undefined -} - const TEXT_CAP = 3000 /** @@ -2158,8 +2176,13 @@ const TIER_META: Record = * 为空」「指标集为空」这类校验根本没跑起来的缺口(gaps)一条都不显示——横幅 * 说「请对照下方声明」,下方什么都没有。 */ -export function IssuanceBanner({ issuance, runClass }: { +export function IssuanceBanner({ issuance, runClass, evidence }: { issuance: any; runClass?: string + /** + * 成果里有报告文档时给:它的计数(文档取到之后才有)、跳到下一处无证据、打开违规清单。 + * 给了它,正文里带证据的字段就不是 Markdown 了,「正文里已用虚线标出」不再成立 + */ + evidence?: { counts?: EvidenceTally; onNext?: () => void; onList?: () => void } }) { const tier = String(issuance?.tier ?? '') if (!tier) return null @@ -2195,6 +2218,20 @@ export function IssuanceBanner({ issuance, runClass }: { {meta.hint &&
        {meta.hint}
        } + {evidence?.counts && (evidence.counts.total > 0 || evidence.counts.other > 0) && ( + // 逐段证据的计数:和报告核对那一行、证据条同一种说法。有无证据的地方才给「定位下一处」 +
        + + {evidenceTally(evidence.counts.cited, evidence.counts.total, evidence.counts.other)} + + {(evidence.counts.none > 0 || evidence.counts.other > 0) && evidence.onNext && ( + + )} +
        + )} {!!gaps.length && (
        @@ -2216,13 +2253,24 @@ export function IssuanceBanner({ issuance, runClass }: { )} {!!unmatched.length && (
        - 无法回指的数字(正文里已用虚线标出): + {/* 带报告文档时正文是 EvidenceDoc:列表序号里的数字画不了线,逐条都在违规清单里; + 文档还没取到(或取不到)时正文是不带标记的普通文本,什么都不许诺 */} + + {!evidence ? '无法回指的数字(正文里已用虚线标出):' + : evidence.onList ? EVIDENCE_TEXT.unmatchedInDoc : EVIDENCE_TEXT.unmatchedPlain} + {unmatched.map((u: any, i: number) => ( {u?.token ?? String(u)} {u?.context && ({String(u.context).slice(0, 24)})} ))} + {evidence?.onList && ( + + )}
        )} {!!calibers.length && ( diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx new file mode 100644 index 00000000..04dbd8a6 --- /dev/null +++ b/frontend/src/run/EvidenceDoc.tsx @@ -0,0 +1,573 @@ +import { + Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, + useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, +} from 'react' +import { createPortal } from 'react-dom' +import { ListChecks } from 'lucide-react' +import clsx from 'clsx' +import { rovingTarget } from '../components/ui' +import { + EVIDENCE_STATE, docTally, segmentLabel, segmentState, tallySummary, unitText, type EvidenceStateCode, + type EvidenceTally as Tally, +} from '../lib/evidence' +import { formatNumber } from '../lib/format' +import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, evidenceTally } from '../lib/terms' +import { useEvidence } from '../store/evidence' +import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit } from '../types' +import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel' +import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown' + +/** + * 报告撰写节点的文档:逐段可点的证据。 + * + * 按后端切好的块、句、片段画,不再解析 Markdown:片段的边界、每个数字的出处都是后端 + * 算定、进了封存的,前端重新解析一遍只会切出另一套边界。块的外观借 Markdown.tsx 的 + * BlockView,和普通答案长得一模一样;有出处的数字画细实线,没有出处的画点状线、 + * 句末挂「无证据」。 + * + * 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段间走,↑/↓ 按 + * 句子走,n / N 跳到下一处 / 上一处无证据,回车打开证据面板,Esc 关掉并留在原片段; + * 面板开着时方向键走到哪、面板跟到哪,Tab 进面板。 + * + * 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加 + * content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。 + */ + +export interface EvidenceDocHandle { + /** 跳到下一处(dir=-1 上一处)无证据并打开面板。一处都画不出来时打开违规清单;什么都没有返回 false */ + next: (dir?: 1 | -1) => boolean + /** 打开违规清单。没有违规返回 false */ + list: () => boolean +} + +/** 和 AssistantStream 的长文本折叠同一个量级:先显示这么多字,后面的折起来 */ +const TEXT_CAP = 3000 + +interface Model { + /** 可点的片段,按正文顺序 */ + order: string[] + index: Map + seg: Map + /** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */ + unitFirst: string[] + unitOf: Map + /** 无证据的片段,按正文顺序(n / N 用) */ + alerts: string[] + /** 每块到它为止的累计字数(折叠用) */ + chars: number[] +} + +function buildModel(blocks: EvidenceBlock[]): Model { + const order: string[] = [] + const index = new Map() + const seg = new Map() + const unitFirst: string[] = [] + const unitOf = new Map() + const alerts: string[] = [] + const chars: number[] = [] + let total = 0 + blocks.forEach((block, b) => { + for (const unit of block.units ?? []) { + let first = true + for (const s of unit.segments ?? []) { + if (s.kind !== 'structural') total += s.text.length + const state = segmentState(s) + if (!state) continue + seg.set(s.id, { seg: s, unit, block: b }) + index.set(s.id, order.length) + order.push(s.id) + if (first) { unitFirst.push(s.id); first = false } + unitOf.set(s.id, unitFirst.length - 1) + if (EVIDENCE_STATE[state].alert) alerts.push(s.id) + } + } + chars.push(total) + }) + return { order, index, seg, unitFirst, unitOf, alerts, chars } +} + +/** 片段的外观:线型走 data 属性(CSS 里只管线型),颜色写成两个变量交给 index.css */ +function segStyle(state: EvidenceStateCode): CSSProperties { + const meta = EVIDENCE_STATE[state] + return { '--ev-line': meta.decoration, '--ev-soft': meta.soft } as CSSProperties +} + +export const EvidenceDoc = forwardRef(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', tally = false }, ref) { + const blocks = useMemo(() => doc.blocks ?? [], [doc]) + const model = useMemo(() => buildModel(blocks), [blocks]) + const counts = useMemo(() => docTally(doc), [doc]) + const rootRef = useRef(null) + const instance = useId() + const panelId = `${instance}-panel` + const summaryId = `${instance}-summary` + const [current, setCurrent] = useState(() => model.order[0] ?? null) + const [view, setView] = useState(null) + const [announce, setAnnounce] = useState('') + const [expanded, setExpanded] = useState(false) + const pendingFocus = useRef(null) + const mode = usePanelMode(panel, dense) + + // 换了一份文档:焦点位回到第一个片段,面板关掉 + useEffect(() => { + setCurrent(model.order[0] ?? null) + setView(null) + }, [model]) + + // 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起 + const owner = useEvidence((s) => s.owner) + const claim = useEvidence((s) => s.claim) + useEffect(() => { + if (view && owner && owner !== instance) setView(null) + }, [owner, instance, view]) + + const foldAt = useMemo(() => { + const total = model.chars[model.chars.length - 1] ?? 0 + if (total <= TEXT_CAP * 1.2) return blocks.length + const at = model.chars.findIndex((c) => c > TEXT_CAP) + return Math.max(1, at < 0 ? blocks.length : at) + }, [model, blocks.length]) + const shownBlocks = expanded ? blocks.length : foldAt + const totalChars = model.chars[model.chars.length - 1] ?? 0 + + const segEl = useCallback((id: string) => + rootRef.current?.querySelector(`[data-seg="${CSS.escape(id)}"]`) ?? null, []) + + /** 把焦点挪到某个片段上。在折叠里的先展开,渲染出来再聚焦 */ + const focusSeg = useCallback((id: string, opts?: { flash?: boolean }) => { + const hit = model.seg.get(id) + if (!hit) return + setCurrent(id) + if (hit.block >= shownBlocks) { + pendingFocus.current = id + setExpanded(true) + return + } + const el = segEl(id) + if (!el) return + el.focus({ preventScroll: false }) + if (opts?.flash) flashOnce(el) + }, [model, shownBlocks, segEl]) + + useLayoutEffect(() => { + const id = pendingFocus.current + if (!id || !expanded) return + pendingFocus.current = null + const el = segEl(id) + if (el) { el.focus(); flashOnce(el) } + }, [expanded, segEl]) + + const openSeg = useCallback((id: string) => { + const hit = model.seg.get(id) + if (!hit) return + claim(instance) + setView({ kind: 'seg', id }) + setAnnounce(`${EVIDENCE_TEXT.panelTitle}:${segmentLabel(hit.seg, doc)}`) + }, [model, doc, claim, instance]) + + const openViolations = useCallback(() => { + claim(instance) + setView({ kind: 'violations' }) + setAnnounce(`${EVIDENCE_TEXT.violations}:${formatNumber(doc.violations?.length ?? 0)} 条`) + }, [claim, instance, doc]) + + const close = useCallback((refocus = true) => { + const back = view?.kind === 'seg' ? view.id : current + setView(null) + setAnnounce('') + if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus()) + }, [view, current, segEl]) + + const step = useCallback((list: string[], from: string | null, dir: 1 | -1): string | null => { + if (!list.length) return null + const pos = from ? model.index.get(from) ?? -1 : -1 + if (dir > 0) return list.find((id) => (model.index.get(id) ?? 0) > pos) ?? list[0] + for (let i = list.length - 1; i >= 0; i--) if ((model.index.get(list[i]) ?? 0) < pos) return list[i] + return list[list.length - 1] + }, [model]) + + useImperativeHandle(ref, () => ({ + next: (dir = 1) => { + const target = step(model.alerts, view?.kind === 'seg' ? view.id : current, dir) + if (target) { + focusSeg(target, { flash: true }) + openSeg(target) + return true + } + if (doc.violations?.length) { openViolations(); return true } + return false + }, + list: () => { + if (!doc.violations?.length) return false + openViolations() + return true + }, + }), [step, model, view, current, focusSeg, openSeg, openViolations, doc]) + + // 事件委托:片段只带 data-seg,点击和按键都在容器上认 + const segOf = (target: EventTarget | null): string | null => { + const el = (target as HTMLElement | null)?.closest?.('[data-seg]') + return el && rootRef.current?.contains(el) ? el.dataset.seg ?? null : null + } + const onClick = (e: ReactMouseEvent) => { + const id = segOf(e.target) + if (!id) return + e.preventDefault() + setCurrent(id) + if (view?.kind === 'seg' && view.id === id) close(false) + else openSeg(id) + } + const onFocus = (e: ReactFocusEvent) => { + const id = segOf(e.target) + if (id && id !== current) setCurrent(id) + } + const onKeyDown = (e: ReactKeyboardEvent) => { + const id = segOf(e.target) + if (!id || e.altKey || e.metaKey || e.ctrlKey) return + const i = model.index.get(id) ?? 0 + let target: string | null = null + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') { + const to = rovingTarget(e, i, model.order.length, 'horizontal') + target = to >= 0 ? model.order[to] : null + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const u = model.unitOf.get(id) ?? 0 + const n = model.unitFirst.length + target = model.unitFirst[(u + (e.key === 'ArrowDown' ? 1 : -1) + n) % n] ?? null + } else if (e.key === 'n' || e.key === 'N') { + target = step(model.alerts, id, e.shiftKey || e.key === 'N' ? -1 : 1) + if (!target) return + } else if (e.key === 'Escape' && view) { + e.preventDefault() + e.stopPropagation() + close() + return + } else if (e.key === 'Tab' && !e.shiftKey && view && mode !== 'inline') { + // 面板在页面末尾(portal):Tab 直接进面板,不让人穿过整页去找 + const heading = document.getElementById(`${panelId}-title`) + if (heading) { e.preventDefault(); heading.focus() } + return + } else { + return + } + e.preventDefault() + e.stopPropagation() + if (!target) return + focusSeg(target) + // 面板开着:跟着走,读屏从 live region 听到新片段 + if (view) openSeg(target) + } + + const activeSeg = view?.kind === 'seg' ? view.id : null + const activeBlock = activeSeg ? model.seg.get(activeSeg)?.block ?? -1 : -1 + const currentBlock = current ? model.seg.get(current)?.block ?? -1 : -1 + const visual = blocks.length > 40 + + const panelNode = view && ( + close()} + onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }} + onViolations={openViolations} + /> + ) + + // 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用) + const summary = tallySummary(counts, model.order.length > 0) + + return ( +
        +

        {summary}

        +
        {announce}
        + {tally && (counts.total > 0 || counts.other > 0) && ( + { + const target = step(model.alerts, current, 1) + if (target) { focusSeg(target, { flash: true }); openSeg(target) } else openViolations() + }} onList={doc.violations?.length ? openViolations : undefined} /> + )} +
        + {blocks.slice(0, shownBlocks).map((block, b) => ( + // 栏内面板紧跟在片段所在的块后面:片段和它的证据在视线上挨着 + + + {mode === 'inline' && b === activeBlock && panelNode} + + ))} + {mode === 'inline' && view?.kind === 'violations' && panelNode} +
        + {shownBlocks < blocks.length && ( +
        + …后面还有,这里先折叠了 + +
        + )} + {expanded && foldAt < blocks.length && ( +
        + +
        + )} + {mode !== 'inline' && panelNode && createPortal(panelNode, document.body)} +
        + ) +}) + +/** 定位到的片段描一下边(CSS 的 [data-flash]),同 AssistantStream 的 flash */ +function flashOnce(el: HTMLElement) { + el.dataset.flash = 'focus' + setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600) +} + +/** 面板放哪。auto:窄栏(dense)里栏内展开,宽屏侧边,窄屏底部抽屉;屏宽变了跟着变 */ +function usePanelMode(panel: 'auto' | PanelMode, dense: boolean): PanelMode { + const query = '(min-width: 900px)' + const [wide, setWide] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches) + useEffect(() => { + if (panel !== 'auto' || dense || typeof matchMedia !== 'function') return + const mq = matchMedia(query) + const on = () => setWide(mq.matches) + on() + mq.addEventListener('change', on) + return () => mq.removeEventListener('change', on) + }, [panel, dense]) + if (panel !== 'auto') return panel + if (dense) return 'inline' + return wide ? 'side' : 'drawer' +} + +/** 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」 */ +export function EvidenceTally({ counts, onNext, onList }: { + counts: Pick + onNext?: () => void + onList?: () => void +}) { + const clean = !counts.none && !counts.other + return ( +
        + + {evidenceTally(counts.cited, counts.total, counts.other)} + + {!clean && onNext && ( + + )} + {onList && ( + + )} +
        + ) +} + +// ------------------------------------------------------------------------- +// 块与片段 +// ------------------------------------------------------------------------- + +/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */ +const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ + +const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId }: { + block: EvidenceBlock + dense: boolean + doc: EvidenceDocData + /** + * 长文档:块上加 content-visibility,屏幕外的块不排版不绘制。短文档不加:它自带 paint + * containment,贴着块边的片段的焦点描边会被裁掉一截,而短文档本来就不慢 + */ + visual: boolean + /** 这一块里拿着 Tab 位的片段 */ + current: string | null + /** 这一块里面板正开着的片段 */ + active: string | null + panelId: string +}) { + const units = block.units ?? [] + const root = { + 'data-block': block.id, + ...(visual ? { style: { contentVisibility: 'auto', containIntrinsicSize: 'auto 2.5em' } as CSSProperties } : {}), + } + const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => ( + + ) + + switch (block.type) { + case 'heading': + return unit(u)) }} /> + case 'hr': + return + case 'quote': + return unit(u)) }} /> + case 'code': + return ( + unit(u, { code: true, tag: false })), + }} /> + ) + case 'list': { + const items: { key: string; depth: number; units: EvidenceUnit[] }[] = [] + for (const u of units) { + const lead = u.segments?.[0] + const starts = !items.length || (lead?.kind === 'structural' && ITEM_START.test(lead.text)) + if (starts) items.push({ key: u.id, depth: u.depth ?? 0, units: [u] }) + else items[items.length - 1].units.push(u) + } + return ( + ({ key: it.key, depth: it.depth, body: it.units.map((u) => unit(u)) })), + }} /> + ) + } + case 'table': { + // 表头 row = -1;一格可能被分成几句,按位置归拢 + const cells = new Map() + let cols = 0 + const rowIds: number[] = [] + for (const u of units) { + const loc = u.loc ?? { row: 0, col: 0 } + const k = `${loc.row}:${loc.col}` + cells.set(k, [...(cells.get(k) ?? []), u]) + cols = Math.max(cols, loc.col + 1) + if (loc.row >= 0 && !rowIds.includes(loc.row)) rowIds.push(loc.row) + } + rowIds.sort((a, b) => a - b) + const text = (row: number, col: number) => (cells.get(`${row}:${col}`) ?? []).map(unitText).join('') + const node = (row: number, col: number) => + (cells.get(`${row}:${col}`) ?? []).map((u) => unit(u, { tag: false })) + const head = Array.from({ length: cols }, (_, c) => text(-1, c)) + const rows = rowIds.map((r) => Array.from({ length: cols }, (_, c) => text(r, c))) + return ( + node(-1, c)), + rows: rowIds.map((r) => Array.from({ length: cols }, (_, c) => node(r, c))), + numeric: numericColumns(head, rows), + csv: () => [head, ...rows].map((r) => r.map((v) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(',')).join('\n'), + }} /> + ) + } + default: + return unit(u)) }} /> + } +}) + +/** + * 一句话:文字按行内 Markdown 渲染(整句算一次样式,跨片段的 `code`、**粗体** 不会 + * 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」 + */ +function UnitView({ unit, doc, current, active, panelId, code, tag }: { + unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string + code?: boolean; tag: boolean +}) { + const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural') + const text = segs.map((s) => s.text).join('') + const styles = useMemo(() => (code ? null : inlineStyles(text)), [text, code]) + let pos = 0 + let alert = false + const nodes = segs.map((s) => { + const from = pos + pos += s.text.length + const state = segmentState(s) + const body = styles + ? + : s.text + if (!state) return {s.strong ? {body} : body} + const meta = EVIDENCE_STATE[state] + if (meta.alert) alert = true + const open = active === s.id + return ( + + ) + }) + return ( + <> + {nodes} + {alert && tag && ( + + )} + + ) +} + +/** + * 成果里带证据的那个字段:按工件 id 取文档,取到之前和取不到时照普通答案显示—— + * 证据是锦上添花,拿不到不能让答案本身消失。以下情况也不画,免得把证据挂到别的字上: + * - 文档的正文和字段对不上(字段被模板加工过),或者文档根本没记正文; + * - 文档记着的运行和这份成果的运行不是同一个(成果上的 doc_artifact 是可以改写的一列) + */ +export const EvidenceField = forwardRef void +}>(function EvidenceField({ artifact, text, runId, dense, label, tally, onDoc }, ref) { + const slot = useEvidence((s) => s.docs[artifact]) + const loadDoc = useEvidence((s) => s.loadDoc) + useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc]) + const doc = slot?.status === 'ok' ? slot.data ?? null : null + const mismatch = !!doc && (typeof doc.markdown !== 'string' || doc.markdown.trim() !== text.trim()) + const otherRun = !!doc && !!runId && typeof doc.run_id === 'string' && !!doc.run_id && doc.run_id !== runId + const usable = doc && !mismatch && !otherRun ? doc : null + useEffect(() => { onDoc?.(usable) }, [usable, onDoc]) + if (usable) { + return + } + const note = slot?.status === 'error' ? EVIDENCE_TEXT.docMissing + : otherRun ? EVIDENCE_TEXT.docOtherRun : mismatch ? EVIDENCE_TEXT.docMismatch : '' + return ( +
        + + {note &&
        {note}
        } +
        + ) +}) diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx new file mode 100644 index 00000000..db1120bb --- /dev/null +++ b/frontend/src/run/EvidencePanel.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react' +import { ArrowLeft, ListChecks, X } from 'lucide-react' +import clsx from 'clsx' +import { StatusBadge, isComposing } from '../components/ui' +import { + EVIDENCE_STATE, docForeign, graphDoc, integrityFailures, locatable, reasonOf, sealVerdict, segmentState, sourceOf, + type SealStatus, +} from '../lib/evidence' +import { formatNumber, NONE, shortId } from '../lib/format' +import { EVIDENCE_TEXT } from '../lib/terms' +import { segmentKey, useEvidence } from '../store/evidence' +import type { + EvidenceDocData, EvidenceInput, EvidenceSeal, EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceViolation, +} from '../types' + +/** + * 证据面板:点开报告里的一个片段,看它从哪来。 + * + * 本期(数字层)展示:片段和它所在的句子;指标步骤——名称、值、口径与版本、原式、 + * 代入式(每个输入是「值 ← 路径」的小标签)、复算结果;封存状态。没有证据的片段说清 + * 为什么(裸数字、引用解析不了),另有一份违规清单:列表序号、代码块标签里的数字 + * 在正文里画不了线,只能在清单里找到。 + * + * 数据两层来源:文档自己记着的出处(doc.catalog,打开就有),和证据接口的证据链 + * (原式、代入式、输入、封存,点开才取)。接口取不到(老后端、没有运行 id 的预览) + * 就只显示第一层,并照实说缺了什么。 + * + * 只信封存链:正文是按成果上的 _evidence.doc_artifact 取的(成果可以改写、工件表可以 + * 事后插行),接口是从封存范围内的事件找的文档。两边不是同一份时,接口的封存状态和 + * 证据链说的是另一份报告——不能挂到正文上画成绿的,要醒目地说正文不是封存的那一份。 + * + * 三种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 + * 画布右栏里直接栏内展开。都不是模态的:开着面板照样能在正文里走。 + */ + +export type PanelMode = 'side' | 'drawer' | 'inline' +export type PanelView = { kind: 'seg'; id: string } | { kind: 'violations' } + +export function EvidencePanel({ id, mode, doc, artifact, runId, view, onClose, onLocate, onViolations }: { + id: string + mode: PanelMode + doc: EvidenceDocData + /** 正文这份文档的工件 id:和证据接口报的封存文档比对,不是同一份就不信接口给的链 */ + artifact?: string + runId?: string + view: PanelView + onClose: () => void + /** 跳到正文里的某个片段(违规清单里的「定位」) */ + onLocate: (segId: string) => void + onViolations: () => void +}) { + const titleId = `${id}-title` + const found = useMemo(() => (view.kind === 'seg' ? findSeg(doc, view.id) : null), [doc, view]) + const state = found ? segmentState(found.seg) : null + const meta = state ? EVIDENCE_STATE[state] : null + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return + e.preventDefault() + // 画布上在 window 上听 Esc 的(收起检查器之类)不该跟着动 + e.stopPropagation() + onClose() + } + + const title = view.kind === 'violations' ? EVIDENCE_TEXT.violations : found?.seg.text ?? EVIDENCE_TEXT.panelTitle + const frame = mode === 'side' + ? 'ev-panel-side fixed bottom-0 right-0 top-0 z-40 flex flex-col border-l bg-panel shadow-elev-3' + : mode === 'drawer' + ? 'ev-panel-rise fixed bottom-0 left-0 right-0 z-40 flex flex-col rounded-t-lg border-t bg-panel shadow-elev-3' + : 'ev-panel-rise my-2 flex flex-col rounded-lg border bg-panel' + + return ( +
        +
        + {mode === 'inline' && ( + + )} + {meta && ( + + {meta.glyph && {meta.glyph}}{meta.label} + + )} +

        + {title} +

        + {mode !== 'inline' && ( + + )} +
        +
        + {view.kind === 'violations' + ? + : found + ? + :

        {NONE}

        } +
        +
        + ) +} + +function findSeg(doc: EvidenceDocData, id: string): { seg: EvidenceSegment; unit: EvidenceUnit } | null { + for (const block of doc.blocks ?? []) { + for (const unit of block.units ?? []) { + const seg = (unit.segments ?? []).find((s) => s.id === id) + if (seg) return { seg, unit } + } + } + return null +} + +/** 面板里的一节:小标题 + 内容 */ +function Part({ title, children, ...rest }: { title: string; children: ReactNode } & Record<`data-${string}`, string>) { + return ( +
        +
        {title}
        + {children} +
        + ) +} + +/** 值怎么写:数带千分位、不丢小数;拿不到写「—」 */ +function valueText(v: unknown): string { + if (v == null || v === '') return NONE + if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 20 }) : String(v) + if (typeof v === 'boolean') return v ? '是' : '否' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +function SegmentBody({ doc, artifact, runId, seg, unit, onViolations }: { + doc: EvidenceDocData; artifact?: string; runId?: string; seg: EvidenceSegment; unit: EvidenceUnit + onViolations: () => void +}) { + const report = doc.node_id || undefined + const key = runId ? segmentKey(runId, seg.id, report) : '' + const slot = useEvidence((s) => (key ? s.segments[key] : undefined)) + const graph = useEvidence((s) => (runId ? s.graphs[runId] : undefined)) + const loadSegment = useEvidence((s) => s.loadSegment) + const loadGraph = useEvidence((s) => s.loadGraph) + useEffect(() => { + if (runId) void loadSegment(runId, seg.id, report) + }, [runId, seg.id, report, loadSegment]) + const answered = slot?.status === 'ok' ? slot.data : undefined + // 接口答的是另一份报告(封存的那份不是正文这份):它的链和封存状态一概不用 + const foreign = docForeign(answered, { artifact, node: report, seg }) + const detail = foreign ? undefined : answered + // 片段接口没给封存状态(或者取不到)时,退回整次运行的证据图 + const needGraph = !!runId && !foreign && (slot?.status === 'error' || (slot?.status === 'ok' && !answered?.seal)) + useEffect(() => { + if (needGraph && runId) void loadGraph(runId) + }, [needGraph, runId, loadGraph]) + const graphData = needGraph && graph?.status === 'ok' ? graph.data : undefined + // 证据图兜底时也得先认出正文这份文档在不在封存的报告里 + const inGraph = graphDoc(graphData, artifact) + const docIssue: 'foreign' | 'tampered' | null = foreign || inGraph === 'foreign' ? 'foreign' + : inGraph === 'tampered' ? 'tampered' : null + + const state = segmentState(seg) + const cite = seg.cite + const entry = cite?.alias ? doc.catalog?.[cite.alias] : undefined + const chain: EvidenceStep[] = Array.isArray(detail?.chain) ? detail.chain : [] + const metricStep = chain.find((s) => s.step === 'metric') + const inputStep = chain.find((s) => s.step === 'run_input') + const isMetric = cite?.kind === 'metric' || entry?.kind === 'metric' || !!metricStep + const isInput = cite?.kind === 'input' || entry?.kind === 'input' || !!inputStep + const violations = (doc.violations ?? []).filter((v) => v.segment === seg.id) + const seal: EvidenceSeal | undefined = docIssue ? undefined : detail?.seal ?? graphData?.seal + const reason = reasonOf(seg) || detail?.note || '' + const verdict = sealVerdict(seal, { + foreign: !!docIssue, + pending: !!runId && !seal && (slot?.status === 'loading' || graph?.status === 'loading'), + noRun: !runId, + docOnly: state !== 'deterministic', + viaGraph: slot?.status === 'error', + }) + const chainFailed = !runId ? EVIDENCE_TEXT.noRun + : docIssue ? EVIDENCE_TEXT.chainForeign + : slot?.status === 'error' ? EVIDENCE_TEXT.chainMissing : '' + + return ( + <> + {docIssue && ( + // 正文不是封存的那一份:比「有出处」三个字重要得多,放在最前面 + + )} + + + + + + {state === 'none' && ( + +

        {reason}

        + {/* 违规的原话多半是「引用 … 解析不了:<原因>」,和上面那句重复的不再列;裸数字那条说的是怎么改,留着 */} + {violations.filter((v) => v.message && !(reason && v.message.includes(reason))).map((v, i) => ( +

        {v.message}

        + ))} + {seg.ref &&

        [[{seg.ref}]]

        } +
        + )} + + {isMetric && ( + + )} + + {isInput && !isMetric && ( + +

        + {inputStep?.field ?? entry?.locator?.field ?? cite?.locator?.field ?? cite?.ref} ={' '} + {valueText(inputStep?.value ?? entry?.value ?? cite?.value)} +

        + +
        + )} + + {state === 'deterministic' && !isMetric && !isInput && ( +

        {sourceOf(seg, doc) || detail?.note}

        + )} + + + + + + {!!doc.violations?.length && ( + + )} + + ) +} + +/** 所在的句子:点开的那一段描底,句子里的数字加粗 */ +function Sentence({ unit, active }: { unit: EvidenceUnit; active: string }) { + return ( +

        + {(unit.segments ?? []).filter((s) => s.kind !== 'structural').map((s) => { + const state = segmentState(s) + const on = s.id === active + const number = s.kind === 'number' || s.kind === 'value' + return ( + + {s.text} + + ) + })} +

        + ) +} + +/** 指标步骤:名称、值、口径与版本、原式、代入式(输入标签)、复算 */ +function MetricPart({ step, entry, chain, runId, seal, pending, failed, resolved }: { + step?: EvidenceStep + entry?: Record + chain: EvidenceStep[] + runId?: string + /** 封存状态:逐项复核里「在不在封存范围内」只在封存完好时单独报 */ + seal?: EvidenceSeal + pending: boolean + /** 证据链为什么没有:没有运行 id、接口取不到、接口答的是另一份报告 */ + failed: string + /** 片段的引用解析成功了(有出处);解析不了的也画这一节,好说清缺的是哪个指标 */ + resolved: boolean +}) { + const name = step?.name ?? entry?.name ?? step?.metric ?? entry?.locator?.metric ?? NONE + const value = step?.value !== undefined ? step.value : entry?.value + const rendered = step?.rendered ?? entry?.rendered + const caliber = step?.caliber ?? entry?.caliber + const version = step?.version ?? entry?.version + const artifact = step?.artifact ?? entry?.artifact + const inputs: EvidenceInput[] = step?.inputs?.length + ? step.inputs + : chain.filter((s) => s.step === 'input').map((s) => s as EvidenceInput) + // 口径卡里 rendered 为「—」有两种:value 为 null 是缺输入,不为 null 是有值但按格式显示不出来 + const dash = rendered === NONE || entry?.rendered === NONE + const missing = entry?.status === 'missing_input' || (value == null && (dash || !resolved)) + const unshowable = !missing && dash && value != null + + return ( + +
        + {name} + {resolved && rendered && {rendered}} +
        + {(caliber || version || artifact) && ( +
        + {(caliber || version) && 口径卡「{caliber || NONE}」{version}} + {artifact && 工件 {shortId(artifact)}} +
        + )} + + {missing &&

        {EVIDENCE_TEXT.missingValue}

        } + {unshowable && ( +

        + {EVIDENCE_TEXT.unshowable(valueText(value))} +

        + )} + + {step ? ( +
        +
        +
        {EVIDENCE_TEXT.expression}
        +
        + {step.expression || NONE} +
        +
        +
        +
        {EVIDENCE_TEXT.substituted}
        +
        + + {step.substituted || NONE} + + {!!inputs.length && ( +
          + {inputs.map((inp, i) => )} +
        + )} +
        +
        +
        +
        {EVIDENCE_TEXT.recompute}
        +
        + {step.recompute_ok == null ? EVIDENCE_TEXT.recomputeNone + : step.recompute_ok ? EVIDENCE_TEXT.recomputeOk : EVIDENCE_TEXT.recomputeBad} +
        +
        +
        + ) : resolved && ( +

        + {pending ? EVIDENCE_TEXT.chainPending : failed || (runId ? EVIDENCE_TEXT.chainMissing : EVIDENCE_TEXT.noRun)} +

        + )} +
        + ) +} + +/** + * 证据接口逐项复核的结果:eid 能重算、工件哈希复验、重新渲染一致、在封存范围内。 + * 全过的时候不说(正常态安静);哪一项明确没过就用失败色写出来——这时候这件证据 + * 不能信,比「有出处」三个字重要得多。「不在封存范围内」只在封存完好时报:没封存、 + * 封存核对失败时后端把每一步都记成 sealed=false,那由封存那一行说,不能说成「事后补进来的」 + */ +function Integrity({ step, seal }: { step?: EvidenceStep; seal?: EvidenceSeal }) { + const bad = integrityFailures(step, seal) + if (!bad.length) return null + return EVIDENCE_TEXT.integrity[k])} /> +} + +function IntegrityList({ code, items }: { code: string; items: string[] }) { + return ( +
          + {items.map((t) =>
        • {t}
        • )} +
        + ) +} + +/** 「45,678.5 ← vars.kpi.gmv」:值从哪条路径取的。缺的写「—」、标成提醒色 */ +function InputChip({ input }: { input: EvidenceInput }) { + const missing = input.status === 'missing' || input.value == null + const from = [input.node_id && `来自节点 ${input.node_id}`, input.via && `(${input.via})`, input.role && ` · ${input.role}`] + .filter(Boolean).join('') + return ( +
      1. + {valueText(input.value)} ← {input.path ?? NONE}{missing ? '(缺)' : ''} +
      2. + ) +} + +/** + * 封存状态:StatusBadge 的剪影 + 一句话。说什么由 lib/evidence 的 sealVerdict 定(顺序、 + * 正文不是封存那份时的失败态都在那里);没有证据的片段封存的只是报告文档本身, + * 不能说成「这个数已封存」 + */ +function SealLine({ status, label }: { status: SealStatus; label: string }) { + return ( +

        + + {label} +

        + ) +} + +const CODE_LABEL: Record = { + uncited_number: '裸数字', + unresolved_ref: '引用解析不了', +} + +/** 违规清单:报告撰写节点核对出来的全部问题,画得出线的能定位,画不出的说清在哪 */ +function ViolationList({ doc, onLocate }: { doc: EvidenceDocData; onLocate: (segId: string) => void }) { + const list: EvidenceViolation[] = doc.violations ?? [] + if (!list.length) return

        {EVIDENCE_TEXT.allCited(docNumbers(doc))}

        + return ( + <> +

        {EVIDENCE_TEXT.violationsHint}

        +
          + {list.map((v, i) => { + const seg = locatable(doc, v) + return ( +
        • +
          + {v.text ?? (v.ref ? `[[${v.ref}]]` : NONE)} + {CODE_LABEL[v.code] ?? '文档核对没通过'} + + {seg && ( + + )} +
          + {v.message &&

          {v.message}

          } + {v.context &&

          「{v.context.trim()}」

          } + {!seg && ( +

          + {v.segment ? EVIDENCE_TEXT.structural : EVIDENCE_TEXT.noSegment} +

          + )} +
        • + ) + })} +
        + + ) +} + +const docNumbers = (doc: EvidenceDocData): number => doc.stats?.numbers ?? 0 diff --git a/frontend/src/run/Markdown.tsx b/frontend/src/run/Markdown.tsx index 6a0ee084..190d07a1 100644 --- a/frontend/src/run/Markdown.tsx +++ b/frontend/src/run/Markdown.tsx @@ -1,7 +1,10 @@ -import { createContext, Fragment, useContext, useState, type ReactNode } from 'react' +import { + createContext, Fragment, useContext, useMemo, useState, type HTMLAttributes, type ReactNode, +} from 'react' import { Check, Copy } from 'lucide-react' import clsx from 'clsx' import { toast } from '../components/ui' +import { codePointIndex } from '../lib/evidence' /** * 模型输出的 Markdown 渲染。 @@ -22,6 +25,9 @@ import { toast } from '../components/ui' * * 全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出是不 * 可信内容,走 innerHTML 就等于给自己开了个 XSS 口子。 + * + * 块的外观(BlockView)单独导出:报告撰写节点的文档(run/EvidenceDoc)是后端切好的 + * 块和片段,不再解析 Markdown,但长得必须和这里一模一样。 */ export function Markdown({ text, dense, marks }: { text: string @@ -29,11 +35,12 @@ export function Markdown({ text, dense, marks }: { /** 要在正文里标出来的词:出具时无法回指的数字、能回指到口径卡的数字 */ marks?: MarkSpec[] }) { + const ctx = useMemo(() => prepareMarks(text ?? '', marks), [text, marks]) if (!text?.trim()) return null return ( - +
        - {parseBlocks(text).map((b, i) => )} + {parseBlocks(text).map((b, i) => )}
        ) @@ -45,35 +52,109 @@ export function Markdown({ text, dense, marks }: { * 出具校验认得出「哪些数字回指不到口径卡」,但以前只在横幅里列一串孤零零的 * token——读的人得自己回正文里找「6」是哪句话里的那个 6。直接在原句上画出来, * 悬停说明原因,证据才落到读的地方。 + * + * 带 start / end(后端 trace_numbers 给的码点偏移)的只标那一处:按字符串标的话, + * 同一个数出现几次就画几处,日期「2026-09-15」里的 15 也会被当成回指不上的 15。 + * 老运行没有位置,位置和这段文字对不上(叙述模板在字段外面加了字)时,也退回按字符串标 */ export interface MarkSpec { token: string title: string - tone: 'warn' | 'ok' + /** ambiguous:回指上了,但同一个值对得上好几个指标,出处不唯一 */ + tone: 'warn' | 'ok' | 'ambiguous' + /** 在原文里的码点偏移 [start, end) */ + start?: number + end?: number } -const MarksContext = createContext(null) +interface MarksCtx { + /** 按字符串认的 */ + loose: MarkSpec[] | null + /** 按位置认的:UTF-16 起点 → 终点和说明 */ + at: Map | null +} + +const MarksContext = createContext(null) + +/** 位置换成 UTF-16 下标并逐个核对:切出来的字就是 token 才按位置标,否则交给字符串匹配 */ +function prepareMarks(text: string, marks: MarkSpec[] | undefined): MarksCtx | null { + if (!marks?.length) return null + const loose: MarkSpec[] = [] + const at = new Map() + // 换行归一会挪动偏移:带 \r 的文字位置一律不认 + const toUtf16 = text.includes('\r') ? null : codePointIndex(text) + for (const m of marks) { + if (toUtf16 && typeof m.start === 'number' && typeof m.end === 'number' && m.end > m.start) { + const s = toUtf16(m.start) + const e = toUtf16(m.end) + if (s >= 0 && e > s && text.slice(s, e) === m.token) { + at.set(s, { end: e, spec: m }) + continue + } + } + loose.push(m) + } + return { loose: loose.length ? loose : null, at: at.size ? at : null } +} + +/** + * 一段文字在原文里的位置:连续的给起点,拼接过的(引用的多行、列表的续行)给逐字的 + * 下标表。只有按位置标记时用得上,其余时候跟着传一下,不做任何事 + */ +type At = number | number[] +const offsetAt = (at: At | undefined, i: number): number => + at == null ? -1 : typeof at === 'number' ? at + i : at[i] ?? -1 +const shiftAt = (at: At | undefined, i: number): At | undefined => + at == null ? undefined : typeof at === 'number' ? at + i : at.slice(i) + +function markSpan(spec: MarkSpec, text: string, key: string): ReactNode { + const color = spec.tone === 'ok' ? 'var(--st-done)' : 'var(--st-waiting)' + return ( + + {text} + + ) +} /** 纯文本段里的标记词包一层。数字按词边界认:「6」不能命中「94.26」里的那个 6 */ -function markText(text: string, marks: MarkSpec[] | null, keyBase: number): ReactNode[] { - if (!marks || !text) return [text] - const alts = [...marks].sort((a, b) => b.token.length - a.token.length) - .map((m) => m.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - const re = new RegExp(`(? b.token.length - a.token.length) + .map((m) => m.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const re = new RegExp(`(? start < h.end && h.start < end)) continue + hits.push({ start, end, spec: ctx.loose.find((x) => x.token === m![0])! }) + } + } + if (!hits.length) return [text] + hits.sort((a, b) => a.start - b.start) const out: ReactNode[] = [] let last = 0 - for (let m = re.exec(text); m; m = re.exec(text)) { - if (m.index > last) out.push(text.slice(last, m.index)) - const spec = marks.find((x) => x.token === m![0])! - out.push( - - {m[0]} - , - ) - last = m.index + m[0].length + for (const h of hits) { + if (h.start > last) out.push(text.slice(last, h.start)) + out.push(markSpan(h.spec, text.slice(h.start, h.end), `mk${keyBase}-${h.start}`)) + last = h.end } if (last < text.length) out.push(text.slice(last)) return out @@ -83,14 +164,17 @@ function markText(text: string, marks: MarkSpec[] | null, keyBase: number): Reac // 块级 // ------------------------------------------------------------------------- +/** 带着在原文里位置的一段文字(位置只给按位置标记用) */ +interface Src { text: string; at: At } + type Block = - | { kind: 'p'; text: string } - | { kind: 'h'; level: number; text: string } + | { kind: 'p'; text: string; at: At } + | { kind: 'h'; level: number; text: string; at: At } | { kind: 'code'; lang: string; code: string } - | { kind: 'quote'; text: string } + | { kind: 'quote'; text: string; at: At } | { kind: 'hr' } - | { kind: 'list'; ordered: boolean; start?: number; items: { text: string; depth: number }[] } - | { kind: 'table'; head: string[]; rows: string[][] } + | { kind: 'list'; ordered: boolean; start?: number; items: { text: string; at: At; depth: number }[] } + | { kind: 'table'; head: Src[]; rows: Src[][] } const FENCE = /^\s*```(\w*)\s*$/ const HEADING = /^(#{1,6})\s+(.*)$/ @@ -101,8 +185,23 @@ const QUOTE = /^\s*>\s?(.*)$/ const TABLE_ROW = /^\s*\|(.+)\|\s*$/ const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/ +/** 逐字下标表:几段不相连的文字拼成一段时,每个字记着它原来在哪 */ +function joinAt(parts: { text: string; at: number }[], sep: string): number[] { + const out: number[] = [] + parts.forEach((p, k) => { + if (k > 0) for (let j = 0; j < sep.length; j++) out.push(-1) + for (let j = 0; j < p.text.length; j++) out.push(p.at + j) + }) + return out +} + function parseBlocks(text: string): Block[] { const lines = text.replace(/\r\n?/g, '\n').split('\n') + // 每一行在原文里从哪开始 + const starts: number[] = [] + for (let i = 0, pos = 0; i < lines.length; i++) { starts.push(pos); pos += lines[i].length + 1 } + /** 这一行里 content 这段(它总是顶到行尾)的起点 */ + const tail = (i: number, content: string) => starts[i] + lines[i].length - content.length const out: Block[] = [] let i = 0 @@ -127,29 +226,30 @@ function parseBlocks(text: string): Block[] { const heading = line.match(HEADING) if (heading) { - out.push({ kind: 'h', level: heading[1].length, text: heading[2] }) + out.push({ kind: 'h', level: heading[1].length, text: heading[2], at: tail(i, heading[2]) }) i += 1 continue } // 表格:一行 | ... | 后面紧跟一行分隔行,两者都有才算 if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) { - const head = splitRow(line) - const rows: string[][] = [] + const head = splitRow(line, starts[i]) + const rows: Src[][] = [] i += 2 - while (i < lines.length && TABLE_ROW.test(lines[i])) { rows.push(splitRow(lines[i])); i += 1 } + while (i < lines.length && TABLE_ROW.test(lines[i])) { rows.push(splitRow(lines[i], starts[i])); i += 1 } out.push({ kind: 'table', head, rows }) continue } const quote = line.match(QUOTE) if (quote) { - const body = [quote[1]] + const body = [{ text: quote[1], at: tail(i, quote[1]) }] i += 1 while (i < lines.length && QUOTE.test(lines[i])) { - body.push(lines[i].match(QUOTE)![1]); i += 1 + const q = lines[i].match(QUOTE)![1] + body.push({ text: q, at: tail(i, q) }); i += 1 } - out.push({ kind: 'quote', text: body.join('\n') }) + out.push({ kind: 'quote', text: body.map((b) => b.text).join('\n'), at: joinAt(body, '\n') }) continue } @@ -159,34 +259,44 @@ function parseBlocks(text: string): Block[] { // 一段连续的列表算一块。有序和无序混排时按第一条定性——模型不常这么写, // 真遇上了也比拆成两块好看 const isOrdered = !!ordered - const items: { text: string; depth: number }[] = [] + const items: { parts: { text: string; at: number }[]; depth: number }[] = [] while (i < lines.length) { const b = lines[i].match(BULLET) const o = b ? null : lines[i].match(ORDERED) if (b || o) { const indent = (b ?? o)![1] - items.push({ text: b ? b[2] : o![3], depth: Math.floor(indent.replace(/\t/g, ' ').length / 2) }) + const body = b ? b[2] : o![3] + items.push({ parts: [{ text: body, at: tail(i, body) }], depth: Math.floor(indent.replace(/\t/g, ' ').length / 2) }) i += 1 } else if (lines[i].trim() && !isBlockStart(lines[i]) && items.length) { // 悬挂缩进的续行接到上一条上,不要另起一段 - items[items.length - 1].text += '\n' + lines[i].trim() + const t = lines[i].trim() + items[items.length - 1].parts.push({ text: t, at: starts[i] + lines[i].indexOf(t) }) i += 1 } else break } // 模型常把有序列表写成被空行隔开的几段,每段各自一块。不记起始序号的话 // 三段都从 1 开始,「1. 1. 1.」读起来像三条并列的第一条 - out.push({ kind: 'list', ordered: isOrdered, items, - ...(ordered ? { start: Number(ordered[2]) } : {}) }) + out.push({ + kind: 'list', ordered: isOrdered, + items: items.map((it) => ({ + text: it.parts.map((p) => p.text).join('\n'), + at: it.parts.length === 1 ? it.parts[0].at : joinAt(it.parts, '\n'), + depth: it.depth, + })), + ...(ordered ? { start: Number(ordered[2]) } : {}), + }) continue } - // 普通段落:一直吃到空行或下一个块开头 + // 普通段落:一直吃到空行或下一个块开头。行是原样接起来的,位置连续 const para = [line] + const at = starts[i] i += 1 while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) { para.push(lines[i]); i += 1 } - out.push({ kind: 'p', text: para.join('\n') }) + out.push({ kind: 'p', text: para.join('\n'), at }) } return out } @@ -196,79 +306,142 @@ function isBlockStart(line: string): boolean { || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line) || TABLE_ROW.test(line) } -function splitRow(line: string): string[] { - return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()) +/** 表格的一行拆成格:去掉首尾竖线、每格去掉两边空白。记下每格去空白之后从哪开始 */ +function splitRow(line: string, lineStart: number): Src[] { + const lead = line.length - line.trimStart().length + let body = line.trim() + let base = lineStart + lead + if (body.startsWith('|')) { body = body.slice(1); base += 1 } + if (body.endsWith('|')) body = body.slice(0, -1) + const out: Src[] = [] + let pos = 0 + for (const raw of body.split('|')) { + out.push({ text: raw.trim(), at: base + pos + (raw.length - raw.trimStart().length) }) + pos += raw.length + 1 + } + return out } -function Block({ block, dense }: { block: Block; dense?: boolean }) { +/** 解析出来的块交给 BlockView:每段文字按行内语法渲染 */ +function MdBlock({ block, dense }: { block: Block; dense?: boolean }) { switch (block.kind) { + case 'h': return }} /> + case 'hr': return + case 'code': return + case 'quote': return }} /> + case 'list': + return ( + ({ depth: it.depth, body: })), + }} /> + ) + case 'table': { + const head = block.head.map((h) => h.text) + const rows = block.rows.map((r) => r.map((c) => c.text)) + return ( + ), + // 用表头的列数对齐:模型偶尔会少写一格,少的补空,多的截掉——否则整张表会错位 + rows: block.rows.map((row) => block.head.map((_, j) => ( + row[j] ? : null))), + numeric: numericColumns(head, rows), + csv: () => toCsv(head, rows), + }} /> + ) + } + default: + return }} /> + } +} + +/** + * 一个块的外观,内容由调用方给。Markdown 给的是按行内语法渲染的文字,EvidenceDoc + * 给的是后端切好的片段——两处长得一模一样,改外观只改这里 + */ +export type BlockShape = + | { kind: 'p'; body: ReactNode } + | { kind: 'h'; level: number; body: ReactNode } + | { kind: 'code'; lang: ReactNode; code: string; body?: ReactNode } + | { kind: 'quote'; body: ReactNode } + | { kind: 'hr' } + | { kind: 'list'; ordered: boolean; start?: number; items: { body: ReactNode; depth: number; key?: string }[] + /** 序号按位数留宽(「100.」比 1rem 宽)。普通答案不开,保持原样 */ + fitMarkers?: boolean } + | { kind: 'table'; head: ReactNode[]; rows: ReactNode[][]; numeric: boolean[]; csv: () => string } + +/** 块最外层那个元素上额外要挂的属性(EvidenceDoc 挂 data-block 和 content-visibility) */ +type RootProps = HTMLAttributes & Record<`data-${string}`, string | undefined> + +export function BlockView({ shape, dense, root }: { shape: BlockShape; dense?: boolean; root?: RootProps }) { + const { className: rootClass, ...rest } = root ?? {} + switch (shape.kind) { case 'h': { // 侧栏只有 360px 宽,一级标题按 h1 的字号会大得不像话。 // 按层级递减但整体压扁,保住层次感就够了 const size = dense - ? ['text-[13px]', 'text-[12px]', 'text-[11.5px]'][Math.min(block.level, 3) - 1] - : ['text-base', 'text-sm', 'text-sm'][Math.min(block.level, 3) - 1] + ? ['text-[13px]', 'text-[12px]', 'text-[11.5px]'][Math.min(shape.level, 3) - 1] + : ['text-base', 'text-sm', 'text-sm'][Math.min(shape.level, 3) - 1] return ( -
        - +
        + {shape.body}
        ) } case 'hr': - return
        + return
        case 'code': - return + return {shape.body} case 'quote': return ( -
        - +
        + {shape.body}
        ) case 'list': { - const Tag = block.ordered ? 'ol' : 'ul' + const Tag = shape.ordered ? 'ol' : 'ul' + // 缩进用 padding 不用 margin:列表符号画在这一段里,块上加了 content-visibility + // (长报告)时,画在 margin 里的符号会被裁掉。序号到了两三位(「100.」)时符号比 + // 1rem 宽,fitMarkers 时按位数加宽,不然序号伸到块外面、被裁掉 + const digits = shape.ordered && shape.fitMarkers ? String((shape.start ?? 1) + shape.items.length - 1).length : 1 return ( - - {block.items.map((it, i) => ( -
      3. - + 1 ? { paddingLeft: `calc(1rem + ${(digits - 1) * 0.6}em)`, ...rest.style } : rest.style}> + {shape.items.map((it, i) => ( +
      4. + {it.body}
      5. ))}
        ) } - case 'table': { + case 'table': // 数值列右对齐、等宽数字:一列金额左对齐时小数点对不上,没法一眼比大小 - const numeric = block.head.map((h, j) => { - if (CODE_COLUMN.test(h.replace(/[*`]/g, '').trim())) return false - const cells = block.rows.map((r) => (r[j] ?? '').replace(/[*`]/g, '').trim()).filter(Boolean) - return cells.length > 0 && cells.every((c) => NUMERIC_CELL.test(c) && !LEADING_ZERO.test(c)) - }) return ( -
        - toCsv(block.head, block.rows)} +
        + - {block.head.map((h, i) => ( + {shape.head.map((h, i) => ( ))} - {block.rows.map((row, i) => ( + {shape.rows.map((row, i) => ( - {/* 用表头的列数对齐:模型偶尔会少写一格,少的补空, - 多的截掉——否则整张表会错位 */} - {block.head.map((_, j) => ( + {row.map((cell, j) => ( ))} @@ -277,12 +450,20 @@ function Block({ block, dense }: { block: Block; dense?: boolean }) {
        - + shape.numeric[i] ? 'text-right' : 'text-left')}> + {h}
        - + shape.numeric[j] && 'tnum text-right')}> + {cell}
        ) - } default: - return

        + return

        {shape.body}

        } } +/** 哪几列是数值列:列名不像编号 / 分组,非空的格全是数 */ +export function numericColumns(head: string[], rows: string[][]): boolean[] { + return head.map((h, j) => { + if (CODE_COLUMN.test(h.replace(/[*`]/g, '').trim())) return false + const cells = rows.map((r) => (r[j] ?? '').replace(/[*`]/g, '').trim()).filter(Boolean) + return cells.length > 0 && cells.every((c) => NUMERIC_CELL.test(c) && !LEADING_ZERO.test(c)) + }) +} + // ------------------------------------------------------------------------- // 行内 // ------------------------------------------------------------------------- @@ -299,7 +480,12 @@ const INLINE = new RegExp([ '~~([^~]+?)~~', // ~~strike~~ ].join('|')) -function Inline({ text, depth = 0 }: { text: string; depth?: number }): ReactNode { +/** + * 行内语法:代码、链接、粗体、斜体、删除线。EvidenceDoc 的文字片段也走它——后端说 + * 不含标记的粗体、`code`、链接原样留在文字片段里,由前端按行内 Markdown 渲染 + */ +export function Inline({ text, depth = 0, at }: { text: string; depth?: number; at?: At }): ReactNode { + const marks = useContext(MarksContext) if (!text) return null const out: ReactNode[] = [] let last = 0 @@ -308,18 +494,17 @@ function Inline({ text, depth = 0 }: { text: string; depth?: number }): ReactNod // 里可以套行内代码),不每次重置就会从上一层留下的位置接着扫 const re = new RegExp(INLINE.source, 'g') - const marks = useContext(MarksContext) - const plain = (t: string) => out.push(...markText(t, marks, key++)) + const plain = (t: string, from: number) => out.push(...markText(t, marks, key++, shiftAt(at, from))) for (let m = re.exec(text); m; m = re.exec(text)) { - if (m.index > last) plain(text.slice(last, m.index)) - const [, , code, linkText, href, bold1, bold2, italic, strike] = m + if (m.index > last) plain(text.slice(last, m.index), last) + const [whole, , code, linkText, href, bold1, bold2, italic, strike] = m // 强调的内容要再解析一层。模型很爱写 **`table_name`**(粗体里套代码), // 而粗体在扫描顺序上先命中,不递归的话那对反引号就原样显示出来了。 // 深度有限:每层的文本都严格更短,且这里也卡了三层 const sub = (t: string) => - depth < 3 ? : t + depth < 3 ? : t if (code !== undefined) { // 代码内部不再解析——这正是代码的含义 @@ -335,12 +520,101 @@ function Inline({ text, depth = 0 }: { text: string; depth?: number }): ReactNod } else if (strike !== undefined) { out.push({sub(strike)}) } - last = m.index + m[0].length + last = m.index + whole.length } - if (last < text.length) plain(text.slice(last)) + if (last < text.length) plain(text.slice(last), last) return <>{out.map((n, i) => {n})} } +/** 行内样式位。EvidenceDoc 的片段按它把一段文字切成几截、分别包上 code / strong / em */ +export const INLINE_BIT = { code: 1, bold: 2, em: 4, strike: 8, link: 16 } as const + +export interface InlineStyles { + /** 语法字符(反引号、星号、链接的方括号和网址):不显示 */ + hide: Uint8Array + /** 每个字的样式位 */ + style: Uint8Array + /** 链接文字所在的字 → 网址。只收 http/https/mailto,别的协议当纯文本,同 Link */ + href: Map +} + +/** + * 一段文字的行内样式,逐字给出。和 Inline 用同一个 INLINE 语法、同样最多套三层。 + * + * 为什么不直接用 Inline:报告文档里一句话已经被后端切成了好几个片段,行内语法可能 + * 跨片段——`limit 100` 里的 100 是一个裸数字片段,两边的反引号在别的片段里。对整句 + * 算一次样式、每个片段取自己那一截,代码就还是代码,数字还能单独点 + */ +export function inlineStyles(text: string): InlineStyles { + const hide = new Uint8Array(text.length) + const style = new Uint8Array(text.length) + const href = new Map() + const walk = (from: number, to: number, inherited: number, depth: number) => { + const part = text.slice(from, to) + const re = new RegExp(INLINE.source, 'g') + let last = 0 + for (let m = re.exec(part); m; m = re.exec(part)) { + for (let i = last; i < m.index; i++) style[from + i] = inherited + const [whole, ticks, code, linkText, url, bold1, bold2, italic, strike] = m + const base = from + m.index + const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? '' + // 内容紧跟在开头的定界符后面:反引号按实际个数,其余 indexOf 找到的就是第一处 + const off = code !== undefined ? ticks.length : whole.indexOf(inner) + for (let i = 0; i < whole.length; i++) hide[base + i] = 1 + for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0 + const safe = linkText !== undefined && /^(https?:|mailto:)/i.test(url.trim()) + const bit = code !== undefined ? INLINE_BIT.code + : linkText !== undefined ? (safe ? INLINE_BIT.link : 0) + : bold1 !== undefined || bold2 !== undefined ? INLINE_BIT.bold + : italic !== undefined ? INLINE_BIT.em : INLINE_BIT.strike + if (code !== undefined || linkText !== undefined || depth >= 3) { + for (let i = 0; i < inner.length; i++) { + style[base + off + i] = inherited | bit + if (safe) href.set(base + off + i, url) + } + } else { + walk(base + off, base + off + inner.length, inherited | bit, depth + 1) + } + last = m.index + whole.length + } + for (let i = last; i < part.length; i++) style[from + i] = inherited + } + walk(0, text.length, 0, 0) + return { hide, style, href } +} + +/** + * 按 inlineStyles 渲染 [from, to) 这一截。links=false 时链接只画样子不给 :片段本身 + * 是个 button,button 里不能再套可交互的元素 + */ +export function StyledSlice({ text, styles, from, to, links = true }: { + text: string; styles: InlineStyles; from: number; to: number; links?: boolean +}): ReactNode { + const out: ReactNode[] = [] + let i = from + while (i < to) { + if (styles.hide[i]) { i += 1; continue } + const st = styles.style[i] + const href = styles.href.get(i) + let j = i + 1 + while (j < to && !styles.hide[j] && styles.style[j] === st && styles.href.get(j) === href) j += 1 + let node: ReactNode = text.slice(i, j) + if (st & INLINE_BIT.code) node = {node} + if (st & INLINE_BIT.link) { + node = links && href + ? {node} + : {node} + } + if (st & INLINE_BIT.strike) node = {node} + if (st & INLINE_BIT.em) node = {node} + if (st & INLINE_BIT.bold) node = {node} + out.push({node}) + i = j + } + return <>{out} +} + /** * 链接。 * @@ -390,16 +664,21 @@ function toCsv(head: string[], rows: string[][]): string { * 代码块带语言标签和复制按钮。模型给的 SQL、脚本十有八九要被拿去别处跑, * 手动框选容易漏掉首尾一行 */ -function CodeBlock({ lang, code, dense }: { lang: string; code: string; dense?: boolean }) { +function CodeBlock({ lang, code, dense, root, children }: { + lang: ReactNode; code: string; dense?: boolean; root?: RootProps + /** 要显示的内容,不给就是 code 原文(EvidenceDoc 给的是片段,代码里的裸数字要画线) */ + children?: ReactNode +}) { + const { className: rootClass, ...rest } = root ?? {} return ( -
        +
        {lang || '代码'} code} />
        -        {code}
        +        {children ?? code}
               
        ) diff --git a/frontend/src/run/__tests__/evidence-doc.json b/frontend/src/run/__tests__/evidence-doc.json new file mode 100644 index 00000000..360c89ea --- /dev/null +++ b/frontend/src/run/__tests__/evidence-doc.json @@ -0,0 +1,2784 @@ +{ + "_comment": "可点击证据第一期的前端夹具。由后端 evidence.py 的 compose_doc 真跑生成,只用通用示例名。", + "run_id": "run-evidence-demo", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e", + "doc": { + "schema": "agentlab.report/1", + "run_id": "run-evidence-demo", + "node_id": "write", + "markdown": "## 本周概览 📊\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。\n\n增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。\n\n## 退款\n\n退款率 2.35%,统计口径是 `refunds` 表里本周发生的退款单。新增用户 —。\n\n- 华东区域销售额 ⟦?v:Q1.r0.amount⟧\n- 小额退款 ⟦?m:small_refund⟧\n - 其余渠道合计 3 笔\n\n| 指标 | 本周 |\n|---|---|\n| 销售额 | 45,678.5元 |\n| 订单数 | 1,234单 |\n\n100. 附注:以上按周报口径统计", + "source": "## 本周概览 📊\n\n本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]],客单价 **[[m:aov]]**。[[see:m:gmv,m:wow,m:orders,m:aov]]\n\n增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。\n\n## 退款\n\n退款率 [[m:refund_rate]],统计口径是 `refunds` 表里本周发生的退款单。新增用户 [[m:new_users]]。\n\n- 华东区域销售额 [[v:Q1.r0.amount]]\n- 小额退款 [[m:small_refund]]\n - 其余渠道合计 3 笔\n\n| 指标 | 本周 |\n|---|---|\n| 销售额 | [[m:gmv]] |\n| 订单数 | [[m:orders]] |\n\n100. 附注:以上按周报口径统计", + "catalog": { + "m:gmv": { + "alias": "m:gmv", + "kind": "metric", + "eid": "ev:metric:f60c701e9c49d5f9", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "gmv" + }, + "caliber": "周报口径", + "version": "v2", + "name": "销售额", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 45678.5, + "status": "ok", + "rendered": "45,678.5元", + "label": "销售额 = 45,678.5元" + }, + "m:gmv_prev": { + "alias": "m:gmv_prev", + "kind": "metric", + "eid": "ev:metric:d9e9c062d3f1be97", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "gmv_prev" + }, + "caliber": "周报口径", + "version": "v2", + "name": "上周销售额", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 42010.0, + "status": "ok", + "rendered": "42,010.0元", + "label": "上周销售额 = 42,010.0元" + }, + "m:wow": { + "alias": "m:wow", + "kind": "metric", + "eid": "ev:metric:ab46bcc0db93e4fb", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "wow" + }, + "caliber": "周报口径", + "version": "v2", + "name": "环比增幅", + "unit": "%", + "decimals": 1, + "format": "plain", + "value": 8.7, + "status": "ok", + "rendered": "8.7%", + "label": "环比增幅 = 8.7%" + }, + "m:orders": { + "alias": "m:orders", + "kind": "metric", + "eid": "ev:metric:2f935b615dbc6381", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "orders" + }, + "caliber": "周报口径", + "version": "v2", + "name": "订单数", + "unit": "单", + "decimals": 0, + "format": "thousands", + "value": 1234, + "status": "ok", + "rendered": "1,234单", + "label": "订单数 = 1,234单" + }, + "m:aov": { + "alias": "m:aov", + "kind": "metric", + "eid": "ev:metric:cdc765917d78ef4b", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "aov" + }, + "caliber": "周报口径", + "version": "v2", + "name": "客单价", + "unit": "元", + "decimals": 1, + "format": "thousands", + "value": 37.0, + "status": "ok", + "rendered": "37.0元", + "label": "客单价 = 37.0元" + }, + "m:refund_rate": { + "alias": "m:refund_rate", + "kind": "metric", + "eid": "ev:metric:199853e026f412ca", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "refund_rate" + }, + "caliber": "周报口径", + "version": "v2", + "name": "退款率", + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "value": 0.0235, + "status": "ok", + "rendered": "2.35%", + "label": "退款率 = 2.35%" + }, + "m:new_users": { + "alias": "m:new_users", + "kind": "metric", + "eid": "ev:metric:e1bd2d529b6c2a6b", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "new_users" + }, + "caliber": "周报口径", + "version": "v2", + "name": "新增用户", + "unit": "人", + "decimals": 0, + "format": "thousands", + "value": null, + "status": "missing_input", + "rendered": "—", + "label": "新增用户 = —" + }, + "m:small_refund": { + "alias": "m:small_refund", + "kind": "metric", + "eid": "ev:metric:9e2cf63ff0317f0c", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "locator": { + "metric": "small_refund" + }, + "caliber": "周报口径", + "version": "v2", + "name": "小额退款", + "unit": "元", + "decimals": -2, + "format": "thousands", + "value": 29, + "status": "ok", + "rendered": "—", + "label": "小额退款 = —" + }, + "i:week": { + "alias": "i:week", + "kind": "input", + "eid": "ev:input:a398c5e60abfeedd", + "locator": { + "field": "week" + }, + "value": "2026-W37", + "rendered": "2026-W37", + "label": "week = 2026-W37" + } + }, + "blocks": [ + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u0", + "kind": "heading", + "span": [ + 3, + 9 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "## ", + "state": "neutral", + "span": [ + 0, + 3 + ], + "id": "s0" + }, + { + "kind": "text", + "text": "本周概览 📊", + "state": "neutral", + "span": [ + 3, + 9 + ], + "id": "s1" + } + ] + } + ], + "id": "b0" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u1", + "kind": "claim", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ], + "see": [ + { + "ref": "m:gmv", + "role": "support", + "alias": "m:gmv", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:f60c701e9c49d5f9", + "locator": { + "metric": "gmv" + } + }, + { + "ref": "m:wow", + "role": "support", + "alias": "m:wow", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:ab46bcc0db93e4fb", + "locator": { + "metric": "wow" + } + }, + { + "ref": "m:orders", + "role": "support", + "alias": "m:orders", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:2f935b615dbc6381", + "locator": { + "metric": "orders" + } + }, + { + "ref": "m:aov", + "role": "support", + "alias": "m:aov", + "kind": "metric", + "status": "resolved", + "eid": "ev:metric:cdc765917d78ef4b", + "locator": { + "metric": "aov" + } + } + ], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 9, + 11 + ], + "id": "s2" + }, + { + "kind": "text", + "text": "本周(", + "state": "neutral", + "span": [ + 11, + 14 + ], + "id": "s3" + }, + { + "kind": "value", + "text": "2026-W37", + "ref": "i:week", + "cite": { + "ref": "week", + "alias": "i:week", + "locator": { + "field": "week" + }, + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "role": "value", + "status": "resolved", + "value": "2026-W37", + "rendered": "2026-W37" + }, + "state": "deterministic", + "span": [ + 14, + 22 + ], + "id": "s4" + }, + { + "kind": "text", + "text": ")销售额 ", + "state": "neutral", + "span": [ + 22, + 27 + ], + "id": "s5" + }, + { + "kind": "number", + "text": "45,678.5元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:f60c701e9c49d5f9", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + }, + "state": "deterministic", + "span": [ + 27, + 36 + ], + "id": "s6" + }, + { + "kind": "text", + "text": ",环比 ", + "state": "neutral", + "span": [ + 36, + 40 + ], + "id": "s7" + }, + { + "kind": "number", + "text": "8.7%", + "ref": "m:wow", + "cite": { + "ref": "wow", + "alias": "m:wow", + "locator": { + "metric": "wow" + }, + "eid": "ev:metric:ab46bcc0db93e4fb", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8.7, + "rendered": "8.7%" + }, + "state": "deterministic", + "span": [ + 40, + 44 + ], + "id": "s8" + }, + { + "kind": "text", + "text": ";订单 ", + "state": "neutral", + "span": [ + 44, + 48 + ], + "id": "s9" + }, + { + "kind": "number", + "text": "1,234单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:2f935b615dbc6381", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + }, + "state": "deterministic", + "span": [ + 48, + 54 + ], + "id": "s10" + }, + { + "kind": "text", + "text": ",客单价 ", + "state": "neutral", + "span": [ + 54, + 59 + ], + "id": "s11" + }, + { + "kind": "number", + "text": "37.0元", + "ref": "m:aov", + "cite": { + "ref": "aov", + "alias": "m:aov", + "locator": { + "metric": "aov" + }, + "eid": "ev:metric:cdc765917d78ef4b", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 37.0, + "rendered": "37.0元" + }, + "state": "deterministic", + "strong": true, + "span": [ + 59, + 64 + ], + "id": "s12" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 64, + 65 + ], + "id": "s13" + } + ] + } + ], + "id": "b1" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u2", + "kind": "claim", + "span": [ + 67, + 89 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 65, + 67 + ], + "id": "s14" + }, + { + "kind": "text", + "text": "增长主要来自新客首单,新客贡献了 ", + "span": [ + 67, + 84 + ], + "state": "neutral", + "id": "s15" + }, + { + "kind": "number", + "text": "12", + "span": [ + 84, + 86 + ], + "state": "none", + "issue": "uncited_number", + "id": "s16" + }, + { + "kind": "text", + "text": " 单。", + "span": [ + 86, + 89 + ], + "state": "neutral", + "id": "s17" + } + ] + }, + { + "id": "u3", + "kind": "connective", + "span": [ + 89, + 97 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "text", + "text": "下面看退款情况。", + "state": "neutral", + "span": [ + 89, + 97 + ], + "id": "s18" + } + ] + } + ], + "id": "b2" + }, + { + "type": "heading", + "level": 2, + "units": [ + { + "id": "u4", + "kind": "heading", + "span": [ + 102, + 104 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n## ", + "state": "neutral", + "span": [ + 97, + 102 + ], + "id": "s19" + }, + { + "kind": "text", + "text": "退款", + "state": "neutral", + "span": [ + 102, + 104 + ], + "id": "s20" + } + ] + } + ], + "id": "b3" + }, + { + "type": "paragraph", + "units": [ + { + "id": "u5", + "kind": "claim", + "span": [ + 106, + 143 + ], + "cites": [ + "m:refund_rate" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n", + "state": "neutral", + "span": [ + 104, + 106 + ], + "id": "s21" + }, + { + "kind": "text", + "text": "退款率 ", + "state": "neutral", + "span": [ + 106, + 110 + ], + "id": "s22" + }, + { + "kind": "number", + "text": "2.35%", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:199853e026f412ca", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0235, + "rendered": "2.35%" + }, + "state": "deterministic", + "span": [ + 110, + 115 + ], + "id": "s23" + }, + { + "kind": "text", + "text": ",统计口径是 `refunds` 表里本周发生的退款单。", + "state": "neutral", + "span": [ + 115, + 143 + ], + "id": "s24" + } + ] + }, + { + "id": "u6", + "kind": "claim", + "span": [ + 143, + 150 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "text", + "text": "新增用户 ", + "state": "neutral", + "span": [ + 143, + 148 + ], + "id": "s25" + }, + { + "kind": "number", + "text": "—", + "ref": "m:new_users", + "cite": { + "ref": "new_users", + "alias": "m:new_users", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「新增用户」这次没有值(缺输入),不能写进报告", + "rendered": "—" + }, + "state": "none", + "issue": "unresolved_ref", + "span": [ + 148, + 149 + ], + "id": "s26" + }, + { + "kind": "text", + "text": "。", + "state": "neutral", + "span": [ + 149, + 150 + ], + "id": "s27" + } + ] + } + ], + "id": "b4" + }, + { + "type": "list", + "ordered": false, + "units": [ + { + "id": "u7", + "kind": "claim", + "span": [ + 154, + 179 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n- ", + "state": "neutral", + "span": [ + 150, + 154 + ], + "id": "s28" + }, + { + "kind": "text", + "text": "华东区域销售额 ", + "state": "neutral", + "span": [ + 154, + 162 + ], + "id": "s29" + }, + { + "kind": "value", + "text": "⟦?v:Q1.r0.amount⟧", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "kind": "cell", + "role": "value", + "status": "unresolved", + "reason": "这种引用在后续版本支持", + "rendered": "⟦?v:Q1.r0.amount⟧" + }, + "state": "none", + "issue": "unresolved_ref", + "span": [ + 162, + 179 + ], + "id": "s30" + } + ], + "depth": 0 + }, + { + "id": "u8", + "kind": "claim", + "span": [ + 182, + 204 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n- ", + "state": "neutral", + "span": [ + 179, + 182 + ], + "id": "s31" + }, + { + "kind": "text", + "text": "小额退款 ", + "state": "neutral", + "span": [ + 182, + 187 + ], + "id": "s32" + }, + { + "kind": "number", + "text": "⟦?m:small_refund⟧", + "ref": "m:small_refund", + "cite": { + "ref": "small_refund", + "alias": "m:small_refund", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "rendered": "⟦?m:small_refund⟧" + }, + "state": "none", + "issue": "unresolved_ref", + "span": [ + 187, + 204 + ], + "id": "s33" + } + ], + "depth": 0 + }, + { + "id": "u9", + "kind": "claim", + "span": [ + 209, + 219 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n - ", + "state": "neutral", + "span": [ + 204, + 209 + ], + "id": "s34" + }, + { + "kind": "text", + "text": "其余渠道合计 ", + "span": [ + 209, + 216 + ], + "state": "neutral", + "id": "s35" + }, + { + "kind": "number", + "text": "3", + "span": [ + 216, + 217 + ], + "state": "none", + "issue": "uncited_number", + "id": "s36" + }, + { + "kind": "text", + "text": " 笔", + "span": [ + 217, + 219 + ], + "state": "neutral", + "id": "s37" + } + ], + "depth": 1 + } + ], + "id": "b5" + }, + { + "type": "table", + "units": [ + { + "id": "u10", + "kind": "connective", + "span": [ + 223, + 225 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": "\n\n| ", + "state": "neutral", + "span": [ + 219, + 223 + ], + "id": "s38" + }, + { + "kind": "text", + "text": "指标", + "state": "neutral", + "span": [ + 223, + 225 + ], + "id": "s39" + } + ], + "loc": { + "row": -1, + "col": 0 + } + }, + { + "id": "u11", + "kind": "connective", + "span": [ + 228, + 230 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 225, + 228 + ], + "id": "s40" + }, + { + "kind": "text", + "text": "本周", + "state": "neutral", + "span": [ + 228, + 230 + ], + "id": "s41" + } + ], + "loc": { + "row": -1, + "col": 1 + } + }, + { + "id": "u12", + "kind": "connective", + "span": [ + 245, + 248 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n|---|---|\n| ", + "state": "neutral", + "span": [ + 230, + 245 + ], + "id": "s42" + }, + { + "kind": "text", + "text": "销售额", + "state": "neutral", + "span": [ + 245, + 248 + ], + "id": "s43" + } + ], + "loc": { + "row": 0, + "col": 0 + } + }, + { + "id": "u13", + "kind": "claim", + "span": [ + 251, + 260 + ], + "cites": [ + "m:gmv" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 248, + 251 + ], + "id": "s44" + }, + { + "kind": "number", + "text": "45,678.5元", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:f60c701e9c49d5f9", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + }, + "state": "deterministic", + "span": [ + 251, + 260 + ], + "id": "s45" + } + ], + "loc": { + "row": 0, + "col": 1 + } + }, + { + "id": "u14", + "kind": "connective", + "span": [ + 265, + 268 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n| ", + "state": "neutral", + "span": [ + 260, + 265 + ], + "id": "s46" + }, + { + "kind": "text", + "text": "订单数", + "state": "neutral", + "span": [ + 265, + 268 + ], + "id": "s47" + } + ], + "loc": { + "row": 1, + "col": 0 + } + }, + { + "id": "u15", + "kind": "claim", + "span": [ + 271, + 277 + ], + "cites": [ + "m:orders" + ], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " | ", + "state": "neutral", + "span": [ + 268, + 271 + ], + "id": "s48" + }, + { + "kind": "number", + "text": "1,234单", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:2f935b615dbc6381", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + }, + "state": "deterministic", + "span": [ + 271, + 277 + ], + "id": "s49" + } + ], + "loc": { + "row": 1, + "col": 1 + } + } + ], + "id": "b6" + }, + { + "type": "list", + "ordered": true, + "start": 100, + "units": [ + { + "id": "u16", + "kind": "connective", + "span": [ + 286, + 298 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "structural", + "text": " |\n\n100. ", + "state": "neutral", + "span": [ + 277, + 286 + ], + "id": "s50" + }, + { + "kind": "text", + "text": "附注:以上按周报口径统计", + "state": "neutral", + "span": [ + 286, + 298 + ], + "id": "s51" + } + ], + "depth": 0 + } + ], + "id": "b7" + } + ], + "stats": { + "units": 17, + "segments": 52, + "claims": 9, + "connective": 6, + "headings": 2, + "numbers": 12, + "numbers_cited": 7, + "values": 1, + "uncited_numbers": 3, + "unresolved": 3, + "see": 4, + "uncited_claims": 5, + "violations": 6 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「12」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 84, + 86 + ], + "text": "12", + "context": " 增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。 ## 退款", + "segment": "s16", + "unit": "u2" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新增用户」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 148, + 149 + ], + "text": "—", + "segment": "s26", + "unit": "u6" + }, + { + "code": "unresolved_ref", + "message": "引用 [[v:Q1.r0.amount]] 解析不了:这种引用在后续版本支持", + "ref": "v:Q1.r0.amount", + "span": [ + 162, + 179 + ], + "text": "⟦?v:Q1.r0.amount⟧", + "segment": "s30", + "unit": "u7" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:small_refund]] 解析不了:29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "ref": "m:small_refund", + "span": [ + 187, + 204 + ], + "text": "⟦?m:small_refund⟧", + "segment": "s33", + "unit": "u8" + }, + { + "code": "uncited_number", + "message": "数字「3」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 216, + 217 + ], + "text": "3", + "context": "efund⟧ - 其余渠道合计 3 笔 | 指标 | 本周 | |-", + "segment": "s36", + "unit": "u9" + }, + { + "code": "uncited_number", + "message": "数字「100」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 281, + 284 + ], + "text": "100", + "context": "| 订单数 | 1,234单 | 100. 附注:以上按周报口径统计", + "segment": "s50", + "unit": "u16" + } + ] + }, + "graph": { + "run_id": "run-evidence-demo", + "schema": "agentlab.evidence/1", + "mode": "cited", + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 48, + "legacy": false + }, + "reports": [ + { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e", + "doc_sealed": true, + "fields": [ + "周报" + ], + "stats": { + "units": 17, + "segments": 52, + "claims": 9, + "connective": 6, + "headings": 2, + "numbers": 12, + "numbers_cited": 7, + "values": 1, + "uncited_numbers": 3, + "unresolved": 3, + "see": 4, + "uncited_claims": 5, + "violations": 6 + } + } + ], + "evidence": [ + { + "alias": "m:gmv", + "eid": "ev:metric:f60c701e9c49d5f9", + "kind": "metric", + "label": "销售额 = 45,678.5元", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s6", + "s45" + ] + }, + { + "alias": "m:gmv_prev", + "eid": "ev:metric:d9e9c062d3f1be97", + "kind": "metric", + "label": "上周销售额 = 42,010.0元", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [] + }, + { + "alias": "m:wow", + "eid": "ev:metric:ab46bcc0db93e4fb", + "kind": "metric", + "label": "环比增幅 = 8.7%", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s8" + ] + }, + { + "alias": "m:orders", + "eid": "ev:metric:2f935b615dbc6381", + "kind": "metric", + "label": "订单数 = 1,234单", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s10", + "s49" + ] + }, + { + "alias": "m:aov", + "eid": "ev:metric:cdc765917d78ef4b", + "kind": "metric", + "label": "客单价 = 37.0元", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s12" + ] + }, + { + "alias": "m:refund_rate", + "eid": "ev:metric:199853e026f412ca", + "kind": "metric", + "label": "退款率 = 2.35%", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s23" + ] + }, + { + "alias": "m:new_users", + "eid": "ev:metric:e1bd2d529b6c2a6b", + "kind": "metric", + "label": "新增用户 = —", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s26" + ] + }, + { + "alias": "m:small_refund", + "eid": "ev:metric:9e2cf63ff0317f0c", + "kind": "metric", + "label": "小额退款 = —", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "sealed": true, + "cited_by": [ + "s33" + ] + }, + { + "alias": "i:week", + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "label": "week = 2026-W37", + "node_id": null, + "artifact": null, + "sealed": true, + "cited_by": [ + "s4" + ] + } + ], + "edges": [] + }, + "segments": { + "s4": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s4", + "text": "2026-W37", + "kind": "value", + "state": "deterministic", + "span": [ + 14, + 22 + ], + "unit": "u1", + "ref": "i:week", + "cite": { + "ref": "week", + "alias": "i:week", + "locator": { + "field": "week" + }, + "eid": "ev:input:a398c5e60abfeedd", + "kind": "input", + "role": "value", + "status": "resolved", + "value": "2026-W37", + "rendered": "2026-W37" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "run_input", + "alias": "i:week", + "field": "week", + "value": "2026-W37", + "rendered": "2026-W37", + "eid": "ev:input:a398c5e60abfeedd", + "eid_ok": true, + "node_id": "in", + "sealed": true + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s6": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s6", + "text": "45,678.5元", + "kind": "number", + "state": "deterministic", + "span": [ + 27, + 36 + ], + "unit": "u1", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:f60c701e9c49d5f9", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:gmv", + "metric": "gmv", + "name": "销售额", + "value": 45678.5, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "45,678.5元", + "caliber": "周报口径", + "version": "v2", + "expression": "vars.kpi.gmv", + "substituted": "45678.5", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:f60c701e9c49d5f9", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s8": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s8", + "text": "8.7%", + "kind": "number", + "state": "deterministic", + "span": [ + 40, + 44 + ], + "unit": "u1", + "ref": "m:wow", + "cite": { + "ref": "wow", + "alias": "m:wow", + "locator": { + "metric": "wow" + }, + "eid": "ev:metric:ab46bcc0db93e4fb", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 8.7, + "rendered": "8.7%" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:wow", + "metric": "wow", + "name": "环比增幅", + "value": 8.7, + "unit": "%", + "decimals": 1, + "format": "plain", + "conv": null, + "rendered": "8.7%", + "caliber": "周报口径", + "version": "v2", + "expression": "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)", + "substituted": "round((45678.5 - 42010.0) / 42010.0 * 100, 1)", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:ab46bcc0db93e4fb", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "tool", + "status": "ok" + }, + { + "step": "input", + "path": "vars.kpi.gmv_prev", + "value": 42010.0, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s10": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s10", + "text": "1,234单", + "kind": "number", + "state": "deterministic", + "span": [ + 48, + 54 + ], + "unit": "u1", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:2f935b615dbc6381", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:orders", + "metric": "orders", + "name": "订单数", + "value": 1234, + "unit": "单", + "decimals": 0, + "format": "thousands", + "conv": null, + "rendered": "1,234单", + "caliber": "周报口径", + "version": "v2", + "expression": "vars.kpi.order_cnt", + "substituted": "1234", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:2f935b615dbc6381", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s12": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s12", + "text": "37.0元", + "kind": "number", + "state": "deterministic", + "span": [ + 59, + 64 + ], + "unit": "u1", + "ref": "m:aov", + "strong": true, + "cite": { + "ref": "aov", + "alias": "m:aov", + "locator": { + "metric": "aov" + }, + "eid": "ev:metric:cdc765917d78ef4b", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 37.0, + "rendered": "37.0元" + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。", + "span": [ + 11, + 65 + ], + "cites": [ + "i:week", + "m:gmv", + "m:wow", + "m:orders", + "m:aov" + ] + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:aov", + "metric": "aov", + "name": "客单价", + "value": 37.0, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "37.0元", + "caliber": "周报口径", + "version": "v2", + "expression": "round(vars.kpi.gmv / vars.kpi.order_cnt, 1)", + "substituted": "round(45678.5 / 1234, 1)", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:cdc765917d78ef4b", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "tool", + "status": "ok" + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s16": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s16", + "text": "12", + "kind": "number", + "state": "none", + "span": [ + 84, + 86 + ], + "unit": "u2", + "issue": "uncited_number" + }, + "unit": { + "id": "u2", + "kind": "claim", + "text": "增长主要来自新客首单,新客贡献了 12 单。", + "span": [ + 67, + 89 + ], + "cites": [] + }, + "block": { + "id": "b2", + "type": "paragraph" + }, + "chain": [], + "note": "这个数字没有出处:写作者直接写了数字,没有用引用标记,系统没法核对它", + "violations": [ + { + "code": "uncited_number", + "message": "数字「12」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 84, + 86 + ], + "text": "12", + "context": " 增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。 ## 退款", + "segment": "s16", + "unit": "u2" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s23": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s23", + "text": "2.35%", + "kind": "number", + "state": "deterministic", + "span": [ + 110, + 115 + ], + "unit": "u5", + "ref": "m:refund_rate", + "cite": { + "ref": "refund_rate", + "alias": "m:refund_rate", + "locator": { + "metric": "refund_rate" + }, + "eid": "ev:metric:199853e026f412ca", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 0.0235, + "rendered": "2.35%" + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "退款率 2.35%,统计口径是 `refunds` 表里本周发生的退款单。", + "span": [ + 106, + 143 + ], + "cites": [ + "m:refund_rate" + ] + }, + "block": { + "id": "b4", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:refund_rate", + "metric": "refund_rate", + "name": "退款率", + "value": 0.0235, + "unit": "", + "decimals": 4, + "format": "percent_of_ratio", + "conv": null, + "rendered": "2.35%", + "caliber": "周报口径", + "version": "v2", + "expression": "vars.kpi.refund_cnt / vars.kpi.order_cnt", + "substituted": "29 / 1234", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:199853e026f412ca", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.refund_cnt", + "value": 29, + "node_id": "fetch", + "via": "tool", + "status": "ok" + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s26": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s26", + "text": "—", + "kind": "number", + "state": "none", + "span": [ + 148, + 149 + ], + "unit": "u6", + "ref": "m:new_users", + "issue": "unresolved_ref", + "cite": { + "ref": "new_users", + "alias": "m:new_users", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "指标「新增用户」这次没有值(缺输入),不能写进报告", + "rendered": "—" + } + }, + "unit": { + "id": "u6", + "kind": "claim", + "text": "新增用户 —。", + "span": [ + 143, + 150 + ], + "cites": [] + }, + "block": { + "id": "b4", + "type": "paragraph" + }, + "chain": [], + "note": "引用解析不了:指标「新增用户」这次没有值(缺输入),不能写进报告", + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新增用户」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 148, + 149 + ], + "text": "—", + "segment": "s26", + "unit": "u6" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s30": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s30", + "text": "⟦?v:Q1.r0.amount⟧", + "kind": "value", + "state": "none", + "span": [ + 162, + 179 + ], + "unit": "u7", + "ref": "v:Q1.r0.amount", + "issue": "unresolved_ref", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "kind": "cell", + "role": "value", + "status": "unresolved", + "reason": "这种引用在后续版本支持", + "rendered": "⟦?v:Q1.r0.amount⟧" + } + }, + "unit": { + "id": "u7", + "kind": "claim", + "text": "华东区域销售额 ⟦?v:Q1.r0.amount⟧", + "span": [ + 154, + 179 + ], + "cites": [] + }, + "block": { + "id": "b5", + "type": "list" + }, + "chain": [], + "note": "引用解析不了:这种引用在后续版本支持", + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[v:Q1.r0.amount]] 解析不了:这种引用在后续版本支持", + "ref": "v:Q1.r0.amount", + "span": [ + 162, + 179 + ], + "text": "⟦?v:Q1.r0.amount⟧", + "segment": "s30", + "unit": "u7" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s33": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s33", + "text": "⟦?m:small_refund⟧", + "kind": "number", + "state": "none", + "span": [ + 187, + 204 + ], + "unit": "u8", + "ref": "m:small_refund", + "issue": "unresolved_ref", + "cite": { + "ref": "small_refund", + "alias": "m:small_refund", + "kind": "metric", + "role": "value", + "status": "unresolved", + "reason": "29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "rendered": "⟦?m:small_refund⟧" + } + }, + "unit": { + "id": "u8", + "kind": "claim", + "text": "小额退款 ⟦?m:small_refund⟧", + "span": [ + 182, + 204 + ], + "cites": [] + }, + "block": { + "id": "b5", + "type": "list" + }, + "chain": [], + "note": "引用解析不了:29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "violations": [ + { + "code": "unresolved_ref", + "message": "引用 [[m:small_refund]] 解析不了:29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "ref": "m:small_refund", + "span": [ + 187, + 204 + ], + "text": "⟦?m:small_refund⟧", + "segment": "s33", + "unit": "u8" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s36": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s36", + "text": "3", + "kind": "number", + "state": "none", + "span": [ + 216, + 217 + ], + "unit": "u9", + "issue": "uncited_number" + }, + "unit": { + "id": "u9", + "kind": "claim", + "text": "其余渠道合计 3 笔", + "span": [ + 209, + 219 + ], + "cites": [] + }, + "block": { + "id": "b5", + "type": "list" + }, + "chain": [], + "note": "这个数字没有出处:写作者直接写了数字,没有用引用标记,系统没法核对它", + "violations": [ + { + "code": "uncited_number", + "message": "数字「3」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 216, + 217 + ], + "text": "3", + "context": "efund⟧ - 其余渠道合计 3 笔 | 指标 | 本周 | |-", + "segment": "s36", + "unit": "u9" + } + ], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s45": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s45", + "text": "45,678.5元", + "kind": "number", + "state": "deterministic", + "span": [ + 251, + 260 + ], + "unit": "u13", + "ref": "m:gmv", + "cite": { + "ref": "gmv", + "alias": "m:gmv", + "locator": { + "metric": "gmv" + }, + "eid": "ev:metric:f60c701e9c49d5f9", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 45678.5, + "rendered": "45,678.5元" + } + }, + "unit": { + "id": "u13", + "kind": "claim", + "text": "45,678.5元", + "span": [ + 251, + 260 + ], + "cites": [ + "m:gmv" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "metric", + "alias": "m:gmv", + "metric": "gmv", + "name": "销售额", + "value": 45678.5, + "unit": "元", + "decimals": 1, + "format": "thousands", + "conv": null, + "rendered": "45,678.5元", + "caliber": "周报口径", + "version": "v2", + "expression": "vars.kpi.gmv", + "substituted": "45678.5", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:f60c701e9c49d5f9", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.gmv", + "value": 45678.5, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s49": { + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "segment": { + "id": "s49", + "text": "1,234单", + "kind": "number", + "state": "deterministic", + "span": [ + 271, + 277 + ], + "unit": "u15", + "ref": "m:orders", + "cite": { + "ref": "orders", + "alias": "m:orders", + "locator": { + "metric": "orders" + }, + "eid": "ev:metric:2f935b615dbc6381", + "kind": "metric", + "role": "value", + "status": "resolved", + "value": 1234, + "rendered": "1,234单" + } + }, + "unit": { + "id": "u15", + "kind": "claim", + "text": "1,234单", + "span": [ + 271, + 277 + ], + "cites": [ + "m:orders" + ] + }, + "block": { + "id": "b6", + "type": "table" + }, + "chain": [ + { + "step": "metric", + "alias": "m:orders", + "metric": "orders", + "name": "订单数", + "value": 1234, + "unit": "单", + "decimals": 0, + "format": "thousands", + "conv": null, + "rendered": "1,234单", + "caliber": "周报口径", + "version": "v2", + "expression": "vars.kpi.order_cnt", + "substituted": "1234", + "recompute_ok": true, + "status": "ok", + "node_id": "caliber", + "artifact": "c47efd33fc1e8a54058138da73b4332abe7349442cf0b9cfc364054ccca85323", + "eid": "ev:metric:2f935b615dbc6381", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "caliber_upgrade": null + }, + { + "step": "input", + "path": "vars.kpi.order_cnt", + "value": 1234, + "node_id": "fetch", + "via": "tool", + "status": "ok" + } + ], + "note": "数字由系统从证据里取出、按口径卡的格式渲染,没有经过模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + } + }, + "output": { + "周报": "## 本周概览 📊\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。\n\n增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。\n\n## 退款\n\n退款率 2.35%,统计口径是 `refunds` 表里本周发生的退款单。新增用户 —。\n\n- 华东区域销售额 ⟦?v:Q1.r0.amount⟧\n- 小额退款 ⟦?m:small_refund⟧\n - 其余渠道合计 3 笔\n\n| 指标 | 本周 |\n|---|---|\n| 销售额 | 45,678.5元 |\n| 订单数 | 1,234单 |\n\n100. 附注:以上按周报口径统计", + "_evidence": { + "report_node": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e", + "fields": [ + "周报" + ] + }, + "_issuance": { + "mode": "citations", + "tier": "degraded", + "report": { + "node_id": "write", + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e" + }, + "calibers": [ + { + "node": "caliber", + "caliber": "周报口径", + "version": "v2" + } + ], + "metrics_checked": 8, + "missing_required": [], + "missing_expected": [], + "matched_numbers": 7, + "matched": [ + { + "token": "45,678.5元", + "metric": "gmv", + "caliber": "周报口径 @ v2", + "start": 27, + "end": 36, + "segment": "s6", + "eid": "ev:metric:f60c701e9c49d5f9" + }, + { + "token": "8.7%", + "metric": "wow", + "caliber": "周报口径 @ v2", + "start": 40, + "end": 44, + "segment": "s8", + "eid": "ev:metric:ab46bcc0db93e4fb" + }, + { + "token": "1,234单", + "metric": "orders", + "caliber": "周报口径 @ v2", + "start": 48, + "end": 54, + "segment": "s10", + "eid": "ev:metric:2f935b615dbc6381" + }, + { + "token": "37.0元", + "metric": "aov", + "caliber": "周报口径 @ v2", + "start": 59, + "end": 64, + "segment": "s12", + "eid": "ev:metric:cdc765917d78ef4b" + }, + { + "token": "2.35%", + "metric": "refund_rate", + "caliber": "周报口径 @ v2", + "start": 110, + "end": 115, + "segment": "s23", + "eid": "ev:metric:199853e026f412ca" + }, + { + "token": "45,678.5元", + "metric": "gmv", + "caliber": "周报口径 @ v2", + "start": 251, + "end": 260, + "segment": "s45", + "eid": "ev:metric:f60c701e9c49d5f9" + }, + { + "token": "1,234单", + "metric": "orders", + "caliber": "周报口径 @ v2", + "start": 271, + "end": 277, + "segment": "s49", + "eid": "ev:metric:2f935b615dbc6381" + } + ], + "unmatched_numbers": [ + { + "token": "12", + "context": " 增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。 ## 退款", + "start": 84, + "end": 86 + }, + { + "token": "3", + "context": "efund⟧ - 其余渠道合计 3 笔 | 指标 | 本周 | |-", + "start": 216, + "end": 217 + }, + { + "token": "100", + "context": "| 订单数 | 1,234单 | 100. 附注:以上按周报口径统计", + "start": 281, + "end": 284 + } + ], + "unresolved": [ + { + "ref": "m:new_users", + "text": "—", + "segment": "s26" + }, + { + "ref": "v:Q1.r0.amount", + "text": "⟦?v:Q1.r0.amount⟧", + "segment": "s30" + }, + { + "ref": "m:small_refund", + "text": "⟦?m:small_refund⟧", + "segment": "s33" + } + ], + "gaps": [], + "declared_at": "2026-09-27T03:00:00+00:00" + } + }, + "legacy": { + "text": "📦 2026-09-15 这一周订单 1,234 单,新渠道来了 15 单。\n\n客单价 37.0 元,退款 0 单,补发 0 单。", + "matched": [ + { + "token": "1,234", + "metric": "orders", + "start": 19, + "end": 24 + }, + { + "token": "37.0", + "metric": "aov", + "start": 44, + "end": 48 + }, + { + "token": "0", + "metric": null, + "candidates": [ + "refund_cnt", + "reship_cnt" + ], + "ambiguous": true, + "start": 54, + "end": 55 + }, + { + "token": "0", + "metric": null, + "candidates": [ + "refund_cnt", + "reship_cnt" + ], + "ambiguous": true, + "start": 61, + "end": 62 + } + ], + "unmatched": [ + { + "token": "15", + "context": "周订单 1,234 单,新渠道来了 15 单。 客单价 37.0 元,退款 ", + "start": 33, + "end": 35 + } + ] + }, + "report_checked": { + "doc_artifact": "70eaf9110d59baf9d290a1b8ec734a8aadf64f97f87b98349ac89a1f100eef3e", + "stats": { + "units": 17, + "segments": 52, + "claims": 9, + "connective": 6, + "headings": 2, + "numbers": 12, + "numbers_cited": 7, + "values": 1, + "uncited_numbers": 3, + "unresolved": 3, + "see": 4, + "uncited_claims": 5, + "violations": 6 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「12」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 84, + 86 + ], + "text": "12", + "context": " 增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。 ## 退款", + "segment": "s16", + "unit": "u2" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:new_users]] 解析不了:指标「新增用户」这次没有值(缺输入),不能写进报告", + "ref": "m:new_users", + "span": [ + 148, + 149 + ], + "text": "—", + "segment": "s26", + "unit": "u6" + }, + { + "code": "unresolved_ref", + "message": "引用 [[v:Q1.r0.amount]] 解析不了:这种引用在后续版本支持", + "ref": "v:Q1.r0.amount", + "span": [ + 162, + 179 + ], + "text": "⟦?v:Q1.r0.amount⟧", + "segment": "s30", + "unit": "u7" + }, + { + "code": "unresolved_ref", + "message": "引用 [[m:small_refund]] 解析不了:29 按口径卡的小数位(-2)显示为 0,精度不够:口径卡要多留几位小数", + "ref": "m:small_refund", + "span": [ + 187, + 204 + ], + "text": "⟦?m:small_refund⟧", + "segment": "s33", + "unit": "u8" + }, + { + "code": "uncited_number", + "message": "数字「3」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 216, + 217 + ], + "text": "3", + "context": "efund⟧ - 其余渠道合计 3 笔 | 指标 | 本周 | |-", + "segment": "s36", + "unit": "u9" + }, + { + "code": "uncited_number", + "message": "数字「100」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 281, + 284 + ], + "text": "100", + "context": "| 订单数 | 1,234单 | 100. 附注:以上按周报口径统计", + "segment": "s50", + "unit": "u16" + } + ], + "repairs": 1 + } +} \ No newline at end of file diff --git a/frontend/src/run/__tests__/markdown-pinned.json b/frontend/src/run/__tests__/markdown-pinned.json new file mode 100644 index 00000000..c5e6d031 --- /dev/null +++ b/frontend/src/run/__tests__/markdown-pinned.json @@ -0,0 +1,59 @@ +{ + "_comment": "Markdown.tsx 改动之前(HEAD b418c0d)对同一批文字渲染出来的 outerHTML,check-evidence 拿它比对现在的渲染:没有 _evidence 的旧回答必须和以前一样。只用通用名。唯一有意的改动是列表缩进 ml-4 → pl-4(见 intended),看起来一样,检查脚本另外量了位置。重新生成见 check-evidence 的 pinned 一节注释。", + "intended": [ + { + "from": "ml-4 marker:text-faint", + "to": "pl-4 marker:text-faint", + "why": "列表缩进从 margin 改成 padding:EvidenceDoc 的长报告块上有 content-visibility(自带 paint containment),画在 margin 里的列表符号会被裁掉。li 的位置和宽度不变" + } + ], + "samples": [ + { + "id": "report", + "text": "## 本周概览 📊\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。\n\n增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。\n\n## 退款\n\n退款率 2.35%,统计口径是 `refunds` 表里本周发生的退款单。新增用户 —。\n\n- 华东区域销售额 ⟦?v:Q1.r0.amount⟧\n- 小额退款 ⟦?m:small_refund⟧\n - 其余渠道合计 3 笔\n\n| 指标 | 本周 |\n|---|---|\n| 销售额 | 45,678.5元 |\n| 订单数 | 1,234单 |\n\n100. 附注:以上按周报口径统计", + "dense": false, + "html": "
        本周概览 📊

        本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。

        增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。

        退款

        退款率 2.35%,统计口径是 refunds 表里本周发生的退款单。新增用户 —。

        • 华东区域销售额 ⟦?v:Q1.r0.amount⟧
        • 小额退款 ⟦?m:small_refund⟧
        • 其余渠道合计 3 笔
        指标本周
        销售额45,678.5元
        订单数1,234单
        1. 附注:以上按周报口径统计
        " + }, + { + "id": "report-dense", + "text": "## 本周概览 📊\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。\n\n增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。\n\n## 退款\n\n退款率 2.35%,统计口径是 `refunds` 表里本周发生的退款单。新增用户 —。\n\n- 华东区域销售额 ⟦?v:Q1.r0.amount⟧\n- 小额退款 ⟦?m:small_refund⟧\n - 其余渠道合计 3 笔\n\n| 指标 | 本周 |\n|---|---|\n| 销售额 | 45,678.5元 |\n| 订单数 | 1,234单 |\n\n100. 附注:以上按周报口径统计", + "dense": true, + "html": "
        本周概览 📊

        本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。

        增长主要来自新客首单,新客贡献了 12 单。下面看退款情况。

        退款

        退款率 2.35%,统计口径是 refunds 表里本周发生的退款单。新增用户 —。

        • 华东区域销售额 ⟦?v:Q1.r0.amount⟧
        • 小额退款 ⟦?m:small_refund⟧
        • 其余渠道合计 3 笔
        指标本周
        销售额45,678.5元
        订单数1,234单
        1. 附注:以上按周报口径统计
        " + }, + { + "id": "syntax", + "text": "# 一级标题\n## 二级 `orders` 标题\n### 三级标题\n\n段落里有 **粗体**、*斜体*、~~删除线~~、`code`、[链接](https://example.com)、[坏链接](javascript:void(0)) 和 **`sales_daily`**。\n第二行接着同一段。\n\n- 第一项\n- 第二项\n 续行接在上一项\n - 嵌套一层\n - 再嵌套一层\n* 星号也是列表\n\n3. 从 3 开始的有序列表\n4. 第二条\n10. 第十条\n\n1. 一\n2. 二\n\n> 引用第一行 **重点**\n> 引用第二行\n\n| 区域 | 销售额 | 订单编号 |\n|---|---:|---|\n| 华东 | 18,230.5 | 0012 |\n| 华南 | 12,004.0 | 0034 |\n| 华北 | 9,876 |\n\n```sql\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region\n```\n\n```\n无语言标签的代码\n```\n\n---\n\n最后一段:环比 8.7%,订单 1,234单,退款 15 笔,日期 2026-09-15。", + "dense": false, + "html": "
        一级标题
        二级 orders 标题
        三级标题

        段落里有 粗体、斜体、删除线、code、链接、坏链接) 和 sales_daily。\n第二行接着同一段。

        • 第一项
        • 第二项\n续行接在上一项
        • 嵌套一层
        • 再嵌套一层
        • 星号也是列表
        1. 从 3 开始的有序列表
        2. 第二条
        3. 第十条
        1. 一
        2. 二
        引用第一行 重点\n引用第二行
        区域销售额订单编号
        华东18,230.50012
        华南12,004.00034
        华北9,876
        sql
        SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region
        代码
        无语言标签的代码

        最后一段:环比 8.7%,订单 1,234单,退款 15 笔,日期 2026-09-15。

        " + }, + { + "id": "syntax-dense", + "text": "# 一级标题\n## 二级 `orders` 标题\n### 三级标题\n\n段落里有 **粗体**、*斜体*、~~删除线~~、`code`、[链接](https://example.com)、[坏链接](javascript:void(0)) 和 **`sales_daily`**。\n第二行接着同一段。\n\n- 第一项\n- 第二项\n 续行接在上一项\n - 嵌套一层\n - 再嵌套一层\n* 星号也是列表\n\n3. 从 3 开始的有序列表\n4. 第二条\n10. 第十条\n\n1. 一\n2. 二\n\n> 引用第一行 **重点**\n> 引用第二行\n\n| 区域 | 销售额 | 订单编号 |\n|---|---:|---|\n| 华东 | 18,230.5 | 0012 |\n| 华南 | 12,004.0 | 0034 |\n| 华北 | 9,876 |\n\n```sql\nSELECT region, SUM(amount) AS gmv FROM orders GROUP BY region\n```\n\n```\n无语言标签的代码\n```\n\n---\n\n最后一段:环比 8.7%,订单 1,234单,退款 15 笔,日期 2026-09-15。", + "dense": true, + "html": "
        一级标题
        二级 orders 标题
        三级标题

        段落里有 粗体、斜体、删除线、code、链接、坏链接) 和 sales_daily。\n第二行接着同一段。

        • 第一项
        • 第二项\n续行接在上一项
        • 嵌套一层
        • 再嵌套一层
        • 星号也是列表
        1. 从 3 开始的有序列表
        2. 第二条
        3. 第十条
        1. 一
        2. 二
        引用第一行 重点\n引用第二行
        区域销售额订单编号
        华东18,230.50012
        华南12,004.00034
        华北9,876
        sql
        SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region
        代码
        无语言标签的代码

        最后一段:环比 8.7%,订单 1,234单,退款 15 笔,日期 2026-09-15。

        " + }, + { + "id": "marks", + "text": "本周销售额 45,678.5元,退款 15 笔,日期 2026-09-15,订单 1,234单。\n\n- 退款 15 笔里有 3 笔是重复单\n\n| 指标 | 本周 |\n|---|---|\n| 订单 | 1,234单 |", + "dense": false, + "marks": [ + { + "token": "15", + "tone": "warn", + "title": "这个数字在口径卡里找不到来源:「退款 15 笔」" + }, + { + "token": "1,234", + "tone": "ok", + "title": "来自口径卡指标「orders」 · 周报口径 @ v2" + }, + { + "token": "45,678.5", + "tone": "ok", + "title": "来自口径卡指标「gmv」" + } + ], + "html": "

        本周销售额 45,678.5元,退款 15 笔,日期 2026-09-15,订单 1,234单。

        • 退款 15 笔里有 3 笔是重复单
        指标本周
        订单1,234单
        " + } + ] +} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index cd9b6d53..b9490ad5 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1,7 +1,8 @@ import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types' import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' -import { TYPE_LABEL, issuanceLabel, nodeTypeLabel } from '../lib/terms' +import { TYPE_LABEL, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms' +import { statsTally } from '../lib/evidence' // 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在 // types.ts 里;这里再导出一遍,老引用不用改 @@ -675,6 +676,16 @@ function explainLog(code: string | undefined, message: string): Pick `· ${String(v?.message ?? v?.text ?? v?.code ?? '')}`).filter((x) => x.length > 2) + const more = violations.length > listed.length ? [`…另有 ${violations.length - listed.length} 处`] : [] + // on_violation=fail 且重写后仍不过:节点接着就失败,这一步不能画成完成。老后端没有 failed 字段,照旧 + const failed = d.failed === true + push({ + id: `rc-${seq}`, seq, kind: 'issuance', nodeId, status: failed ? 'failed' : 'done', code: 'report_checked', + level: failed ? 'error' : none || violations.length ? 'warn' : 'info', + title: tally ? `核对报告:${tally}` : '核对报告', + ...(listed.length ? { detail: [...listed, ...more].join('\n') } : {}), + ...(repairs ? { meta: `重写 ${repairs} 次` } : {}), + ...(typeof d.doc_artifact === 'string' && d.doc_artifact ? { artifact: d.doc_artifact } : {}), + }, nodeId) + break + } + case 'caliber.upgrade': out.push({ id: `cu-${seq}`, seq, kind: 'note', level: 'warn', status: 'done', diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index e45790e3..9dba5289 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -3,6 +3,7 @@ import { ApiError, api, streamCopilot, streamRun } from '../api/client' import type { CopilotOp, FixKind, RunFinal } from '../run/decode' import { decodeRun } from '../run/decode' import { errorMessage, humanizeError, isNetworkError } from '../lib/errors' +import { guardReview, hasEvidence } from '../lib/evidence' import { explainRunError } from '../lib/explain' import { parseServerTime } from '../lib/format' import { useCatalog } from './catalog' @@ -137,6 +138,11 @@ export interface TurnMeta { clipped?: 'partial' | 'lost' /** 这一问限定了只查哪些数据源 */ scope?: ScopeSource[] + /** + * 成果里有逐段证据(报告撰写节点的文档)。库里只存答案的文字,证据的标注和字段名在 + * 运行的成果里:有这个记号的轮次恢复时要回运行那里取一次 + */ + evidence?: boolean /** * 这份 meta 写下的时刻(本机时钟)。断在建图阶段的轮次按它和 created_at 里晚的那个算 * 有多久没动静:后端按 updated_at 算,而重试一轮昨天的问题时 created_at 是昨天的 @@ -429,6 +435,7 @@ function buildMeta(t: ChatTurn): TurnMeta { queries: t.events.length ? countQueries(t.events) : t.meta?.queries, clipped: t.clipped, scope: t.scope?.length ? t.scope : undefined, + evidence: hasEvidence(t.output) || t.meta?.evidence || undefined, at: Date.now(), } } @@ -665,6 +672,8 @@ function needsEagerHydrate(t: ChatTurn): boolean { if (t.phase === 'checking' || t.phase === 'suspended') return true // 「接着跑」给不给取决于运行现在的状态;老数据没存过 if (t.phase === 'error' && !t.runStatus) return true + // 有逐段证据:标注在运行的成果里,库里那份答案只有文字 + if (t.phase === 'done' && t.meta?.evidence && !hasEvidence(t.output) && !t.review?.answer) return true // 答案没取全:运行还在就把完整版补回来 return t.phase === 'done' && !t.review?.answer && (t.clipped === 'partial' || (typeof t.output?.answer === 'string' && atClip(t.output.answer))) @@ -1026,6 +1035,10 @@ export const useChat = create((set, get) => ({ if (repaired) { patch(() => ({ output: run.output, clipped: undefined })) void persist(conversationId, turnId, { answer: full }) + } else if (hasEvidence(run.output) && !hasEvidence(cur.output) && !cur.review?.answer) { + // 库里只存了答案的文字,逐段证据的标注(_evidence)和字段名都在运行的成果里: + // 换回运行那份,刷新之后报告里的数字照样能点开。复核改写过的不换(有证据的不会被改写) + patch(() => ({ output: run.output, clipped: undefined })) } else if (issuance && !cur.output?._issuance) { patch((t) => ({ output: t.output ? { ...t.output, _issuance: issuance } : t.output })) } @@ -1683,6 +1696,9 @@ async function settle( // 复核期间用户点了停止或者重试:这一轮已经不归这次运行管了 if (!flow.alive()) return + // 成果带逐段证据时复核只能加说明:改写会把成果整个换成 {answer},报告文档的每个片段 + // 就都对不上了。后端也拦,这里再拦一道,老后端回来的改写同样不落到界面和库里 + result = guardReview(result, output) // 库里存完整结论(包括 verdict='ok'),store 里只留值得摆到界面上的那部分。 // 两者分开,"这一轮没复核过"和"复核过、没发现问题"才是两件可区分的事—— // 事后排查"它当时为什么没警告我",靠的就是这个区别 diff --git a/frontend/src/store/evidence.ts b/frontend/src/store/evidence.ts new file mode 100644 index 00000000..634a8cb1 --- /dev/null +++ b/frontend/src/store/evidence.ts @@ -0,0 +1,91 @@ +import { create } from 'zustand' +import { api } from '../api/client' +import type { EvidenceDocData, EvidenceGraph, EvidenceSegmentDetail } from '../types' + +/** + * 可点击证据的缓存:报告文档、证据图、点开过的片段。 + * + * - 文档按工件 id 缓存,永久:工件内容寻址,同一个 id 永远是同一份内容,取回时后端 + * 还复验了哈希。同一份报告在问数据页、画布右栏、记录页各出现一次也只取一次。 + * - 片段按 `runId:segId` 缓存:点开时才取,同一个片段来回点不重复请求。 + * - 证据图按运行缓存:面板里封存状态的兜底来源。 + * + * 出错的不缓存成定论:下次点开再取一次(后端刚重启、接口还没部署到这个版本都会是 + * 一时的)。正在取的不重复发。 + */ + +export interface Slot { + status: 'loading' | 'ok' | 'error' + data?: T + error?: unknown +} + +interface EvidenceState { + /** + * 哪一份报告的面板开着。同一屏上可能有好几份报告(问数据页的几轮、改写前的原件), + * 侧边面板只有一个位置:新打开的认领它,别的自己收起 + */ + owner: string | null + claim: (owner: string) => void + docs: Record> + graphs: Record> + segments: Record> + loadDoc: (artifact: string) => Promise + loadGraph: (runId: string) => Promise + loadSegment: (runId: string, segmentId: string, report?: string) => Promise +} + +/** + * 片段的缓存键:`runId:segId`。一次运行里有好几份报告时片段 id 会撞(每份都从 s0 数起), + * 带上报告节点:`runId:segId@report` + */ +export const segmentKey = (runId: string, segmentId: string, report?: string) => + report ? `${runId}:${segmentId}@${report}` : `${runId}:${segmentId}` + +/** 工件内容是不是一份报告文档:至少得有块。别的工件(指标集、查询快照)点进来不能当文档画 */ +export function asDoc(content: unknown): EvidenceDocData | null { + if (!content || typeof content !== 'object') return null + const doc = content as EvidenceDocData + return Array.isArray(doc.blocks) ? doc : null +} + +export class NotADocError extends Error { + constructor() { + super('这件工件不是报告文档') + this.name = 'NotADocError' + } +} + +export const useEvidence = create((set, get) => { + /** 取一次、记进 table[key];正在取或已经取到的不再发 */ + async function fill( + table: K, key: string, fetcher: () => Promise>, + ) { + const cur = get()[table][key] + if (cur && cur.status !== 'error') return + set((s) => ({ [table]: { ...s[table], [key]: { status: 'loading' } } }) as Partial) + try { + const data = await fetcher() + set((s) => ({ [table]: { ...s[table], [key]: { status: 'ok', data } } }) as Partial) + } catch (error) { + set((s) => ({ [table]: { ...s[table], [key]: { status: 'error', error } } }) as Partial) + } + } + + return { + owner: null, + claim: (owner) => { if (get().owner !== owner) set({ owner }) }, + docs: {}, + graphs: {}, + segments: {}, + loadDoc: (artifact) => fill('docs', artifact, async () => { + const res = await api.artifact(artifact) + const doc = asDoc(res?.content) + if (!doc) throw new NotADocError() + return doc + }), + loadGraph: (runId) => fill('graphs', runId, () => api.evidence.graph(runId)), + loadSegment: (runId, segmentId, report) => + fill('segments', segmentKey(runId, segmentId, report), () => api.evidence.segment(runId, segmentId, { report })), + } +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8564f4a2..81265a5d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,7 +1,7 @@ export type NodeType = | 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code' | 'branch' | 'loop' | 'subgraph' | 'memory' | 'retrieve' | 'transform' - | 'human' | 'validate' | 'metrics' + | 'human' | 'validate' | 'metrics' | 'report' export interface GraphNode { id: string @@ -503,3 +503,215 @@ export interface ReviewResult { severity: 'broken' | 'degraded' | '' signals: ReviewSignal[] } + +// ------------------------------------------------------------------------- +// 可点击证据(报告撰写节点产出的文档、证据接口) +// +// 形状照后端 engine/evidence.py 和 api/evidence.py。前端对缺字段一律宽容:接口和文档 +// 是分期长出来的,老运行、别的版本的后端都可能少几个键,少了就降级显示,不白屏。 +// 偏移(span、start/end)按 Unicode 码点算,不是 JS 的 UTF-16 下标。 +// ------------------------------------------------------------------------- + +/** 一处引用解析后的结果 */ +export interface EvidenceCitation { + /** 不带前缀的引用,如 gmv、week */ + ref?: string + /** 目录里的别名,如 m:gmv、i:week、Q1 */ + alias?: string + locator?: { metric?: string; field?: string; row?: number; column?: string; table?: string } + eid?: string + /** metric / input / cell / table / column / quote / query */ + kind?: string + role?: string + status?: 'resolved' | 'unresolved' | string + /** 解析不了的原因(人话) */ + reason?: string + conv?: string + value?: unknown + rendered?: string +} + +/** 可点的最小片段 */ +export interface EvidenceSegment { + id: string + /** text / number / value / entity / quote / structural */ + kind: string + text: string + span?: [number, number] + /** deterministic / none / neutral,后续期还有 probabilistic、candidate */ + state?: string + /** 带前缀的原始引用,如 m:gmv|万 */ + ref?: string + cite?: EvidenceCitation + strong?: boolean + /** uncited_number / unresolved_ref */ + issue?: string +} + +/** 句子、列表项或表格单元格 */ +export interface EvidenceUnit { + id: string + /** claim / connective / heading / code。确定性规则分的,不是裁判结论 */ + kind?: string + span?: [number, number] + cites?: string[] + see?: EvidenceCitation[] + segments: EvidenceSegment[] + /** 表格单元格的位置,表头 row = -1 */ + loc?: { row: number; col: number } + /** 列表项的缩进层级 */ + depth?: number +} + +export interface EvidenceBlock { + id: string + /** heading / paragraph / list / table / quote / code / hr */ + type: string + level?: number + ordered?: boolean + start?: number + lang?: string + units: EvidenceUnit[] +} + +export interface EvidenceViolation { + /** uncited_number / unresolved_ref / render_mismatch / eid_mismatch / … */ + code: string + message?: string + span?: [number, number] + text?: string + /** 指向的片段。结构片段(列表序号、代码围栏标签)里的数字没有可画线的文字,只能在清单里找 */ + segment?: string + unit?: string + ref?: string + context?: string +} + +export interface EvidenceStats { + units?: number + segments?: number + claims?: number + connective?: number + numbers?: number + numbers_cited?: number + values?: number + uncited_numbers?: number + unresolved?: number + see?: number + uncited_claims?: number + violations?: number +} + +/** report_doc 工件的内容 */ +export interface EvidenceDocData { + schema?: string + run_id?: string | null + node_id?: string + /** 渲染后的全文(真实数字,不含标记) */ + markdown?: string + /** 建文档时的完整目录:alias → 条目 */ + catalog?: Record> + blocks: EvidenceBlock[] + stats?: EvidenceStats + violations?: EvidenceViolation[] +} + +/** + * 成果上的标注:这几个字段逐字等于某个报告撰写节点的文档,可以逐段点开看证据。 + * 契约 report_from 指着的那份在最外层;另有报告也被原样放进成果的,列在 others 里 + */ +export interface EvidenceFieldRef { + report_node?: string + doc_artifact?: string + fields?: string[] + others?: EvidenceFieldRef[] +} + +/** 封存状态 */ +export interface EvidenceSeal { + sealed?: boolean + ok?: boolean | null + /** 这件证据在不在封存范围内 */ + covered?: boolean + manifest_seq?: number + legacy?: boolean +} + +/** GET /runs/{id}/evidence:整次运行的证据图 */ +export interface EvidenceGraph { + run_id?: string + schema?: string + /** cited / legacy_contract / legacy_text / none */ + mode?: string + seal?: EvidenceSeal + reports?: { node_id?: string; doc_artifact?: string; doc_sealed?: boolean; fields?: string[]; stats?: EvidenceStats + /** 文档取回时哈希复验:false 对不上(或不是这次运行这个节点写的),null 文件不在了 */ + hash_ok?: boolean | null }[] + evidence?: { alias?: string; eid?: string; kind?: string; label?: string; node_id?: string; artifact?: string + sealed?: boolean; cited_by?: string[] }[] + edges?: { from: string; to: string; rel: string }[] +} + +/** 指标的一个输入:值从哪来 */ +export interface EvidenceInput { + path?: string + value?: unknown + node_id?: string + via?: string + field?: string + role?: string + /** ok / missing */ + status?: string +} + +/** + * 片段证据链的一步。本期有三种:metric(指标)、input(指标的一个输入,也可能挂在 + * metric.inputs 里)、run_input(报告直接引用的运行输入) + */ +export interface EvidenceStep { + step: string + metric?: string + decimals?: number | null + format?: string + conv?: string | null + eid?: string + /** eid 能由工件和定位重算出来 */ + eid_ok?: boolean + /** 工件取回时哈希复验通过、指标在卡里 */ + hash_ok?: boolean + /** 卡里的值按同样的格式渲染出来就是报告上的字 */ + render_ok?: boolean + /** 这件证据在封存范围内 */ + sealed?: boolean + alias?: string + name?: string + value?: unknown + unit?: string + rendered?: string + caliber?: string + version?: string + node_id?: string + artifact?: string + expression?: string + substituted?: string + recompute_ok?: boolean | null + status?: string + inputs?: EvidenceInput[] + path?: string + field?: string + via?: string +} + +/** GET /runs/{id}/evidence/segments/{sid}:点开一个片段 */ +export interface EvidenceSegmentDetail { + report?: { node_id?: string; doc_artifact?: string } + segment?: Partial & { unit?: string } + unit?: { id?: string; kind?: string; text?: string; span?: [number, number]; cites?: string[] } + block?: { id?: string; type?: string } + chain?: EvidenceStep[] + /** 片段为什么是现在这个状态,一句人话 */ + note?: string + /** 落在这个片段(或这一句、没有片段的)上的违规 */ + violations?: EvidenceViolation[] + seal?: EvidenceSeal +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index a26c17d2..230d54f4 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -34,6 +34,7 @@ const ORDER = [ 'guards', // 输入法、未知节点、错误边界 'shell', // 外壳:导航、徽标、命令面板、离开前确认、启动页 'stream', // 回答流的渲染 + 'evidence', // 可点击证据:报告逐段渲染、证据面板、旧契约按位置标记 'run-states', // 节点卡的运行态 'canvas-fx', // 画布表层的运行态 'studio', // 编排页的编辑 @@ -45,7 +46,8 @@ const ORDER = [ const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000) // 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截, // 却照样报「全部通过」,所以一律不传给子进程 -const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME'] +const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME', + 'EVIDENCE_ONLY'] // 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒 const unlisted = readdirSync(HERE) .map((f) => f.match(/^check-(.+)\.mjs$/)?.[1]) diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 87b634e0..ddb96861 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -31,7 +31,7 @@ // 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时 // 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-chat.mjs import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' -import { mkdirSync } from 'node:fs' +import { mkdirSync, readFileSync } from 'node:fs' const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' const CHROME = process.env.CHROME_PATH @@ -273,7 +273,13 @@ const DETAIL = { // 要改的是工具库里的参数定义,不在这一轮的流程里:先去改,再接着跑(没记 fix 的老写法,按原话认) c0fixy: [turn('ty1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL, graph: GRAPH, run_id: 'run-badtool' })], } +/** + * 成果带逐段证据的一轮(报告撰写节点 + _evidence)。夹具是后端 compose_doc 真跑出来的,只用 + * 通用名。复核回的是一次改写:有证据时复核只能加说明,答案不能被换掉 + */ +const EVIDENCE = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8')) const RUNS = { + 'run-evid': { status: 'succeeded', output: EVIDENCE.output, error: null }, 'run-cancelled': { status: 'cancelled', output: {}, error: null }, 'run-failed': { status: 'failed', output: {}, error: '查询超时' }, 'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' }, @@ -406,6 +412,12 @@ async function fakeApi(route) { if (path === '/copilot/review' && method === 'POST') { const wait = ctl.reviewDelay[body().run_id] if (wait) await new Promise((r) => setTimeout(r, wait)) + // 老后端的复核不认 _evidence,照样回一次改写 + if (body().run_id === 'run-evid') { + return json({ verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多', + original: EVIDENCE.output['周报'], retry: false, severity: 'degraded', + signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] }) + } return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] }) } if (path === '/runs' && method === 'POST') { @@ -413,7 +425,8 @@ async function fakeApi(route) { const conversation = String(b.input?.question ?? '') // 发起请求根本没到后端(网断了、后端正在重启) if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused') - const id = conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') + const id = conversation.includes('带证据') ? 'run-evid' + : conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') : conversation.includes('审批') ? 'run-wait' : conversation.includes('重启') ? 'run-restart' : conversation.includes('正在写') ? 'run-writing' @@ -478,6 +491,18 @@ function script(runId, after = 0) { end: 'succeeded', } } + if (runId === 'run-evid') { + return { + events: [ + ev(1, 'run.started', null, { nodes: 3 }), + ev(2, 'node.started', 'write', { node_type: 'report', label: '写周报' }), + ev(3, 'report.checked', 'write', EVIDENCE.report_checked), + ev(4, 'node.finished', 'write', { duration_ms: 900 }), + ev(5, 'run.finished', null, { output: EVIDENCE.output }), + ], + end: 'succeeded', + } + } if (runId === 'run-long') { return { events: [ @@ -876,6 +901,37 @@ for (const theme of THEMES) { await ctx.close() }) + await section('evidence', '成果带逐段证据:复核只加说明,不改写答案(可点击证据第一期)', async () => { + const { page, ctx, errors } = await open(theme) + // 文档记着的运行要和成果的运行对得上(run-evid),不然前端按「别的运行写的」退回普通文本 + await page.route(/\/api\/artifacts\//, (route) => route.fulfill({ json: { id: EVIDENCE.doc_artifact, + content: { ...EVIDENCE.doc, run_id: 'run-evid' } } })) + await page.route(/\/api\/runs\/run-evid\/evidence(\/.*)?(\?.*)?$/, (route) => route.fulfill({ json: EVIDENCE.graph })) + const patchesBefore = log.patches.length + await goto(page, 'c0trunc') + await send(page, '写一份带证据的周报') + check('答完了', await page.waitForFunction( + () => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'done', null, { timeout: 10000 }) + .then(() => true, () => false)) + const t = (await chatState(page)).byConversation.c0trunc.at(-1) + check('复核的改写没有落到答案上:成果还是报告原文和它的证据标注', + t.output?.['周报'] === EVIDENCE.output['周报'] && !!t.output?._evidence && !t.output?.answer, JSON.stringify(Object.keys(t.output ?? {}))) + check('复核的说明照样摆出来,改写的文字丢掉', t.review?.note?.includes('检索降级过') && t.review.answer === null + && t.review.verdict === 'annotated' && !t.rawOutput, JSON.stringify(t.review)) + await page.waitForSelector('[data-evidence-doc]', { timeout: 5000 }).catch(() => {}) + check('页面上是逐段可点的报告,不是改写后的文字', await page.locator('[data-evidence-doc] [data-seg]').count() > 0 + && !(await shows(page, '改写后的答案'))) + check('没有「复核改写过这个答案」的对照', await page.getByText('复核改写过这个答案').count() === 0) + await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) + const saved = log.patches.slice(patchesBefore) + check('落库的是报告原文,不是改写', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer) + .every((p) => p.body.answer === EVIDENCE.output['周报']), saved.map((p) => String(p.body.answer ?? '').slice(0, 12)).join(' | ')) + check('meta 记下这一轮有证据(刷新后回运行那里取标注)', saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true) + check('落库的复核也不带改写', saved.filter((p) => p.body.review?.verdict).every((p) => !p.body.review.answer)) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + }) + await section('restart', '服务重启:interrupted 且没有待审批,收尾而不是转圈', async () => { const { page, ctx, errors } = await open(theme) await goto(page, 'c0resti') diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 51d092b4..359f4b5a 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1049,6 +1049,82 @@ await section('分支、循环出口的说法(runfx-9、终验 NEW)', async check('没有出口的节点不进表', labelOf('x', 'out') === undefined) }) +await section('报告核对 report.checked、口径卡的台账(可点击证据第一期)', async () => { + // 夹具由后端 evidence.py 的 compose_doc 真跑生成(只用通用名),report_checked 是报告节点发的那条事件的载荷 + const evidence = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8')) + const rc = evidence.report_checked + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + // 修复重写那条日志的原话:和 backend/app/engine/nodes/report.py 发的同一个格式(_named 用「、」 + // 接着点名的字)。下面先核对后端源码里还是这个格式,免得这里的夹具自说自话 + const REPAIR_MESSAGE = '报告里有 3 处没通过核对(「12」、「m:nope」、「3」),已要求写作者重写(第 1 次)' + const reportPy = readFileSync(`${root}backend/app/engine/nodes/report.py`, 'utf8') + check('后端发修复日志的格式还是夹具里这一种', reportPy.includes('code="report_repair"') + && reportPy.includes('处没通过核对({_named(blocking)}),') && reportPy.includes('已要求写作者重写(第 {repairs} 次)') + && reportPy.includes('"、".join(f"「{n}」"'), '改了 report.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具') + const card = evidence.doc.catalog['m:gmv'].artifact + const events = [ + E(1, 'run.started', null, { total: 3 }), + E(2, 'node.started', 'caliber', { node_type: 'metrics', label: '周报口径' }), + // 口径卡的 node.finished 多了台账(evidence 数组),产出里多了 artifact 字符串:照常解码、不另起行 + E(3, 'node.finished', 'caliber', { duration_ms: 12, + preview: { kind: 'metric_set', caliber: '周报口径', caliber_version: 'v2', artifact: card, text: '销售额 = 45,678.5元' }, + evidence: [{ kind: 'metric_set', node_id: 'caliber', exec: 1, artifact: card, caliber: '周报口径', version: 'v2', metrics: ['gmv'] }] }), + E(4, 'node.started', 'write', { node_type: 'report' }), + // 后端 report.py 原样的说法:「报告里有 N 处没通过核对(点名的字),已要求写作者重写(第 N 次)」 + E(5, 'log', 'write', { level: 'warn', code: 'report_repair', message: REPAIR_MESSAGE }), + E(6, 'report.checked', 'write', rc), + E(7, 'node.finished', 'write', { duration_ms: 2300, preview: { text: evidence.doc.markdown.slice(0, 40), doc_artifact: rc.doc_artifact } }), + E(8, 'run.finished', null, { output: {} }), + ] + const steps = mod.decodeRun(events) + const all = flatten(steps) + check('新事件都有翻译,没有「一条还没翻译的记录」', !all.some((s) => s.title === '一条还没翻译的记录'), + all.filter((s) => s.title === '一条还没翻译的记录').map((s) => s.detail?.split('\n')[0]).join('、')) + const node = steps.find((s) => s.nodeId === 'write' && s.kind === 'node') + check('没起名的报告节点叫「报告撰写」', node?.title === '报告撰写', node?.title) + const row = all.find((s) => s.code === 'report_checked') + check('report.checked 解码成一条步骤,挂在报告节点下面', !!row && !!node?.children?.includes(row), row?.title) + check('标题说几个数字有出处、几个没有(算得平:12 − 7 = 5),非数字引用另起一句(和横幅同一种说法)', + row?.title === '核对报告:7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了', row?.title) + check('有无证据的地方就是提醒,不是安静的一行', row?.level === 'warn', row?.level) + check('能下钻到报告文档', row?.artifact === rc.doc_artifact, row?.artifact) + check('违规清单放进展开区', !!row?.detail?.includes('数字「12」没有出处') && !!row.detail.includes('这种引用在后续版本支持'), + row?.detail?.slice(0, 80)) + check('重写过几次写在行尾', row?.meta === '重写 1 次', row?.meta) + const repair = all.find((s) => s.code === 'report_repair') + check('修复重写说人话:几处没通过、第几次,原话留在展开区', repair?.title === '报告有 3 处没通过核对,已让模型按清单重写(第 1 次)' + && repair.detail === REPAIR_MESSAGE, `${repair?.title} / ${repair?.detail}`) + check('点名的字放进副标题(「12」「m:nope」这类)', repair?.sub === '「12」、「m:nope」、「3」', repair?.sub) + const oldSaying = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report' }), + E(2, 'log', 'w', { level: 'warn', code: 'report_repair', message: '报告要重写' })])).find((s) => s.code === 'report_repair') + check('别的说法(老后端):标题照样说人话,不瞎填数', oldSaying?.title === '报告没通过核对,已让模型按清单重写' + && oldSaying.sub === undefined, `${oldSaying?.title} / ${oldSaying?.sub}`) + const caliber = steps.find((s) => s.nodeId === 'caliber') + check('口径卡带台账的 node.finished 照常收成完成', caliber?.status === 'done' && !caliber.children?.length, + `${caliber?.status} ${caliber?.children?.length ?? 0}`) + + const clean = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写周报' }), + E(2, 'report.checked', 'w', { doc_artifact: 'abc', repairs: 0, + stats: { numbers: 7, numbers_cited: 7, uncited_numbers: 0, unresolved: 0 }, violations: [] })])) + .find((s) => s.code === 'report_checked') + check('全都有出处:一句话说完,不是提醒', clean?.title === '核对报告:7 个数字都有出处' && clean.level !== 'warn' + && !clean.meta, `${clean?.title} ${clean?.level} ${clean?.meta}`) + const blocked = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写周报' }), + E(2, 'report.checked', 'w', { doc_artifact: 'abc', repairs: 1, ok: false, on_violation: 'fail', failed: true, + stats: { numbers: 3, numbers_cited: 2, uncited_numbers: 1, unresolved: 0 }, + violations: [{ code: 'uncited_number', message: '数字「45678」没有出处' }] })])) + .find((s) => s.code === 'report_checked') + check('fail 模式下仍不过:这一步画成失败,不是完成', blocked?.status === 'failed' && blocked.level === 'error', + `${blocked?.status} ${blocked?.level}`) + const flagged = flatten(mod.decodeRun([E(1, 'report.checked', 'w', { ok: false, on_violation: 'flag', failed: false, + stats: { numbers: 3, numbers_cited: 2, uncited_numbers: 1, unresolved: 0 }, violations: [] })])) + .find((s) => s.code === 'report_checked') + check('flag 模式照常产出:完成、提醒级', flagged?.status === 'done' && flagged.level === 'warn', + `${flagged?.status} ${flagged?.level}`) + const bare = flatten(mod.decodeRun([E(1, 'report.checked', null, {})])).find((s) => s.code === 'report_checked') + check('载荷缺字段也不崩,只说核对了报告', bare?.title === '核对报告' && !bare.artifact, bare?.title) +}) + await section('术语', async () => { // 没起名的节点退到类型名,类型名跟全站同一张表 const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }]) diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs new file mode 100644 index 00000000..e2bea00a --- /dev/null +++ b/scripts/check-evidence.mjs @@ -0,0 +1,697 @@ +// 可点击证据(第一期:数字层)的回归检查:报告文档逐段渲染、证据面板、键盘、读屏、 +// 减少动效、窄栏,以及旧契约「按位置标记」和没有 _evidence 的旧回答。 +// +// 守的是这些「看着没坏、其实用不了」的地方:两种状态的下划线长得一样(去掉颜色就分不 +// 出有没有出处)、整份报告每个数字各占一个 Tab 位、面板关了焦点丢到页首、减少动效时面板 +// 照样滑进来、360px 的画布右栏被面板撑出横向滚动、日期里的 15 被当成回指不上的 15、 +// 旧运行的答案被新组件改了样子。 +// +// 跑在预览页上:/ui-harness.html?evidence=1(组件)和 /preview.html?syn=evidence(问数据页 +// 的答案形态)。文档是后端 compose_doc 真跑出来的夹具(frontend/src/run/__tests__/ +// evidence-doc.json,只用通用名);片段接口、证据图、报告工件都用 page.route 伪造,非 GET +// 一律拦掉,不写库。 +// +// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时 +// 带上地址: +// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-evidence.mjs +// EVIDENCE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。 +import { readFileSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const CHROME = process.env.CHROME_PATH + ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const SHOTS = process.env.EVIDENCE_SHOTS +const root = new URL('..', import.meta.url).pathname +const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8')) + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +// EVIDENCE_ONLY=look,keys 只跑这几节(改坏验证时省时间);check-all 不会把它传下来 +const ONLY = (process.env.EVIDENCE_ONLY ?? '').split(',').filter(Boolean) +async function section(key, name, fn) { + if (ONLY.length && !ONLY.includes(key)) return + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } +} + +const browser = await chromium.launch({ executablePath: CHROME }) + +/** 伪造证据相关的 GET;片段请求记账(缓存:同一个片段点两次只取一次) */ +async function routed(page) { + const hits = [] + await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => { + const req = r.request() + const url = new URL(req.url()) + if (req.method() !== 'GET') return r.abort() + const seg = url.pathname.match(/\/api\/runs\/[^/]+\/evidence\/segments\/([^/]+)$/) + if (seg) { + hits.push(decodeURIComponent(seg[1])) + const body = fx.segments[decodeURIComponent(seg[1])] + return body ? r.fulfill({ json: body }) + : r.fulfill({ status: 404, json: { detail: '报告里没有这个片段', code: 'evidence_segment_not_found' } }) + } + if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: fx.graph }) + if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } }) + return r.continue() + }) + return hits +} + +async function open(url, { w = 1280, h = 1000, reduced = false } = {}) { + const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) }) + const page = await ctx.newPage() + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + const hits = await routed(page) + await page.goto(`${WEB}${url}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('[data-evidence-doc], [data-turn]', { timeout: 8000 }) + return { ctx, page, errors, hits } +} + +const active = (page) => page.evaluate(() => document.activeElement?.getAttribute('data-seg') + ?? (document.activeElement?.closest('[data-evidence-panel]') ? 'panel' : document.activeElement?.tagName ?? null)) +const panel = (page) => page.locator('[data-evidence-panel]') + +let harness +try { + harness = await open('/ui-harness.html?evidence=1') +} catch (e) { + console.error(`✗ 打不开预览页(${WEB}/ui-harness.html?evidence=1)——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`) + await browser.close() + process.exit(1) +} +const { page } = harness +const wide = '#evidence-wide' + +await section('look', '两种状态四通道都分得开:线型、字形、颜色、文字', async () => { + const look = await page.evaluate((sel) => { + const pick = (state) => { + const el = document.querySelector(`${sel} [data-ev-state="${state}"]`) + if (!el) return null + const cs = getComputedStyle(el) + return { line: cs.textDecorationLine, style: cs.textDecorationStyle, color: cs.textDecorationColor, + tag: el.tagName, type: el.getAttribute('type'), font: cs.fontSize, fg: cs.color } + } + const body = getComputedStyle(document.querySelector(`${sel} [data-evidence-doc] p`)).color + return { det: pick('deterministic'), none: pick('none'), body } + }, wide) + check('确定性画实线下划线', look.det?.line.includes('underline') && look.det.style === 'solid', JSON.stringify(look.det)) + check('无证据画点状下划线', look.none?.line.includes('underline') && look.none.style === 'dotted', JSON.stringify(look.none)) + check('两种线型不同(读计算后的 text-decoration-style)', look.det?.style !== look.none?.style) + check('两种下划线颜色也不同', look.det?.color !== look.none?.color, `${look.det?.color} / ${look.none?.color}`) + check('片段是原生 button type=button', look.det?.tag === 'BUTTON' && look.det.type === 'button') + check('片段外观和正文一样:字色、字号继承正文', look.det?.fg === look.body, `${look.det?.fg} vs ${look.body}`) + const labels = await page.evaluate((sel) => Object.fromEntries(['s8', 's16', 's33', 's4'].map((id) => + [id, document.querySelector(`${sel} [data-seg="${id}"]`)?.getAttribute('aria-label') ?? ''])), wide) + check('aria-label 写了状态和出处(有出处)', labels.s8 === '8.7%,有出处:口径卡指标 环比增幅', labels.s8) + check('aria-label 写了状态和原因(裸数字)', labels.s16.startsWith('12,无证据:'), labels.s16) + check('aria-label 写了解析不了的原因', labels.s33.includes('无证据') && labels.s33.includes('显示为 0'), labels.s33) + check('运行输入也写明出处', labels.s4 === '2026-W37,有出处:运行输入 week', labels.s4) + const tags = await page.locator(`${wide} .ev-tag`).allInnerTexts() + check('含无证据的句子末尾挂「?无证据」(字形 + 文字),表格格子里不挂', tags.length === 5 && tags.every((t) => t === '?无证据'), + tags.join('|')) + const summary = await page.locator(`${wide} [data-evidence-summary]`).innerText().catch(() => '') + check('顶部有一段读屏摘要,说清计数和按键', summary.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && summary.includes('方向键'), summary) + check('读屏摘要说清画不了线的在哪:列表序号里的数字,不混说成「句末依据」', summary.includes('其中 1 个数字在列表序号') + && !summary.includes('句末依据'), summary) + const sr = await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [data-evidence-summary]`) + const r = el?.getBoundingClientRect() + return !!r && r.width <= 1 && r.height <= 1 + }, wide) + check('读屏摘要不占视觉位置(sr-only)', sr) + const tally = await page.locator(`${wide} [data-evidence-tally]`).innerText().catch(() => '') + check('没有出具横幅时文档自己给出计数条', tally.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && tally.includes('定位下一处'), tally) + const [cited, total, gaps] = (tally.match(/(\d+)\/(\d+) 数字有出处 · 无证据 (\d+)/) ?? []).slice(1).map(Number) + check('计数算得平:无证据 = 总数 − 有出处', total - cited === gaps, tally) +}) + +await section('keys', '键盘:整份报告一个 Tab 位,←/→、↑/↓、n/N', async () => { + const stops = await page.locator(`${wide} [data-seg][tabindex="0"]`).count() + const segs = await page.locator(`${wide} [data-seg]`).count() + check('只有一个片段在 Tab 顺序里(roving tabindex)', stops === 1 && segs > 5, `${stops}/${segs}`) + await page.locator(`${wide} [data-evidence-list]`).focus() + await page.keyboard.press('Tab') + check('Tab 进报告落在第一个片段', await active(page) === 's4', await active(page)) + await page.keyboard.press('Tab') + // 下一个 Tab 位是表格的「复制为 CSV」之类的控件,不能是另一个片段 + const next = await active(page) + check('再按一次 Tab 不会落到另一个片段上', !/^s\d+$/.test(String(next)), String(next)) + await page.locator(`${wide} [data-seg="s4"]`).focus() + const walk = [] + for (const key of ['ArrowRight', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp']) { + await page.keyboard.press(key) + walk.push(await active(page)) + } + check('→ → ← 在片段间走', walk.slice(0, 3).join(',') === 's6,s8,s6', walk.join(',')) + check('↓ 到下一句的第一个片段,↑ 回来', walk[3] === 's16' && walk[4] === 's4', walk.join(',')) + const stop0 = await page.locator(`${wide} [data-seg][tabindex="0"]`).getAttribute('data-seg') + check('Tab 位跟着焦点走', stop0 === 's4', stop0) + const jumps = [] + for (const key of ['n', 'n', 'Shift+N', 'N']) { + await page.keyboard.press(key) + jumps.push(await active(page)) + } + check('n 跳到下一处无证据、N / Shift+N 回上一处', jumps.join(',') === 's16,s26,s16,s36', jumps.join(',')) + await page.keyboard.press('End') + check('End 到最后一个片段', await active(page) === 's49', await active(page)) + await page.keyboard.press('ArrowRight') + check('→ 首尾相接', await active(page) === 's4', await active(page)) + check('只是走动不打开面板', await panel(page).count() === 0) +}) + +await section('panel', '面板:回车打开、能看到代入式、Esc 关掉焦点回原片段', async () => { + await page.locator(`${wide} [data-seg="s8"]`).focus() + await page.keyboard.press('Enter') + await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]', { timeout: 4000 }) + const p = panel(page) + check('宽屏从侧边弹出', await p.getAttribute('data-evidence-panel') === 'side') + const sub = await p.locator('[data-ev-substituted]').innerText() + check('代入式', sub === 'round((45678.5 - 42010.0) / 42010.0 * 100, 1)', sub) + check('原式', (await p.locator('[data-ev-expression]').innerText()).includes('vars.kpi.gmv_prev')) + const chips = await p.locator('[data-ev-input]').allInnerTexts() + check('每个输入是「值 ← 路径」的小标签', chips.join('|') === '45,678.5 ← vars.kpi.gmv|42,010 ← vars.kpi.gmv_prev', chips.join('|')) + const text = await p.innerText() + check('指标名称、值、口径与版本', text.includes('环比增幅') && text.includes('8.7%') && text.includes('口径卡「周报口径」') && text.includes('v2')) + check('复算结果', await p.locator('[data-ev-recompute]').getAttribute('data-ev-recompute') === 'ok' && text.includes('复算一致')) + check('封存状态用状态徽标', await p.locator('[data-ev-seal] svg[data-status="done"]').count() === 1 && text.includes('已封存 · 核对一致')) + check('所在的句子里点开的那段描底', (await p.locator('[data-ev-sentence] [data-ev-here]').innerText()) === '8.7%') + check('片段标着展开了哪个面板', await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-expanded') === 'true' + && !!(await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-controls'))) + check('焦点留在片段上,读屏从 live region 听到标题', await active(page) === 's8' + && (await page.locator(`${wide} [aria-live="polite"]`).innerText()).includes('8.7%,有出处')) + + await page.keyboard.press('ArrowRight') + await page.waitForTimeout(150) + check('面板开着时方向键走到哪,面板跟到哪', (await p.locator('h3').innerText()) === '1,234单', await p.locator('h3').innerText()) + await page.keyboard.press('Escape') + await page.waitForTimeout(150) + check('Esc 关面板,焦点仍在原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page)) + + await page.keyboard.press('Enter') + await page.waitForSelector('[data-evidence-panel]') + await page.keyboard.press('Tab') + check('Tab 从片段直接进面板', await active(page) === 'panel', await active(page)) + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + check('在面板里按 Esc:关掉并把焦点还回原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page)) + + await page.locator(`${wide} [data-seg="s8"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]') + const again = harness.hits.filter((h) => h === 's8').length + check('同一个片段再点开不重复请求(按 runId:segId 缓存)', again === 1, `请求了 ${again} 次`) + await page.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click() + check('关闭按钮', await panel(page).count() === 0) +}) + +await section('none', '没有证据的地方:原因、缺输入 / 显示不出来、违规清单', async () => { + await page.locator(`${wide} [data-seg="s26"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]') + let text = await panel(page).innerText() + check('解析不了的引用说出原因', text.includes('缺输入'), text.slice(0, 80)) + check('value 为 null:写「缺输入」', await panel(page).locator('[data-ev-missing]').count() === 1 + && await panel(page).locator('[data-ev-unshowable]').count() === 0) + check('没有证据的片段不说「这个数已封存」', text.includes('报告文档已封存')) + await page.locator(`${wide} [data-seg="s33"]`).click() + await page.waitForTimeout(200) + text = await panel(page).innerText() + check('value 不为 null、rendered 为「—」:写「有值但显示不出来」,和缺输入分开', + (await panel(page).locator('[data-ev-unshowable]').innerText().catch(() => '')).includes('有值(29)') + && await panel(page).locator('[data-ev-missing]').count() === 0, text.slice(0, 120)) + await page.locator(`${wide} [data-seg="s16"]`).click() + await page.waitForTimeout(200) + text = await panel(page).innerText() + check('裸数字说清没写引用标记', text.includes('没有写成引用标记') && text.includes('要写成引用标记'), text.slice(0, 120)) + + await page.locator('[data-evidence-panel] [data-ev-open-violations]').click() + await page.waitForSelector('[data-ev-violations]') + const items = page.locator('[data-ev-violation]') + check('违规清单列全 6 条', await items.count() === 6, String(await items.count())) + const hundred = items.filter({ hasText: '100' }) + check('列表序号里的 100 在清单里找得到,并说清为什么正文里没画线', + await hundred.getAttribute('data-ev-locatable') === 'no' && (await hundred.innerText()).includes('列表序号'), + await hundred.innerText().catch(() => '')) + await items.filter({ hasText: '数字「3」' }).getByRole('button', { name: '定位' }).click() + await page.waitForTimeout(250) + check('清单里「定位」跳到正文那一处并打开它的证据', await active(page) === 's36' + && (await panel(page).locator('h3').innerText()) === '3', await active(page)) + await page.keyboard.press('Escape') + + await page.locator(`${wide} [data-evidence-next]`).click() + await page.waitForTimeout(250) + const first = await active(page) + const flash = await page.locator(`${wide} [data-seg="s16"]`).getAttribute('data-flash') + await page.locator(`${wide} [data-evidence-next]`).click() + await page.waitForTimeout(250) + check('「定位下一处」依次跳到无证据的片段、描一下边、打开面板', first === 's16' && flash === 'focus' + && await active(page) === 's26' && await panel(page).count() === 1, `${first} ${flash} ${await active(page)}`) + await page.keyboard.press('Escape') +}) + +await section('narrow', '360px:画布右栏里栏内展开,不出横向滚动', async () => { + const narrow = '#evidence-narrow' + const overflow = () => page.evaluate((sel) => { + const el = document.querySelector(sel) + return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width } + }, narrow) + const before = await overflow() + await page.locator(`${narrow} [data-seg="s8"]`).click() + await page.waitForSelector(`${narrow} [data-evidence-panel="inline"] [data-ev-substituted]`) + const after = await overflow() + check('窄栏宽 360px', Math.round(before.w) === 360, String(before.w)) + check('窄栏里没有横向滚动(面板打开前后)', before.sw <= before.cw && after.sw <= after.cw, JSON.stringify({ before, after })) + check('窄栏里的面板在栏内、紧跟着片段所在的块', await page.evaluate((sel) => { + const p = document.querySelector(`${sel} [data-evidence-panel]`) + return p?.previousElementSibling?.getAttribute('data-block') === 'b1' + }, narrow)) + check('栏内面板带「回到正文」', await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).count() === 1) + await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).click() + await page.waitForTimeout(150) + check('回到正文:面板收起,焦点回片段', await page.locator(`${narrow} [data-evidence-panel]`).count() === 0 && await active(page) === 's8') + + const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 }) + const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth })) + const a = await scroll() + await small.page.locator('#evidence-wide [data-seg="s8"]').click() + await small.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]') + const b = await scroll() + check('360px 宽的屏幕:面板从底部抽出', await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer') + check('360px 宽的屏幕:整页没有横向滚动(面板打开前后)', a.sw <= a.vw && b.sw <= b.vw, JSON.stringify({ a, b })) + const box = await small.page.locator('[data-evidence-panel]').boundingBox() + check('抽屉不超出屏幕', !!box && box.x >= 0 && box.x + box.width <= 361, JSON.stringify(box)) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await small.page.waitForTimeout(250) + await small.page.screenshot({ path: `${SHOTS}/evidence-360-drawer-${theme}.png` }) + } + } + check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | ')) + await small.ctx.close() +}) + +await section('motion', '减少动效:面板直接出现,没有动画', async () => { + // 先证明这条检查抓得住:正常情况下面板是滑进来的 + // 全局的减少动效规则把过渡压到 0.01ms(主题色切换那种),那不算动画;动画和更长的过渡才算 + const running = () => { + const p = document.querySelector('[data-evidence-panel]') + if (!p) return -1 + return p.getAnimations({ subtree: true }) + .filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length + } + await page.locator(`${wide} [data-seg="s8"]`).click() + const moving = await page.evaluate(running) + check('正常情况下面板有入场动画(对照)', moving > 0, String(moving)) + await page.keyboard.press('Escape') + const r = await open('/ui-harness.html?evidence=1', { reduced: true }) + await r.page.locator('#evidence-wide [data-seg="s8"]').click() + await r.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]').catch(() => {}) + const still = await r.page.evaluate((fn) => ({ + n: new Function(`return (${fn})()`)(), + name: getComputedStyle(document.querySelector('[data-evidence-panel]')).animationName, + }), running.toString()) + check('减少动效时面板没有任何动画', still.n === 0 && still.name === 'none', JSON.stringify(still)) + await r.page.locator('#evidence-narrow [data-seg="s8"]').click() + const inline = await r.page.evaluate(() => document.querySelector('#evidence-narrow [data-evidence-panel]') + ?.getAnimations({ subtree: true }) + .filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length ?? -1) + check('减少动效时栏内面板也不动', inline === 0, String(inline)) + await r.ctx.close() +}) + +await section('legacy', '旧契约按位置标记(REQ-A-2)', async () => { + const pos = await page.evaluate(() => { + const box = document.querySelector('#legacy-marks') + const marks = [...box.querySelectorAll('[data-mark]')] + return marks.map((m) => ({ tone: m.dataset.mark, text: m.textContent, title: m.title, + before: (m.previousSibling?.textContent ?? '').slice(-3) })) + }) + const warn = pos.filter((m) => m.tone === 'warn') + check('按位置:只标正文里回指不上的那个 15,日期 2026-09-15 里的不标', warn.length === 1 && warn[0].text === '15' + && !warn[0].before.includes('-'), JSON.stringify(warn)) + const amb = pos.filter((m) => m.tone === 'ambiguous') + check('出处不唯一写「出处不唯一:候选 a、b」', amb.length === 2 && amb.every((m) => m.title === '出处不唯一:候选 refund_cnt、reship_cnt'), + JSON.stringify(amb)) + check('不再出现「来自口径卡指标「」」', !pos.some((m) => m.title.includes('指标「」'))) + check('回指上的照常标出来源', pos.some((m) => m.tone === 'ok' && m.text === '1,234' && m.title.includes('orders'))) + const loose = await page.locator('#legacy-loose [data-mark="warn"]').allInnerTexts() + check('老运行没有位置:退回按字符串标(日期里的 15 也会被标,和以前一样)', loose.length === 2 && loose.every((t) => t === '15'), + loose.join('|')) +}) + +await section('chat', '问数据页的答案:带 _evidence 的换成证据文档,没有的和以前一样', async () => { + const chat = await open('/preview.html?syn=evidence') + const p = chat.page + await p.waitForSelector('[data-turn="evidence"] [data-evidence-doc]', { timeout: 6000 }) + const line = await p.locator('[data-turn="evidence"] [data-evidence-line]').innerText().catch(() => '') + check('出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」', line.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && line.includes('定位下一处'), line) + const note = await p.locator('[data-turn="evidence"] [data-unmatched-note]').innerText().catch(() => '') + check('横幅不再说「正文里已用虚线标出」(列表序号里的 100 画不了线),改指违规清单', !note.includes('虚线') + && note.includes('违规清单'), note) + await p.locator('[data-turn="evidence"] [data-issuance-banner] [data-evidence-list]').click() + await p.waitForSelector('[data-evidence-panel] [data-ev-violations]', { timeout: 3000 }).catch(() => {}) + check('横幅上的「违规清单」打开报告的违规清单,100 在里面', (await p.locator('[data-evidence-panel] [data-ev-violation]') + .filter({ hasText: '100' }).count()) === 1) + await p.keyboard.press('Escape') + await p.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click().catch(() => {}) + check('有横幅时文档不再重复计数条', await p.locator('[data-turn="evidence"] [data-evidence-tally]').count() === 0) + await p.locator('[data-turn="evidence"] [data-evidence-next]').click() + await p.waitForSelector('[data-evidence-panel]') + check('横幅的「定位下一处」跳到第一处无证据并打开面板', await active(p) === 's16') + await p.keyboard.press('Escape') + const legacy = await p.evaluate(() => { + const turn = document.querySelector('[data-turn="evidence-legacy"]') + const md = turn?.querySelector('div.space-y-2.leading-relaxed') + const base = document.querySelector('#md-baseline > div') + return { segs: turn?.querySelectorAll('[data-seg], [data-evidence-doc]').length ?? -1, + same: !!md && !!base && md.outerHTML === base.outerHTML, md: !!md, base: !!base } + }) + check('没有 _evidence 的回答不出现证据片段', legacy.segs === 0, String(legacy.segs)) + check('没有 _evidence 的回答渲染和普通 Markdown 一字不差', legacy.same, JSON.stringify(legacy)) + const row = await p.locator('[data-turn="evidence"]').innerText() + check('执行过程里有「核对报告」那一行', row.includes('核对报告:7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了')) + const legacyNote = await p.locator('[data-turn="evidence-legacy"] [data-unmatched-note]').count() + check('没有 _evidence 的旧回答不挂横幅也不挂证据说法', legacyNote === 0) + check('没有运行时报错(问数据页)', chat.errors.length === 0, chat.errors.join(' | ')) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await p.locator('[data-turn="evidence"] [data-seg="s8"]').click() + await p.waitForSelector('[data-evidence-panel] [data-ev-substituted]') + await p.waitForTimeout(350) + await p.screenshot({ path: `${SHOTS}/evidence-chat-${theme}.png` }) + await p.keyboard.press('Escape') + } + } + await chat.ctx.close() +}) + +await section('artifacts', '记录页的工件:口径卡指标集、报告文档有名字(REQ-A-1)', async () => { + // 按页面自己加载的那一份取(热更新过的模块带 ?t=,另 import 一份也是同一套代码,这里只读纯函数) + const got = await page.evaluate(async () => { + const m = await import('/src/pages/runs/model.ts') + return { + metric: m.artifactKindLabel('metric_set'), report: m.artifactKindLabel('report_doc'), + card: m.artifactDescription('metric_set', { caliber: '周报口径', version: 'v2' }), + bare: m.artifactDescription('metric_set', null), doc: m.artifactDescription('report_doc', {}), + evidence: m.isEvidence('metric_set') && m.isEvidence('report_doc'), + } + }) + check('工件种类:口径卡指标集、报告文档', got.metric === '口径卡指标集' && got.report === '报告文档', JSON.stringify(got)) + check('口径卡那件写成「口径卡「x」v2」', got.card === '口径卡「周报口径」v2', got.card) + check('没有 meta 的老数据也有一句说明', got.bare === '口径卡的指标、算式和输入' && got.doc.includes('报告')) + check('两种都算证据(列表里醒目)', got.evidence) +}) + +await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => { + const box = '#evidence-long' + const info = await page.evaluate((sel) => { + const blocks = [...document.querySelectorAll(`${sel} [data-block]`)] + return { n: blocks.length, cv: blocks.map((b) => getComputedStyle(b).contentVisibility), + fold: document.querySelector(sel).innerText.includes('后面还有,这里先折叠了') } + }, box) + check('超过 40 块的报告:每块 content-visibility: auto', info.cv.length > 0 && info.cv.every((v) => v === 'auto'), info.cv.slice(0, 3).join(',')) + check('超过 3000 字先折叠,说清「后面还有」', info.fold && info.n < 128, `${info.n} 块`) + const tally = await page.locator(`${box} [data-evidence-tally]`).innerText() + check('没有 stats、没有违规清单时从片段自己数(16 份:数字 4 处 + 非数字引用 1 处)', + tally.includes('112/176 数字有出处 · 无证据 64 · 另有 16 处引用解析不了'), tally) + await page.locator(`${box} [data-seg="s4-0"]`).focus() + await page.keyboard.press('End') + await page.waitForTimeout(250) + const after = await page.evaluate((sel) => ({ n: document.querySelectorAll(`${sel} [data-block]`).length, + active: document.activeElement?.getAttribute('data-seg') }), box) + check('跳到折叠区里的片段:先展开再聚焦', after.n === 128 && after.active === 's49-15', JSON.stringify(after)) + const stops = await page.locator(`${box} [data-seg][tabindex="0"]`).count() + check('长报告也只占一个 Tab 位', stops === 1, String(stops)) +}) + +await section('integrity', '证据接口复核出问题:醒目写出来,不说「有出处」就完事', async () => { + const bad = await open('/ui-harness.html?evidence=1') + await bad.page.unroute((u) => new URL(u).pathname.startsWith('/api/')) + await bad.page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => { + const m = new URL(r.request().url()).pathname.match(/\/evidence\/segments\/([^/]+)$/) + if (!m) return r.request().method() === 'GET' ? r.continue() : r.abort() + const body = structuredClone(fx.segments[m[1]]) + body.chain[0] = { ...body.chain[0], hash_ok: false, sealed: false } + return r.fulfill({ json: body }) + }) + await bad.page.locator('#evidence-wide [data-seg="s8"]').click() + await bad.page.waitForSelector('[data-evidence-panel] [data-ev-integrity]', { timeout: 4000 }).catch(() => {}) + const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '') + check('工件哈希对不上、不在封存范围内:用失败色逐条写出', said.includes('哈希对不上') && said.includes('不在封存范围内'), said) + await bad.page.keyboard.press('Escape') + await page.locator(`${wide} [data-seg="s8"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]') + check('全部复核通过时不多说一句', await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0) + await page.keyboard.press('Escape') + await bad.ctx.close() +}) + +/** + * 组件预览页,片段接口按 answer(segId, 夹具里那一份的拷贝) 回:返回对象就当 200,返回数字 + * 就当那个状态码。证据图按 graph(夹具拷贝) 回 + */ +async function probe(answer, { graph = (g) => g } = {}) { + const r = await open('/ui-harness.html?evidence=1') + await r.page.unroute((u) => new URL(u).pathname.startsWith('/api/')) + await r.page.route((u) => new URL(u).pathname.startsWith('/api/'), (route) => { + const req = route.request() + if (req.method() !== 'GET') return route.abort() + const path = new URL(req.url()).pathname + const m = path.match(/\/evidence\/segments\/([^/]+)$/) + if (m) { + const body = answer(m[1], structuredClone(fx.segments[m[1]] ?? {})) + return typeof body === 'number' + ? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } }) + : route.fulfill({ json: body }) + } + if (/\/evidence$/.test(path)) return route.fulfill({ json: graph(structuredClone(fx.graph)) }) + return route.continue() + }) + return r +} +const sealOf = async (p) => ({ + state: await p.locator('[data-evidence-panel] [data-ev-seal-state]').getAttribute('data-ev-seal-state').catch(() => null), + text: await p.locator('[data-evidence-panel]').innerText().catch(() => ''), +}) +async function openSeg(p, id = 's8', wait = '[data-ev-seal-state]:not([data-ev-seal-state="idle"])') { + await p.locator(`#evidence-wide [data-seg="${id}"]`).click() + await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {}) + await p.waitForTimeout(100) +} + +await section('trust', '只信封存链:证据接口答的是另一份报告时,不能画成绿的', async () => { + // 正文是按成果上的 doc_artifact 取的(可改写),接口从封存的事件找文档。两边不是同一份: + // 接口报的文档工件 id 不同、这个位置的字也不同(封存的那份写的是 9.9%) + const other = await probe((id, body) => (id === 's8' ? { + ...body, report: { ...body.report, doc_artifact: 'f'.repeat(64) }, + segment: { ...body.segment, text: '9.9%' }, + chain: [{ ...body.chain[0], value: 9.9, rendered: '9.9%' }, ...body.chain.slice(1)], + } : body)) + await openSeg(other.page) + let got = await sealOf(other.page) + const integrity = await other.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').innerText().catch(() => '') + check('封存文档不是正文这份:封存那一行不是绿的「核对一致」,是失败', got.state === 'failed' && !got.text.includes('核对一致'), + `${got.state} ${got.text.replace(/\s+/g, ' ').slice(0, 120)}`) + check('醒目写出「正文不是封存的那一份」,并说封存的那份这里写的是 9.9%', integrity.includes('不是封存范围内的那一份') + && integrity.includes('9.9%'), integrity.replace(/\s+/g, ' ')) + check('另一份报告的算式和值不挂到正文上', await other.page.locator('[data-evidence-panel] [data-ev-substituted]').count() === 0 + && !(await other.page.locator('[data-evidence-panel] [data-ev-metric]').innerText()).includes('9.9%')) + check('标题还是正文上的字', (await other.page.locator('[data-evidence-panel] h3').innerText()) === '8.7%') + await other.ctx.close() + + // 只有文档工件 id 不同(字碰巧一样)也不行 + const idOnly = await probe((id, body) => ({ ...body, report: { ...body.report, doc_artifact: 'e'.repeat(64) } })) + await openSeg(idOnly.page) + got = await sealOf(idOnly.page) + check('只是文档工件 id 不同:一样判失败', got.state === 'failed' + && await idOnly.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state) + await idOnly.ctx.close() + + // 片段接口取不到,退回证据图:证据图里封存的报告没有正文这份 → 失败;有 → 只说文档封存了 + const noSeg = await probe(() => 500, { graph: (g) => ({ ...g, reports: g.reports.map((r) => ({ ...r, doc_artifact: 'd'.repeat(64) })) }) }) + await openSeg(noSeg.page) + got = await sealOf(noSeg.page) + check('证据图兜底:封存的报告里没有正文这份,判失败', got.state === 'failed' + && await noSeg.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state) + await noSeg.ctx.close() + const viaGraph = await probe(() => 500) + await openSeg(viaGraph.page) + got = await sealOf(viaGraph.page) + check('证据图兜底、正文这份在封存的报告里:只说文档封存了,这个数的链没逐项核对', got.state === 'done' + && got.text.includes('证据链这次没取到'), `${got.state} ${got.text.replace(/\s+/g, ' ').slice(-80)}`) + await viaGraph.ctx.close() + + // 对照:接口答的就是正文这份时照常是绿的(上面那几条不是因为什么都判失败才过的) + await page.locator(`${wide} [data-seg="s8"]`).click() + await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]', { timeout: 4000 }).catch(() => {}) + got = await sealOf(page) + check('对照:同一份文档照常「已封存 · 核对一致」', got.state === 'done' && got.text.includes('已封存 · 核对一致') + && await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0, got.state) + await page.keyboard.press('Escape') +}) + +await section('seal', '封存状态:被改过的封存画成失败,没封存不说「事后补进来」', async () => { + // 后端 covered = 封存核对通过 && 每步在台账里:核对失败时 covered 必然 false、每步 sealed 也是 false + const cases = [ + ['封存被改过(sealed, ok=false)', { sealed: true, ok: false, covered: false }, 'failed', '已封存 · 核对不一致', false], + ['还没封存', { sealed: false, ok: null, covered: false }, 'idle', '尚未封存', false], + ['封存完好、这件证据不在台账里', { sealed: true, ok: true, covered: false }, 'waiting', '不在封存范围内', true], + ] + for (const [name, seal, state, label, late] of cases) { + const r = await probe((id, body) => ({ ...body, seal, chain: body.chain?.map((st) => ('sealed' in st ? { ...st, sealed: false } : st)) })) + await openSeg(r.page, 's8', '[data-ev-substituted]') + const got = await sealOf(r.page) + check(`${name}:封存那一行是 ${state}「${label}」`, got.state === state && got.text.includes(label), `${got.state}`) + check(`${name}:${late ? '单独报「不在封存范围内」' : '不说「事后补进来的」(封存那一行已经说清了)'}`, + got.text.includes('事后补进来') === late, got.text.replace(/\s+/g, ' ').slice(0, 120)) + await r.ctx.close() + } +}) + +await section('fields', '成果字段取文档:别的运行写的、没记正文的都不画', async () => { + const r = await open('/ui-harness.html?evidence=1') + await r.page.route(/\/api\/artifacts\/fx-field-/, (route) => { + const id = new URL(route.request().url()).pathname.split('/').pop() + const doc = id === 'fx-field-other-run' ? { ...fx.doc, run_id: 'run-someone-else' } + : id === 'fx-field-no-markdown' ? (({ markdown: _m, ...d }) => d)(fx.doc) : fx.doc + return route.fulfill({ json: { id, content: doc } }) + }) + await r.page.reload({ waitUntil: 'networkidle' }) + await r.page.waitForSelector('#fx-field-ok [data-evidence-doc]', { timeout: 5000 }).catch(() => {}) + check('对照:正文这份照常逐段可点', await r.page.locator('#fx-field-ok [data-evidence-doc] [data-seg]').count() > 0) + const other = await r.page.locator('#fx-field-other-run [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null) + check('文档的 run_id 和成果的运行不同:退回普通文本并说明', other === 'other-run' + && (await r.page.locator('#fx-field-other-run').innerText()).includes('不是这次运行写的') + && await r.page.locator('#fx-field-other-run [data-seg]').count() === 0, String(other)) + const bare = await r.page.locator('#fx-field-no-markdown [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null) + check('文档没记正文(缺 markdown):当对不上处理,不画', bare === 'mismatch' + && await r.page.locator('#fx-field-no-markdown [data-seg]').count() === 0, String(bare)) + await r.ctx.close() +}) + +await section('pinned', '没有 _evidence 的旧回答:和 Markdown.tsx 改动前逐字一样', async () => { + // markdown-pinned.json 里的 html 是改动前(HEAD b418c0d)的 Markdown.tsx 渲染的。重新生成:把 + // `git show <旧提交>:frontend/src/run/Markdown.tsx` 临时放进 src/run/,另起一个页面逐个渲染 + // samples、取 outerHTML 写回 html 字段,然后删掉临时文件。唯一有意的改动列在 intended 里 + const pinned = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/markdown-pinned.json`, 'utf8')) + const now = await page.evaluate(() => Object.fromEntries([...document.querySelectorAll('#md-pinned [data-pin]')] + .map((el) => [el.dataset.pin, el.firstElementChild?.outerHTML ?? '']))) + for (const sample of pinned.samples) { + let before = sample.html + for (const d of pinned.intended) before = before.split(d.from).join(d.to) + const cur = now[sample.id] ?? '' + let at = 0 + while (at < before.length && before[at] === cur[at]) at++ + check(`「${sample.id}」和改动前逐字一样(只差列表缩进 ml-4 → pl-4)`, !!cur && cur === before, + cur === before ? '' : `第 ${at} 个字起不同:改动前「${before.slice(at, at + 60)}」现在「${cur.slice(at, at + 60)}」`) + } + // ml-4 → pl-4:量一下列表项的位置和宽度,把类名换回 ml-4 再量一遍,必须一样 + const moved = await page.evaluate(() => { + const out = [] + for (const list of document.querySelectorAll('#md-pinned ul, #md-pinned ol')) { + const rects = () => [...list.children].map((li) => { + const r = li.getBoundingClientRect() + return [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10).join(',') + }).join('|') + // 类名得真是 pl-4 才有得换:换不了就算没量(别的缩进一律不认) + if (!list.classList.contains('pl-4') || list.classList.contains('ml-4')) { out.push(`类名是「${list.className}」`); continue } + const a = rects() + list.classList.replace('pl-4', 'ml-4') + const b = rects() + list.classList.replace('ml-4', 'pl-4') + if (a !== b) out.push(`${a} ≠ ${b}`) + } + return { lists: document.querySelectorAll('#md-pinned ul, #md-pinned ol').length, moved: out } + }) + check('列表缩进 pl-4 和以前的 ml-4 看起来一样:每个列表项的位置、宽度都没变', moved.lists >= 6 && !moved.moved.length, + `${moved.lists} 个列表 ${moved.moved.join(' ; ')}`) +}) + +await section('studio', '报告撰写节点:画布认得、检查器能配(REQ-A-3)', async () => { + // 假的工作流,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库 + const at = (x, y) => ({ x, y }) + const node = (id, type, x, label, config = {}) => ({ id, type, position: at(x, 80), data: { label, config } }) + const graph = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('caliber', 'metrics', 260, '周报口径', { caliber: '周报口径', caliber_version: 'v2', + metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'input.week' }] }), + node('write', 'report', 520, '写周报', { instructions: '为 {{ input.week }} 写周报', numbers: 'strict', max_repairs: 1 }), + node('late', 'metrics', 780, '事后口径', { caliber: '事后', metrics: [{ id: 'x', expression: '1' }] }), + node('out', 'output', 1040, '成果', { fields: [{ name: '周报', value: '{{ nodes.write.text }}' }] }), + ], + edges: [['in', 'caliber'], ['caliber', 'write'], ['write', 'late'], ['late', 'out']] + .map(([source, target]) => ({ id: `${source}-${target}`, source, target })), + } + const wf = { id: 'fx-report', name: '报告节点检查', description: '', graph, tags: [], version: 1, is_template: false, + status: 'draft', published_version: null, run_count: 0, created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z' } + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) + const p = await ctx.newPage() + const errors = [] + p.on('pageerror', (e) => errors.push(e.message)) + const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf]) : json(r, { detail: '检查脚本不写库' }, 409))) + await p.route(/\/api\/workflows\/fx-report(\/.*)?(\?.*)?$/, (r) => + (r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, []))) + await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) => + (r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] }))) + await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409))) + await p.goto(`${WEB}/studio/fx-report`, { waitUntil: 'networkidle' }) + await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-report', null, { timeout: 15000 }) + await p.waitForTimeout(600) + const card = await p.locator('.react-flow__node[data-id="write"]').innerText().catch(() => '') + check('画布上的报告节点有摘要(写作要求、指标从哪来),不是未知类型', card.includes('为 {{ input.week }} 写周报') + && card.includes('上游全部口径卡'), card.replace(/\n/g, ' ').slice(0, 120)) + const tint = await p.evaluate(() => { + const el = document.querySelector('.react-flow__node[data-id="write"] .nt-report') + return el ? getComputedStyle(el).getPropertyValue('--nt').trim() : '' + }) + check('报告节点有自己的类型色(--nt-report)', !!tint, tint) + await p.evaluate(() => window.__studio.getState().select('write')) + await p.waitForSelector('[data-field="metrics_from"]', { timeout: 5000 }) + check('检查器标题写「报告撰写」', (await p.locator('body').innerText()).includes('报告撰写')) + const fields = await p.evaluate(() => [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field'))) + check('检查器的字段:写作要求、指标来自、裸数字、违规时、重写次数、模型', + ['instructions', 'metrics_from', 'numbers', 'on_violation', 'max_repairs', 'model'].every((f) => fields.includes(f)), fields.join(',')) + await p.locator('[data-field="metrics_from"] button', { hasText: '选择口径卡' }).click() + const opts = await p.locator('[data-field="metrics_from"] .max-h-52 button').allInnerTexts() + check('「指标来自」只列上游的口径卡(下游那张不列)', opts.length === 1 && opts[0].includes('周报口径'), opts.join(' | ')) + const onViolation = await p.locator('[data-field="on_violation"] option').allInnerTexts() + check('违规时的默认写明按运行类别', onViolation[0]?.includes('探索运行照常产出'), onViolation.join(' | ')) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await p.waitForTimeout(250) + await p.screenshot({ path: `${SHOTS}/evidence-studio-report-${theme}.png` }) + } + } + check('没有运行时报错(画布)', errors.length === 0, errors.join(' | ')) + await ctx.close() +}) + +if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.locator(`${wide} [data-seg="s8"]`).click() + await page.waitForTimeout(350) + await page.screenshot({ path: `${SHOTS}/evidence-harness-${theme}.png`, fullPage: true }) + await page.locator('#evidence-narrow [data-seg="s26"]').click() + await page.waitForTimeout(350) + await page.locator('#evidence-narrow').screenshot({ path: `${SHOTS}/evidence-360-inline-${theme}.png` }) + await page.keyboard.press('Escape') + } +} +check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | ')) +await harness.ctx.close() +await browser.close() +console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 可点击证据全部通过') +process.exit(failed ? 1 : 0) diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 7287d02d..16358f73 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -1648,6 +1648,53 @@ await section('画布右栏:去审批、放弃、出具横幅、回执(REQ-1 await ctx.close() }) +await section('逐段证据:带 _evidence 的成果(可点击证据第一期)', async () => { + // 报告文档和片段接口用夹具伪造(后端 compose_doc 真跑出来的,只用通用名),非 GET 一律拦掉 + const { readFileSync } = await import('node:fs') + const fx = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8')) + for (const [w, dense] of [[1100, false], [380, true]]) { + const page = await browser.newPage({ viewport: { width: w, height: 900 } }) + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => { + const url = new URL(r.request().url()) + if (r.request().method() !== 'GET') return r.abort() + const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/) + if (seg) return r.fulfill({ json: fx.segments[seg[1]] ?? {} }) + if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } }) + return r.continue() + }) + const tag = dense ? '窄栏' : '宽栏' + await page.goto(`${WEB}/preview.html?syn=evidence${dense ? '&dense=1' : ''}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('[data-turn="evidence"] [data-evidence-doc]', { timeout: 6000 }) + const turn = page.locator('[data-turn="evidence"]') + check(`${tag}:带证据的字段换成逐段文档,数字是可点的片段`, await turn.locator('[data-seg]').count() === 13) + check(`${tag}:出具横幅多一行计数`, (await turn.locator('[data-evidence-line]').innerText()) + .includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了')) + check(`${tag}:没有 _evidence 的那一轮不变`, await page.locator('[data-turn="evidence-legacy"] [data-seg]').count() === 0) + await turn.locator('[data-seg="s6"]').click() + await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]') + const mode = await page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') + check(`${tag}:面板${dense ? '在栏内展开' : '从侧边弹出'}`, mode === (dense ? 'inline' : 'side'), mode) + const box = await page.evaluate(() => { + const el = document.querySelector('[data-stream-scroll]') + return { sw: el.scrollWidth, cw: el.clientWidth } + }) + check(`${tag}:打开面板后流里没有横向滚动`, box.sw <= box.cw, JSON.stringify(box)) + const copy = await turn.locator('button[aria-label="复制"]').count() + check(`${tag}:答案的复制 / 导出照常在`, copy > 0) + check(`${tag}:没有运行时报错`, errors.length === 0, errors.join(' | ')) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.screenshot({ path: `${SHOTS}/stream-evidence-${dense ? 'dense' : 'wide'}-${theme}.png` }) + } + } + await page.close() + } +}) + await browser.close() console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 助手流渲染全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-ui-kit.mjs b/scripts/check-ui-kit.mjs index 8416cdbd..368204e5 100644 --- a/scripts/check-ui-kit.mjs +++ b/scripts/check-ui-kit.mjs @@ -282,6 +282,114 @@ await section('lib:格式、状态、快捷键、术语、报错', async () => })) check(name, ok, detail) }) +await section('lib/evidence:证据状态的四通道元数据、报告节点的叫法、复核守卫', async () => { + for (const [name, ok, detail] of await page.evaluate(() => { + const { evidence: ev, terms: t } = window.__ui.lib + const out = [] + const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`]) + const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail]) + const codes = ev.EVIDENCE_STATES + // 方案 6.1 的八种:确定性、概率性四种、无证据、连接性、旧运行候选 + ok('八种状态一种不少', ['deterministic', 'supported', 'partial', 'unsupported', 'unjudged', 'none', 'connective', 'candidate'] + .every((c) => codes.includes(c)) && codes.length === 8, codes.join(',')) + const lines = new Set(['solid', 'dotted', 'badge', 'none']) + const bad = codes.filter((c) => { + const m = ev.EVIDENCE_STATE[c] + return !m || m.code !== c || !m.label || !lines.has(m.line) || typeof m.glyph !== 'string' + || !/^var\(--st-[\w-]+\)$/.test(m.color) || !m.decoration || !/^var\(--st-[\w-]+\)$/.test(m.soft) || !m.hint + }) + ok('每种都有线型、字形、颜色、文字(颜色只用 --st-*)', !bad.length, bad.join(',')) + ok('下划线颜色也只取 --st-*(或透明)', codes.every((c) => { + const d = ev.EVIDENCE_STATE[c].decoration + return d === 'transparent' || /var\(--st-[\w-]+\)/.test(d) + }), codes.map((c) => ev.EVIDENCE_STATE[c].decoration).join(' | ')) + const pairs = codes.map((c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`) + ok('去掉颜色也分得开:线型 + 字形两两不同', new Set(pairs).size === codes.length, pairs.join(' ')) + const labels = codes.map((c) => ev.EVIDENCE_STATE[c].label) + ok('文字两两不同', new Set(labels).size === codes.length, labels.join(' ')) + eq('确定性:细实线、有出处', `${ev.EVIDENCE_STATE.deterministic.line}/${ev.EVIDENCE_STATE.deterministic.label}`, 'solid/有出处') + eq('无证据:点状线、?、无证据', `${ev.EVIDENCE_STATE.none.line}/${ev.EVIDENCE_STATE.none.glyph}/${ev.EVIDENCE_STATE.none.label}`, 'dotted/?/无证据') + ok('概率性的「有依据」不用确定性的绿', ev.EVIDENCE_STATE.supported.color !== ev.EVIDENCE_STATE.deterministic.color) + eq('本期只会出现确定性和无证据', codes.filter((c) => ev.EVIDENCE_STATE[c].phase === 1).join(','), 'deterministic,none') + ok('只有异常态醒目(进 n / N 的跳转)', !ev.EVIDENCE_STATE.deterministic.alert && ev.EVIDENCE_STATE.none.alert) + eq('片段状态:deterministic', ev.segmentState({ kind: 'number', state: 'deterministic' }), 'deterministic') + eq('片段状态:结构片段不画', ev.segmentState({ kind: 'structural', state: 'none' }), null) + eq('片段状态:文字不画', ev.segmentState({ kind: 'text', state: 'neutral' }), null) + eq('片段状态:probabilistic 裁判前按未裁判画', ev.segmentState({ kind: 'text', state: 'probabilistic' }), 'unjudged') + + eq('报告节点叫「报告撰写」', t.nodeTypeLabel('report'), '报告撰写') + eq('计数的说法:无证据 = 总数 − 有出处,算得平', t.evidenceTally(7, 12), '7/12 数字有出处 · 无证据 5') + eq('不是数字的解析不了的引用另起一句', t.evidenceTally(7, 12, 1), '7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') + eq('数字全有出处', t.evidenceTally(1234, 1234, 0), '1,234 个数字都有出处') + eq('数字全有出处、另有引用解析不了', t.evidenceTally(3, 3, 2), '3 个数字都有出处 · 另有 2 处引用解析不了') + eq('一个数字都没有、只有解析不了的引用', t.evidenceTally(0, 0, 1), '1 处引用解析不了') + eq('什么都没有时给空串', t.evidenceTally(0, 0, 0), '') + + const out1 = { 周报: 'x', _evidence: { report_node: 'write', doc_artifact: 'abc', fields: ['周报'], + others: [{ report_node: 'w2', doc_artifact: 'def', fields: ['附录'] }] } } + const f = ev.evidenceFields(out1) + ok('_evidence 的字段和 others 都认', f.get('周报')?.artifact === 'abc' && f.get('附录')?.report === 'w2', JSON.stringify([...f])) + eq('缺 doc_artifact 的标注当没有', ev.evidenceFields({ _evidence: { fields: ['a'] } }).size, 0) + const review = { verdict: 'rewritten', note: '有缺口', answer: '改写后的', original: '原文', retry: false, severity: 'degraded', signals: [] } + const guarded = ev.guardReview(review, out1) + ok('成果带证据:复核只加说明,不改写', guarded.answer === null && guarded.original === null && guarded.verdict === 'annotated' + && guarded.note === '有缺口', JSON.stringify(guarded)) + ok('成果不带证据:复核照旧可以改写', ev.guardReview(review, { answer: 'x' }).answer === '改写后的') + eq('出处不唯一的说法', ev.matchedSource({ token: '0', metric: null, candidates: ['a', 'b'], ambiguous: true }).text, '出处不唯一:候选 a、b') + eq('出处唯一的照写指标', ev.matchedSource({ token: '12', metric: 'gmv' }).text, 'gmv') + const cp = ev.codePointIndex('📦 15 单') + eq('码点偏移换成 UTF-16:emoji 后面差一位', cp(2), 3) + eq('没有 emoji 时原样', ev.codePointIndex('abc')(2), 2) + const doc = window.__ui.evidenceFixture.doc + const tally = ev.docTally(doc) + const k = (x) => `${x.cited}/${x.total}/${x.none}/${x.other}/${x.hidden}/${x.structural}/${x.noSegment}` + eq('文档计数(有出处/总数/无证据/非数字引用/画不了线/其中结构/其中没对应字)', k(tally), '7/12/5/1/1/1/0') + const { stats: _s, ...bare } = doc + eq('没有 stats 时从片段和违规清单数,口径一样', k(ev.docTally(bare)), '7/12/5/1/1/1/0') + const { violations: _v, ...noList } = doc + eq('有 stats、没有违规清单:非数字引用由 stats 反推', `${ev.docTally(noList).none}/${ev.docTally(noList).other}`, '5/1') + eq('只有 stats(report.checked 的载荷):同一种算法', JSON.stringify(ev.statsTally(doc.stats)), + JSON.stringify({ total: 12, cited: 7, none: 5, other: 1 })) + eq('载荷缺字段:不给计数', ev.statsTally({ uncited_numbers: 1 }), null) + // 句末依据里写错的引用:违规不指任何片段,和列表序号里的数字分开数、分开说 + const withSee = { ...doc, stats: { ...doc.stats, unresolved: 4, violations: 7 }, + violations: [...doc.violations, { code: 'unresolved_ref', message: '依据 [[see:m:nope]] 解析不了', ref: 'm:nope', unit: 'u1' }] } + const t2 = ev.docTally(withSee) + eq('句末依据的引用:算进非数字引用、算进「没对应字」', k(t2), '7/12/5/2/2/1/1') + const sum = ev.tallySummary(t2, true) + ok('读屏摘要:两种画不了线的分开说在哪', sum.includes('其中 1 个数字在列表序号') && sum.includes('1 处在正文里没有对应的字') + && sum.includes('句末依据'), sum) + ok('读屏摘要:只有结构片段时不提「句末依据」', !ev.tallySummary(tally, true).includes('句末依据'), ev.tallySummary(tally, true)) + + // 封存那一行:顺序是 正文不是封存那份 → 没拿到 → 没封存 → 核对失败 → 不在范围 → 通过 + const sv = (seal, opts) => { const v = ev.sealVerdict(seal, opts); return `${v.status}:${v.label}` } + eq('封存被改过(covered 必然也是 false):失败,不是「不在封存范围内」', sv({ sealed: true, ok: false, covered: false }), + 'failed:已封存 · 核对不一致') + eq('没封存:灰「尚未封存」', sv({ sealed: false, ok: null, covered: false }), 'idle:尚未封存') + eq('封存完好、这件不在台账里:琥珀', sv({ sealed: true, ok: true, covered: false }), 'waiting:不在封存范围内') + eq('都过了:绿', sv({ sealed: true, ok: true, covered: true }), 'done:已封存 · 核对一致') + ok('正文不是封存的那份:再完好的封存也判失败', sv({ sealed: true, ok: true, covered: true }, { foreign: true }).startsWith('failed:')) + ok('没有证据的片段:落到报告文档上', sv({ sealed: true, ok: true, covered: true }, { docOnly: true }).includes('报告文档已封存')) + ok('链没取到、封存状态从证据图查的:只说文档封存了', sv({ sealed: true, ok: true }, { viaGraph: true }).includes('证据链这次没取到')) + eq('逐项复核:没封存时不报「不在封存范围内」', ev.integrityFailures({ sealed: false, hash_ok: true }, { sealed: false }).join(','), '') + eq('逐项复核:封存被改过时也不报', ev.integrityFailures({ sealed: false }, { sealed: true, ok: false }).join(','), '') + eq('逐项复核:封存完好时照报', ev.integrityFailures({ sealed: false, hash_ok: false }, { sealed: true, ok: true }).join(','), 'hash,sealed') + + // 证据接口答的是不是正文这份 + const seg = { id: 's8', text: '8.7%' } + const mine = { artifact: 'aaa', node: 'write', seg } + eq('同一份:不算外来', ev.docForeign({ report: { doc_artifact: 'aaa', node_id: 'write' }, segment: { id: 's8', text: '8.7%' } }, mine), null) + ok('文档工件 id 不同:外来', !!ev.docForeign({ report: { doc_artifact: 'bbb' }, segment: { id: 's8', text: '8.7%' } }, mine)) + eq('这个位置的字不同:外来,并记下封存那份写的是什么', + ev.docForeign({ report: { doc_artifact: 'aaa' }, segment: { id: 's8', text: '9.9%' } }, mine)?.sealedText, '9.9%') + eq('接口没报文档 id(老后端):只比字', ev.docForeign({ segment: { text: '8.7%' } }, mine), null) + eq('证据图兜底:封存的报告里有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'aaa' }] }, 'aaa'), 'ok') + eq('证据图兜底:没有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'bbb' }] }, 'aaa'), 'foreign') + eq('证据图兜底:有,但哈希对不上', ev.graphDoc({ reports: [{ doc_artifact: 'aaa', hash_ok: false }] }, 'aaa'), 'tampered') + return out + })) check(name, ok, detail) +}) + await section('lib/actor:本机署名', async () => { for (const [name, ok, detail] of await page.evaluate(() => { const a = window.__ui.lib.actor