diff --git a/README.md b/README.md
index 2bb46f40..eff62429 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建
| **产出复核** | 运行结束后规则扫描事件流,发现异常才调用模型复核;复核可重组答案但**不得引入原答案中不存在的数字** |
| **追踪与成本** | 全量事件落库可回放;逐节点耗时、token 用量、按目录价折算的成本 |
| **三档出具** | 口径卡(受控指标集)+ 出具契约:叙述中每个数字回指指标集,或由报告撰写节点按引用逐段核对,formal / degraded / withheld 三档判定 |
-| **可点击证据** | 报告撰写节点只写引用标记,数字和表格由系统照快照填值;数字、表名字段名、知识库原话都点得开,一路指到查询结果里的那一格;结论句可要求挂依据或交给另一个模型裁判;证据清单可导出 |
+| **可点击证据** | 报告撰写节点只写引用标记,数字和表格由系统照快照填值;数字、表名字段名、知识库原话都点得开,一路指到查询结果里的那一格;结论句可要求挂依据或交给另一个模型裁判;证据清单可导出,报告导出为自包含 HTML(数字点得开口径与出处),另有封存哈希核对件「可验证报告」 |
| **发布前检查** | 受管级别加证据门禁 G1–G5;问题给出一键修复 / 选项 / 交给 Copilot 三类修法,一律先预览、不许降低要求;老工作流一键升级为可追溯结构 |
| **正式 / 探索分级** | 正式运行仅从已发布的不可变版本发起(按图哈希钉死);画布试跑自动标记为探索性 |
@@ -228,6 +228,15 @@ flowchart LR
问数据的 input → agent → output 中间插一个报告撰写,配了 `output_schema` 的 agent 打开 `cite_fields`;
沙箱代码喂口径卡的只建议标成 `source`,不替人改。升级只给预览,人确认后才保存。
+**导出两份。** 问数据答案和运行详情成果上的「导出」把报告导出成一个自包含的 HTML 文件
+(`GET /api/runs/{id}/report.html`):正文由服务端渲染,不执行脚本也读得到(邮件附件预览常常不执行脚本);
+执行脚本后每个数字、无证据的数字、可疑的表名字段名都点得开——面板是网页上证据面板同一个拼法的
+出处链(口径、来源、计算方式、SQL、被引用的行),封存状态逐项标明;遮罩列照网页上的规矩写「已遮罩」。
+没封存的运行照常导出(和网页上读报告一个道理)。记录页「凭证详情」里的「导出可验证报告」
+(`GET /api/runs/{id}/bundle.html`)是另一份:封存核对件,收件人不连服务、在浏览器里重算哈希,核对每个
+数字、文档和运行记录没被改过;数字的出处面板同样带口径、原式、代入式、输入和复算,口径卡输入指向的
+查询快照也随文件附带、引用链认得这条引用。要证明没被改过用可验证报告,要让人读报告、查数字的来历用报告导出。
+
### 受管门禁与发布前修复
受管级别在治理 lint 之外加了证据门禁,只看图和配置,让「每个数都点得开」在结构上成立:
@@ -679,7 +688,7 @@ cd backend && env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_T
# 前端类型检查
cd frontend && npx tsc -b
-# 前端检查:20 项,默认 8 道并行,约 8 分钟。连 5273 / 8000,先起 ./scripts/dev.sh
+# 前端检查:28 项,默认 8 道并行。连 5273 / 8000,先起 ./scripts/dev.sh
node scripts/check-all.mjs
# 另起 2 套只给检查用的「后端 + vite」,各用一份数据拷贝,约 6.5 分钟;边改代码边跑也不被打断
CHECK_DATA_SRC=<数据目录> node scripts/check-all.mjs --stacks 2 --lanes 12
@@ -690,7 +699,7 @@ node scripts/check-all.mjs evidence publish
pytest 前面的 `env -u` 不能省:没配 Key 的 provider 会退回环境变量里的密钥,启动时的 seed 也会拿它种一个
provider——环境里留着真密钥,测试就可能真的去调接口。
-`check-all` 的 20 项按 `scripts/check-all.mjs` 里 `ORDER` 的顺序排:
+`check-all` 的 28 项按 `scripts/check-all.mjs` 里 `ORDER` 的顺序排:
| 类别 | 检查 |
|---|---|
@@ -698,6 +707,7 @@ provider——环境里留着真密钥,测试就可能真的去调接口。
| 走线、组件与护栏 | `canvas-layout` `ui-kit` `guards` |
| 外壳、回答流与运行态 | `shell` `stream` `evidence`(可点击证据)`run-states`(节点卡)`canvas-fx`(画布表层) |
| 各页面 | `studio` `publish`(发布前检查与自动修复)`chat` `runs` `manage` `versions`(上传表格的版本)`catalog`(数据目录)`ui`(端到端,连真数据) |
+| 复核套件与导出 | `schedules`(定时任务)`compare`(运行对比)`verify`(双盲复算、反方审稿)`inquiry`(审批质询)`challenge`(反方审稿)`bundle`(可验证报告)`whatif`(数字推演)`report-export`(报告导出) |
完整输出写进 `/tmp/agentlab-checks/<时间>/`,终端只给汇总;并行时没过的最后单独重跑一遍,重跑才过的单列成「偶发」。
`--stacks` 每套拷一份数据、端口自动挑空闲的,后端不热重载、vite 不监听文件,跑完、出错或被中断都收干净。
diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py
index 8e16d786..581a6be3 100644
--- a/backend/app/api/evidence.py
+++ b/backend/app/api/evidence.py
@@ -2,7 +2,6 @@
一件证据可信,当且仅当它能从封存范围内的事件(seq ≤ manifest_seq)出发走到,
并且取回时哈希复验通过:
-
- 报告文档:report.checked 事件里的 doc_artifact
- 口径卡的指标集:口径卡节点 node.finished 事件里的 evidence 台账
- 查询快照:tool.end 事件里的 query_artifact,或者 node.finished.evidence 里的 query 条目
@@ -34,6 +33,10 @@ certain_masks;转抄上游口径卡的指标在指标依赖图上一次认,_
一元加号这类认法本身漏掉的表达式(+phone,masking._derived_masks 的缺口)、Postgres 给没起别名的表达式起的列名(upper、
?column?)、视图和别的表里换了名字存着的遮罩列、自己建的函数,以及数据整形把号码取出来再赋给变量(出处认不出)。
+报告导出(GET /api/runs/{run_id}/report.html,app/report_export)用的也是这套出处链:
+把选中的报告文档渲染成自包含的 HTML,每个数字点得开和网页上同一个拼法的面板
+(口径、来源、计算方式),同样的引用共用一条链。
+
期 4 的核查状态同样只读封存范围内的东西:指标步骤带双盲复算的结果(_dual_check_of,读双盲复算节点的产出和台账
条目),句子带反方审稿的判断(_challenge_of,读反方审稿节点的产出和审稿记录)。它们是核查,不是证据:没有就不带这两个键。
证据图的每份报告另带一张被反方审过的句子 → 判断的表(_challenge_verdicts,和 _challenge_of 同一个读法),正文据此挂
@@ -2650,6 +2653,99 @@ async def evidence_provenance(run_id: str, segment_id: str, report: str | None =
return asdict(out)
+# --------------------------------------------------------------------------
+# GET /api/runs/{run_id}/report.html(报告导出:自包含的 HTML,数字点得开口径、来源、计算方式)
+# --------------------------------------------------------------------------
+
+
+def _export_cite_key(seg: dict[str, Any]) -> tuple | None:
+ """数字 / 取值片段的引用键:同一处引用(同一个指标、同一格、同样的换算)共用一条出处链。
+ 别的片段(表名、引文)的链跟着片段走,不归并。"""
+ if seg.get("kind") not in ("number", "value"):
+ return None
+ cite = seg.get("cite")
+ if not isinstance(cite, dict):
+ return None
+ loc = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
+ return (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"),
+ loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue"))
+
+
+@router.get("/{run_id}/report.html")
+async def export_report_html(run_id: str, report: str | None = None) -> Response:
+ """把一次运行的报告导出成一个自包含的 HTML 文件(app/report_export)。
+
+ 正文由服务端按报告文档渲染(不执行脚本也读得到);带出处链的数据嵌在页面里,执行脚本后每个数字、
+ 无证据的数字、可疑的表名字段名都点得开——面板是网页上证据面板同一个拼法(_chain):口径卡、口径与
+ 版本、原式、代入式、输入、复算、SQL、被引用的行。封存状态逐项标明;没封存的运行照常导出(和网页上
+ 读报告一个道理)。只读:不追加任何事件,不改运行记录。
+
+ 一次运行里有好几份报告时用 ?report=<节点 id> 指定;不指定取成果里标注的那一份(和片段接口同一个选法)。
+ 选报告、错误码和片段接口相同(run_not_found、evidence_report_not_found、evidence_doc_tampered、
+ evidence_doc_missing)。
+ """
+ from datetime import datetime, timezone
+
+ from app.report_export.build import (
+ attachment_name, build_report, content_disposition, render_html, segment_state,
+ )
+
+ 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)
+ doc = chosen.doc
+ nodes: dict[str, dict[str, str]] = {}
+ for item in sealed.graph.get("nodes") or []:
+ if isinstance(item, dict) and item.get("id"):
+ data = item.get("data") if isinstance(item.get("data"), dict) else {}
+ nodes[str(item["id"])] = {"label": str(data.get("label") or item["id"]),
+ "type": str(item.get("type") or "")}
+ # 每个有状态的片段一条出处链(和网页上点开的同一个 _chain);同样的引用共用一条,别把整份快照抄几十遍
+ masks = _Masks()
+ chains: dict[str, list[dict[str, Any]]] = {}
+ refs: dict[str, str] = {}
+ notes: dict[str, str] = {}
+ memo: dict[tuple, str] = {}
+ for _, unit in iter_units(doc):
+ for seg in unit.get("segments") or []:
+ if not isinstance(seg, dict) or not seg.get("id") or segment_state(seg) is None:
+ continue
+ sid = str(seg["id"])
+ if note := _note(seg, unit):
+ notes[sid] = note
+ key: tuple | None = _export_cite_key(seg)
+ if key is not None and key in memo:
+ refs[sid] = memo[key]
+ continue
+ chain = await _chain(seg, doc, sealed, chosen.node_id)
+ ck = key if key is not None else (sid,)
+ chains[str(ck)] = chain
+ refs[sid] = str(ck)
+ if key is not None:
+ memo[key] = str(ck)
+ async with SessionLocal() as session:
+ run = await session.get(Run, run_id)
+ if run is None: # 两次读之间被删掉了
+ raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
+ info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name,
+ "run_class": run.run_class, "status": run.status,
+ "started_at": run.started_at, "finished_at": run.finished_at}
+ data = build_report(info, report_node=chosen.node_id, doc_artifact=chosen.doc_artifact,
+ doc_hash_ok=chosen.hash_ok, doc=doc, seal=sealed.seal, chains=chains, refs=refs,
+ notes=notes, nodes=nodes, exported_at=datetime.now(timezone.utc).isoformat(timespec="seconds"))
+ return Response(render_html(data, doc), media_type="text/html; charset=utf-8", headers={
+ "Content-Disposition": content_disposition(attachment_name(info), run_id=run_id),
+ # 导出的是这一刻的报告和出处,不该被哪一层缓存下来、换给下一个请求
+ "Cache-Control": "no-store",
+ })
+
+
#: R14 之后(绑定快照、取引擎、编译核对、回查、行级核对)会抛的三类异常,按 _snapshot_failure 的顺序映射
_SNAPSHOT_ERRORS: tuple[type[BaseException], ...] = (SnapshotMissing, SnapshotTampered)
diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py
index bf5c83e5..f7345ad9 100644
--- a/backend/app/bundle/build.py
+++ b/backend/app/bundle/build.py
@@ -19,8 +19,10 @@
报告撰写节点把值不给的写成「已遮罩」;还写着原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给 id、
不附带它引用的工件——哪些指标的值不给和写作目录同一个判断(metric_word 的 sure:只按确定取自遮罩列的认,由调用方按
证据面板的那一套认好交进来,api/evidence.py 的 certain_masks),SQL 认不准时没确定取自遮罩列的普通金额照常附带。
- 文档里写成「已遮罩」的指标所在的口径卡一律只给 id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析
- 不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被报告引用的工件不嵌。
+ 文档里写成「已遮罩」的指标所在的口径卡一律只给 id。随文件附带的口径卡,它记着的输入指向的查询快照也一并附上(遮罩
+ 规则同上):点开数字时看得到每个输入的 SQL 和被引用的行,引用链(④)按「已核对的口径卡的输入」认它们。只给 id 的
+ 工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被
+ 报告引用、也没被附带的口径卡的输入引用的工件不嵌。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次
@@ -1011,6 +1013,30 @@ def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
return list(dict.fromkeys(out))
+def _card_input_queries(card: Any) -> list[tuple[str, str | None]]:
+ """口径卡记着的输入引用的查询快照:(工件 id, 输入来自的节点),按先后、按工件去重。
+
+ 只认 cell() 取的格、Agent 交来的字段这两类带着定位的输入(nodes.*、input.* 的取值不走查询快照)。
+ 卡是调用方确认过随文件附带的那份(遮罩的、文件不在的不进来):出处面板据此展示每个输入的 SQL 和被引用的行。"""
+ out: list[tuple[str, str | None]] = []
+ seen: set[str] = set()
+ if not isinstance(card, dict):
+ return out
+ for metric in card.get("metrics") or []:
+ if not isinstance(metric, dict):
+ continue
+ for item in metric.get("inputs") or []:
+ if not isinstance(item, dict) or item.get("via") not in ("tool_cell", "agent_field"):
+ continue
+ artifact = item.get("artifact")
+ if not isinstance(artifact, str) or not artifact or artifact in seen:
+ continue
+ seen.add(artifact)
+ owner = item.get("node_id")
+ out.append((artifact, str(owner) if owner else None))
+ return out
+
+
class _Artifacts:
"""嵌进页面的工件清单,按先后:报告文档在前,接着是它引用到的。同一份只列一次。events 是封存范围内的事件
(认口径卡带不带遮罩时找写变量的节点当时的产出)。"""
@@ -1019,13 +1045,14 @@ class _Artifacts:
self.store = store
self.events = events
self.items: list[dict[str, Any]] = []
+ self._by_id: dict[str, dict[str, Any]] = {}
self._seen: set[str] = set()
- def add(self, artifact: str, kind: str, node_id: str | None, *, masked: bool = False) -> None:
- """masked:调用方另认出不能附带的(报告文档的证据目录里带着值不给的指标的原值;报告文档里写成「已遮罩」的指标
- 所在的口径卡),只给 id。"""
+ def add(self, artifact: str, kind: str, node_id: str | None, *, masked: bool = False) -> dict[str, Any] | None:
+ """列进清单的一份工件,交回它的条目(同一份只列一次,第二次交回第一次的)。masked:调用方另认出不能附带的
+ (报告文档的证据目录里带着值不给的指标的原值;报告文档里写成「已遮罩」的指标所在的口径卡),只给 id。"""
if artifact in self._seen:
- return
+ return self._by_id[artifact]
self._seen.add(artifact)
item: dict[str, Any] = {"id": artifact, "kind": kind}
if node_id:
@@ -1047,6 +1074,8 @@ class _Artifacts:
# 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上
item["b64"] = base64.b64encode(bytes(raw)).decode("ascii")
self.items.append(item)
+ self._by_id[artifact] = item
+ return item
# --------------------------------------------------------------------------
@@ -1236,9 +1265,16 @@ def build_bundle(
for report in reports:
artifacts.add(report["doc_artifact"], "report_doc", report["node_id"], masked=report["node_id"] in withheld)
for artifact, kind, owner in next((_cited_artifacts(doc) for r, doc in docs if r is report), []):
- artifacts.add(artifact, kind, owner, masked=kind == "metric_set" and artifact in forced)
+ item = artifacts.add(artifact, kind, owner, masked=kind == "metric_set" and artifact in forced)
if kind == "query":
cited_queries.append(artifact)
+ continue
+ # 口径卡随文件附带了(没遮、文件在):它记着的输入——哪个指标的值取自哪次查询的哪一格——
+ # 那几次查询的快照也一并附上(设了遮罩的照旧只给 id)。点开数字看得到输入的 SQL 和被引用的行;
+ # 引用链(④)按「已核对的口径卡的输入」认它们,被改过的卡(intact 不过)不带,免得替它圆
+ if kind == "metric_set" and item is not None and "b64" in item and store.intact(artifact):
+ for query, query_owner in _card_input_queries(store.content(artifact)):
+ artifacts.add(query, "query", query_owner)
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
shown_events = []
diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js
index 376c8e1e..e9308f5d 100644
--- a/backend/app/bundle/verifier.js
+++ b/backend/app/bundle/verifier.js
@@ -136,6 +136,7 @@
chainDoc: (label) => `报告「${label}」的文档`,
chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`,
chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`,
+ chainByCard: (label, metric) => `口径卡「${label}」的输入(指标 ${metric})`,
chainFrom: '引用自',
chainNoCheckEvent: '找不到这个报告节点的报告自查事件',
chainCheckEventFailed: '报告自查事件的内容没有通过核对',
@@ -200,6 +201,12 @@
shownOdd: '紧挨着这个数的字不在常见写法里,无法确认读者看到的数',
numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过',
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
+ // 数字详情里口径卡一节:指标、计算方式(原式、代入式)、输入、复算。遮罩未附带的卡没有这一节(原因见核对明细 ③)
+ dMetric: '指标', dUnit: '单位', dExpression: '原式(计算方式)', dSubstituted: '代入式(代入输入后的算式)',
+ dInputs: '输入', dRecompute: '复算', dSourceName: '数据源', dInputValue: '取值', dFrom: '取自',
+ recomputeOk: '复算一致', recomputeBad: '复算不一致:代入式的计算结果与记录值不符', recomputeNone: '无法复算',
+ inputFrom: (query, row, column) => (query ? `查询 ${query}` : '') + (Number.isInteger(row) ? ` · 第 ${row + 1} 行` : '') + (column ? ` · ${column}` : ''),
+ inputNotAttached: '快照未随文件附带(见核对明细 ③),输入的值按口径卡的记录展示',
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`,
statusLegacy: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对',
@@ -1157,6 +1164,7 @@
async function verify(data, digest, shownSeal) {
const out = {
events: [], manifest: [], artifacts: [], chain: [], numbers: [], docs: [], meta: [], checked: new Map(), order: [],
+ arts: new Map(), queryAliases: new Map(),
}
const events = Array.isArray(data.events) ? data.events.filter(isObj) : []
const meta = data.meta
@@ -1313,12 +1321,33 @@
if (isObj(entry) && typeof entry.artifact === 'string') push(byDoc, entry.artifact, { report, alias })
}
}
+ // 工件 → 报告目录里它的编号(Q1、M1……):数字详情里口径卡的输入据此写出「查询 Q1 · 第 N 行 · 列」
+ for (const [id, refs] of byDoc) {
+ if (refs.length && !out.queryAliases.has(id)) out.queryAliases.set(id, refs[0].alias)
+ }
+ out.arts = arts
+ // 已核对的口径卡记着的输入 → 输入指向的查询快照。卡本身核过哈希(③ 通过),它记的引用和文档的证据目录
+ // 同一个分量:报告只引用指标、不直接引用查询时,查询快照就是从这条路嵌进来、也是从这条路认的。
+ // 没核对过的卡(哈希对不上)不认:被改过的卡说输入来自哪,不能替它圆
+ const byCard = new Map() // 工件 id → [{ card, metric }]
+ for (const item of out.artifacts) {
+ if (item.a.kind !== 'metric_set' || item.result !== PASS) continue
+ for (const metric of Array.isArray(item.json.metrics) ? item.json.metrics : []) {
+ if (!isObj(metric) || !Array.isArray(metric.inputs)) continue
+ for (const input of metric.inputs) {
+ if (isObj(input) && typeof input.artifact === 'string' && input.artifact) {
+ push(byCard, input.artifact, { card: item, metric: metric.id })
+ }
+ }
+ }
+ }
for (const item of out.artifacts) {
if (item.a.kind === 'report_doc') continue
const evs = byEvent.get(item.a.id) || []
const docs = byDoc.get(item.a.id) || []
- const ok = evs.length > 0 || docs.length > 0
- out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, result: ok ? PASS : FAIL,
+ const cards = byCard.get(item.a.id) || []
+ const ok = evs.length > 0 || docs.length > 0 || cards.length > 0
+ out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, cards, result: ok ? PASS : FAIL,
reason: ok ? '' : T.chainUnreferenced })
if (ok) chained.add(item.a.id)
}
@@ -1402,6 +1431,8 @@
const at = metrics.findIndex((m) => isObj(m) && m.id === metricId)
const metric = metrics[at]
info.card = art.json
+ // 指标本身:详情里据此展示指标名、原式、代入式、输入和复算(计算方式)
+ info.metric = metric || null
if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric }
info.truth = metric.value
info.truthToken = tokenAt(tokensOf(art), ['metrics', at, 'value'])
@@ -1717,9 +1748,36 @@
return [' ', badge(item.result), item.reason ? h('span', { class: 'muted' }, ` ${item.reason}`) : null]
}
+ /** 一份随文件附带的查询快照:SQL + 被引用的那一行(引用的格高亮)。数字详情里单元格引用和口径卡的输入共用。
+ * art 是这份快照的核对条目:数照 JSON 原文写(同「快照里的值」),超过 2^53 的订单号读成 double 会丢位 */
+ function snapshotBlock(snap, row, column, art) {
+ const parts = []
+ if (typeof snap.sql === 'string' && snap.sql) parts.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, snap.sql))
+ if (snap.source) parts.push(kv([[T.dSourceName, String(snap.source)]]))
+ const record = Number.isInteger(row) && Array.isArray(snap.rows) ? snap.rows[row] : undefined
+ if (Array.isArray(record) || isObj(record)) {
+ // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮
+ let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String)
+ if (isObj(record) && !columns.length) columns = Object.keys(record)
+ const tokens = art ? tokensOf(art) : null
+ const cellText = (c, i) => {
+ const value = isObj(record) ? own(record, c) : record[i]
+ if (value == null) return '—'
+ if (typeof value !== 'number') return textOf(value)
+ return numberText(value, tokenAt(tokens, ['rows', row, isObj(record) ? c : i]))
+ }
+ parts.push(h('p', { class: 'sub' }, T.dRow(row)), h('div', { class: 'table-wrap' }, h('table', {},
+ h('thead', {}, h('tr', {}, columns.map((c) => h('th', { class: c === column ? 'hit' : null }, c)))),
+ h('tbody', {}, h('tr', {}, columns.map((c, i) => h('td', { class: c === column ? 'hit' : null,
+ 'data-cell': c }, cellText(c, i))))))))
+ }
+ return parts
+ }
+
function detailOf(number) {
const cite = isObj(number.seg.cite) ? number.seg.cite : null
const card = number.card
+ const metric = number.metric
// 显示文字核对过的(引用的值对上了,按显示规则渲染得出来):一致时注明,对不上时另列应显示的字。没有出处的数、
// 核对不了的引用不写:那些字没人重新渲染过。一致不等于通过:运行输入的显示文字对得上,引用的值仍核对不了;紧挨着
// 还有数字的,这处的字本身也对得上,读起来却不是这个值
@@ -1730,6 +1788,8 @@
[T.dSource, number.source],
number.node ? [T.dNode, nodeLabel(number.node)] : null,
card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null,
+ metric ? [T.dMetric, [metric.name || metric.id || '—', metric.id && metric.name ? h('span', { class: 'muted' }, `(${metric.id})`) : null]] : null,
+ metric && metric.unit ? [T.dUnit, String(metric.unit)] : null,
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, exactText(cite.value, number.citedToken)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell,
number.truthText ?? exactText(number.truth, number.truthToken)] : null,
@@ -1739,27 +1799,52 @@
[T.dReport, nodeLabel(number.report.node_id)],
]
const parts = [kv(rows)]
- const snap = number.snapshot
- if (snap && typeof snap.sql === 'string' && snap.sql) parts.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, snap.sql))
- const record = snap && Number.isInteger(number.row) && Array.isArray(snap.rows) ? snap.rows[number.row] : undefined
- if (Array.isArray(record) || isObj(record)) {
- // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮。数照 JSON
- // 原文写(同上面「快照里的值」):超过 2^53 的订单号读成 double 会丢位,和一位不差的「引用的值」对不上
- let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String)
- if (isObj(record) && !columns.length) columns = Object.keys(record)
- const art = view.out.artifacts.find((a) => a.a.id === number.artifact)
- const tokens = art ? tokensOf(art) : null
- const cellText = (c, i) => {
- const value = isObj(record) ? own(record, c) : record[i]
- if (value == null) return '—'
- if (typeof value !== 'number') return textOf(value)
- return numberText(value, tokenAt(tokens, ['rows', number.row, isObj(record) ? c : i]))
+ // 口径卡一节:计算方式(原式、代入式)、输入、复算。卡随文件附带才有(遮罩的只给 id,见核对明细 ③)
+ if (metric) {
+ parts.push(h('p', { class: 'sub' }, T.dExpression), h('pre', { class: 'sql' }, String(metric.expression || '—')))
+ parts.push(h('p', { class: 'sub' }, T.dSubstituted), h('pre', { class: 'sql' }, String(metric.substituted || '—')))
+ // 输入的值照卡上 JSON 的原文写(同「口径卡里的值」):超过 2^53 的数读成 double 会丢位
+ const cardArt = view.out.artifacts.find((a) => a.a.id === number.artifact)
+ const cardTokens = cardArt ? tokensOf(cardArt) : null
+ const at = Array.isArray(card && card.metrics) ? card.metrics.indexOf(metric) : -1
+ const inputs = (Array.isArray(metric.inputs) ? metric.inputs : [])
+ .map((input, i) => ({ input, token: tokenAt(cardTokens, ['metrics', at, 'inputs', i, 'value']) }))
+ .filter(({ input }) => isObj(input))
+ if (inputs.length) {
+ parts.push(h('p', { class: 'sub' }, T.dInputs),
+ h('ul', { class: 'items' }, inputs.map(({ input, token }) => {
+ const loc = isObj(input.locator) ? input.locator : {}
+ const row = Number.isInteger(loc.row) ? loc.row : null
+ const column = loc.column != null ? String(loc.column) : null
+ const alias = typeof input.artifact === 'string' ? view.out.queryAliases.get(input.artifact) : undefined
+ const from = [T.inputFrom(alias, row, column), input.node_id ? nodeLabel(input.node_id) : null]
+ .filter(Boolean).join(' · ')
+ return h('li', {},
+ h('span', { class: 'what' }, [
+ `${T.dInputValue} `, exactText(input.value, token),
+ h('span', { class: 'muted' }, ` ← ${input.path || '—'}`),
+ ]),
+ from ? h('span', { class: 'why' }, `${T.dFrom} ${from}`) : null)
+ })))
+ // 输入指向的查询快照:随文件附带的给出 SQL 和被引用的那一行;没附带的(遮罩等)说明按卡的记录展示
+ for (const { input } of inputs) {
+ if (typeof input.artifact !== 'string' || !input.artifact) continue
+ const art = view.out.arts.get(input.artifact)
+ if (!art || art.result !== PASS) {
+ parts.push(h('p', { class: 'note' }, `${T.inputNotAttached}${art && art.a.masked ? `:${art.reason}` : ''}`))
+ continue
+ }
+ const loc = isObj(input.locator) ? input.locator : {}
+ parts.push(...snapshotBlock(art.json, Number.isInteger(loc.row) ? loc.row : null,
+ loc.column != null ? String(loc.column) : null, art))
+ }
}
- parts.push(h('p', { class: 'sub' }, T.dRow(number.row)), h('div', { class: 'table-wrap' }, h('table', {},
- h('thead', {}, h('tr', {}, columns.map((c) => h('th', { class: c === number.column ? 'hit' : null }, c)))),
- h('tbody', {}, h('tr', {}, columns.map((c, i) => h('td', { class: c === number.column ? 'hit' : null,
- 'data-cell': c }, cellText(c, i))))))))
+ parts.push(kv([[T.dRecompute, metric.recompute_ok == null ? T.recomputeNone
+ : metric.recompute_ok ? T.recomputeOk : T.recomputeBad]]))
}
+ const snap = number.snapshot
+ if (snap) parts.push(...snapshotBlock(snap, number.row, number.column,
+ view.out.artifacts.find((a) => a.a.id === number.artifact)))
return parts
}
@@ -1920,6 +2005,7 @@
return [T.chainDoc(nodeLabel(c.report.node_id)), ' ', h('code', {}, short(c.report.doc_artifact)), from]
}
const refs = [...c.docs.map((d) => T.chainByDoc(nodeLabel(d.report.node_id), d.alias)),
+ ...c.cards.map((k) => T.chainByCard(nodeLabel(k.card.a.node_id), k.metric)),
...c.events.map((ev) => T.chainByEvent(ev.seq, eventWhat(ev)))]
return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)),
refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : '']
diff --git a/backend/app/report_export/__init__.py b/backend/app/report_export/__init__.py
new file mode 100644
index 00000000..6acb897b
--- /dev/null
+++ b/backend/app/report_export/__init__.py
@@ -0,0 +1,13 @@
+"""报告导出:把一次运行的报告导出成一个自包含的 HTML 文件,正文里的数字点得开口径、来源和计算方式。
+
+- build.py:把报告文档渲染成正文 HTML(不执行脚本也读得到),连同每个数字的出处链一起填进模板(纯函数,不碰数据库)
+- template.html:页面骨架和样式
+- runtime.js:浏览器端的出处面板(原生 JS,无依赖;正文是服务端渲染好的,脚本只负责点开数字)
+
+接口在 api/evidence.py(GET /api/runs/{run_id}/report.html)——出处链和证据面板(点开报告里一个片段的接口)
+用的是同一套拼法,页面上看到的就是网页上看到的。
+
+和可验证报告(app/bundle)的分工:那边导出的是封存核对件,收件人核的是「没被改过」;这边导出的是报告本身,
+读的人看的是每个数字的口径、来源和计算方式,封存状态照网页上的样子逐项标明。要向系统外的人证明文件没被改过,
+用可验证报告;要让人读报告、查数字的来历,用这份。
+"""
diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py
new file mode 100644
index 00000000..df2ccb88
--- /dev/null
+++ b/backend/app/report_export/build.py
@@ -0,0 +1,390 @@
+"""报告导出的构建:报告文档 → 自包含的 HTML(正文服务端渲染 + 每个数字的出处链嵌进数据块)。
+
+拿到文件的人在浏览器里打开:不执行脚本也读得到整份报告(正文是服务端按文档的块、句、片段渲染好的 HTML);
+执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的
+出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
+输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查),
+以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。
+
+这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希
+(可验证报告,app/bundle)。页面页脚写明这一点。
+
+主体是纯函数:不碰数据库。调用方(api/evidence 的路由)取好报告文档、逐个片段算好出处链再交进来。
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import html
+import json
+import re
+from collections.abc import Mapping, Sequence
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+SCHEMA = "agentlab.report/1"
+
+#: 和 bundle/build.py 的附件名同一个规矩:文件名里不能有的字(Windows 最严,按它来)换成下划线
+_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
+_NAME_MAX = 80
+FILE_SUFFIX = "-报告.html"
+#: 没挂在工作流上的运行叫「未保存的工作流」(同 bundle/build.py、前端 lib/terms.ts 的 runName)
+UNSAVED_NAME = "未保存的工作流"
+_UNSAVED_NAMES = frozenset({"", "临时图", UNSAVED_NAME})
+
+
+def display_name(run: Mapping[str, Any]) -> str:
+ """页面标题、附件名用的运行名:工作流名;没保存的工作流一律叫「未保存的工作流」。(同 bundle/build.py)"""
+ name = str(run.get("workflow_name") or "").strip()
+ if not run.get("workflow_id") and name in _UNSAVED_NAMES:
+ return UNSAVED_NAME
+ return name or f"运行 {str(run.get('id') or '')[:8]}".strip()
+
+
+def attachment_name(run: Mapping[str, Any]) -> str:
+ """附件名「<运行名>-报告.html」。"""
+ name = _BAD_FILENAME.sub("_", display_name(run)).strip(" .")[:_NAME_MAX].strip(" .")
+ return f"{name or UNSAVED_NAME}{FILE_SUFFIX}"
+
+
+def content_disposition(filename: str, *, run_id: str) -> str:
+ """附件头:中文名按 RFC 5987 写在 filename* 里;filename 给不认 filename* 的老客户端一个纯 ASCII 的名字。
+ (同 bundle/build.py 的拼法,脚本 check-bundle.mjs 对这份文件同样断言附件名。)"""
+ ascii_id = re.sub(r"[^A-Za-z0-9]", "", run_id)[:8]
+ return f"attachment; filename=\"agentlab-{ascii_id}-report.html\"; filename*=UTF-8''{quote(filename, safe='')}"
+
+
+# --------------------------------------------------------------------------
+# 正文:报告文档 → HTML(服务端渲染)
+# --------------------------------------------------------------------------
+
+#: 行内语法的写法和 bundle/verifier.js、前端 run/Markdown.tsx 同一套:代码(反引号)、链接(只显示文字:
+#: 页面不连网)、粗体、斜体、删除线。斜体左边紧挨 * 的不算(否则 **粗体** 会先被它拆掉)
+_INLINE = re.compile(
+ r"(`+)([^`]+?)\1|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|\*\*([^*]+?)\*\*|__([^_]+?)__"
+ r"|\*([^*\n]+?)\*(?!\*)|~~([^~]+?)~~")
+_CODE, _LINK, _BOLD, _EM, _STRIKE = 1, 2, 4, 8, 16
+#: 列表项的开头:行首的 -、*、+ 或者序号(同 verifier.js 的 ITEM_START)
+_ITEM_START = re.compile(r"(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$")
+#: 表格里看着是数的列(右对齐):同 verifier.js 的 NUMERIC_CELL
+_NUMERIC_CELL = re.compile(r"^[-+−]?[\d,]+(?:\.\d+)?%?[^\d\s]{0,4}$")
+
+#: 片段的状态 → 正文里的线型(class)。同网页上证据文档的颜色语言:实线绿是有出处,点线是核对不了,
+#: 波浪红是可疑(反引号里发现的编造名字这类)。probabilistic / candidate 按未裁判、候选画
+_STATE_CLASS = {
+ "deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad",
+ "unjudged": "na", "candidate": "na",
+}
+_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"}
+
+
+def _esc(text: Any) -> str:
+ return html.escape(str(text if text is not None else ""))
+
+
+def _inline_matches(part: str) -> list[re.Match[str]]:
+ """一句里的行内语法,按先后。斜体左边紧挨着 * 的跳过、从下一个字接着找(同 verifier.js)。"""
+ out: list[re.Match[str]] = []
+ pos = 0
+ while True:
+ m = _INLINE.search(part, pos)
+ if m is None:
+ return out
+ if m.group(7) is not None and m.start() > 0 and part[m.start() - 1] == "*":
+ pos = m.start() + 1
+ continue
+ out.append(m)
+ pos = m.end()
+
+
+def _inline_marks(text: str) -> tuple[bytearray, bytearray]:
+ """整句算一次行内样式:hide 是语法符号占的位置(不显示),style 是每个可见字的样式位。
+ 粗体、代码可能跨片段(服务端把一句切成了好几个片段),所以按整句算、每个片段取自己那一截。
+ ( verifier.js inlineStyles 的 Python 版,规则一致。)"""
+ hide = bytearray(len(text))
+ style = bytearray(len(text))
+
+ def walk(frm: int, to: int, inherited: int, depth: int) -> None:
+ part = text[frm:to]
+ last = 0
+ for m in _inline_matches(part):
+ code, link_text, bold1, bold2, italic, strike = (
+ m.group(2), m.group(3), m.group(5), m.group(6), m.group(7), m.group(8))
+ for i in range(last, m.start()):
+ style[frm + i] = inherited
+ inner = next((v for v in (code, link_text, bold1, bold2, italic, strike) if v is not None), "") or ""
+ base = frm + m.start()
+ whole = m.group(0)
+ off = len(m.group(1) or "") if code is not None else whole.find(inner)
+ for i in range(len(whole)):
+ hide[base + i] = 1
+ for i in range(len(inner)):
+ hide[base + off + i] = 0
+ bit = _CODE if code is not None else _LINK if link_text is not None \
+ else _BOLD if bold1 is not None or bold2 is not None \
+ else _EM if italic is not None else _STRIKE
+ if code is not None or link_text is not None or depth >= 3:
+ for i in range(len(inner)):
+ style[base + off + i] = inherited | bit
+ else:
+ walk(base + off, base + off + len(inner), inherited | bit, depth + 1)
+ last = m.end()
+ for i in range(last, len(part)):
+ style[frm + i] = inherited
+
+ walk(0, len(text), 0, 0)
+ return hide, style
+
+
+def _styled_html(text: str, hide: bytearray, style: bytearray, from_: int, to: int) -> str:
+ """一句里 [from_, to) 那一段按算好的样式写成 HTML。"""
+ out: list[str] = []
+ i = from_
+ while i < to:
+ if hide[i]:
+ i += 1
+ continue
+ st = style[i]
+ j = i + 1
+ while j < to and not hide[j] and style[j] == st:
+ j += 1
+ piece = _esc(text[i:j])
+ if st & _CODE:
+ piece = f"{piece}"
+ if st & _EM:
+ piece = f"{piece}"
+ if st & _BOLD:
+ piece = f"{piece}"
+ if st & _STRIKE:
+ piece = f"{piece}"
+ if st & _LINK:
+ piece = f'{piece}'
+ out.append(piece)
+ i = j
+ return "".join(out)
+
+
+def segment_state(seg: Mapping[str, Any]) -> str | None:
+ """片段的状态(同前端 segmentState 的分法):文字、结构片段是 None(不画线、点不开)。
+ 导出的路由按它决定哪个片段要算出处链,正文渲染按它画线,两边同一个分法。"""
+ if seg.get("kind") == "structural":
+ return None
+ state = seg.get("state")
+ if state == "deterministic":
+ return "deterministic"
+ if state == "none":
+ if seg.get("issue") == "unknown_entity" or (seg.get("cite") or {}).get("unknown"):
+ return "suspect"
+ if seg.get("issue") == "unverified_entity" or (seg.get("cite") or {}).get("unverified"):
+ return "unverified"
+ return "none"
+ if state in ("probabilistic", "candidate"):
+ return "unjudged" if state == "probabilistic" else "candidate"
+ return None
+
+
+def _seg_html(seg: Mapping[str, Any], body: str) -> str:
+ """一个片段:有状态的是按钮(点开出处面板),文字、结构片段照字渲染。"""
+ state = segment_state(seg)
+ kind = str(seg.get("kind") or "")
+ if state is None:
+ inner = f"{body}" if seg.get("strong") else body
+ return f"{inner}"
+ cls = _STATE_CLASS.get(state, "na")
+ label = _KIND_LABEL.get(kind, "片段")
+ title = f"{label}「{_esc(seg.get('text'))}」:点开查看出处"
+ attrs = (f'class="num num-{cls}" data-seg="{_esc(seg["id"])}" data-state="{_esc(state)}"'
+ f' data-kind="{_esc(kind)}" title="{title}"')
+ if seg.get("strong"):
+ body = f"{body}"
+ return f''
+
+
+def _render_unit(unit: Mapping[str, Any]) -> str:
+ """一句:片段先拼成整句算行内样式,再各自取自己那一截(结构片段不进正文,列表序号由
{"".join(_render_unit(u) for u in units)}' + if btype == "code": + return f'
{_esc("".join(_unit_text(u) for u in units))}'
+ if btype == "list":
+ items: list[dict[str, Any]] = []
+ for u in units:
+ lead = (u.get("segments") or [None])[0]
+ starts = not items or (isinstance(lead, dict) and lead.get("kind") == "structural"
+ and _ITEM_START.match(str(lead.get("text") or "")))
+ if starts:
+ items.append({"depth": int(u.get("depth") or 0), "units": [u]})
+ else:
+ items[-1]["units"].append(u)
+ tag = "ol" if block.get("ordered") else "ul"
+ start = f' start="{int(block["start"])}"' if block.get("ordered") \
+ and isinstance(block.get("start"), int) and block["start"] != 1 else ""
+ rows = []
+ for item in items:
+ margin = f' style="margin-left:{item["depth"] * 14}px"' if item["depth"] else ""
+ rows.append(f'{"".join(_render_unit(u) for u in units)}
' + + +def render_body(doc: Mapping[str, Any]) -> str: + """报告文档的正文 HTML。块都读不出来时退回 markdown 原文(等宽显示,至少读得到)。""" + blocks = [b for b in doc.get("blocks") or [] if isinstance(b, dict)] + if not blocks: + return f'{_esc(doc.get("markdown") or "")}