合入报告导出为自包含 HTML(数字点得开口径、来源、计算方式)与可验证报告数字详情里的口径卡一节;和可验证报告的遮罩改造合并:口径卡只给 id 的规则(有指标的值取自遮罩列、文档里写成「已遮罩」的)照旧,随文件附带的卡才附上它的输入指向的查询快照;数字详情保留显示文字核对和超过 2^53 的数照 JSON 原文显示,口径卡一节里输入的值同样照原文;样本页从 16 份变成 17 份

This commit is contained in:
ThearchyHelios
2026-10-13 10:03:24 +08:00
17 changed files with 2120 additions and 60 deletions
+13 -3
View File
@@ -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 不监听文件,跑完、出错或被中断都收干净。
+97 -1
View File
@@ -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)
+43 -7
View File
@@ -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 = []
+107 -21
View File
@@ -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('、')}` : '']
+13
View File
@@ -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)的分工:那边导出的是封存核对件,收件人核的是「没被改过」;这边导出的是报告本身,
读的人看的是每个数字的口径、来源和计算方式,封存状态照网页上的样子逐项标明。要向系统外的人证明文件没被改过,
用可验证报告;要让人读报告、查数字的来历,用这份。
"""
+390
View File
@@ -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"<code>{piece}</code>"
if st & _EM:
piece = f"<em>{piece}</em>"
if st & _BOLD:
piece = f"<strong>{piece}</strong>"
if st & _STRIKE:
piece = f"<s>{piece}</s>"
if st & _LINK:
piece = f'<span class="link">{piece}</span>'
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"<strong>{body}</strong>" if seg.get("strong") else body
return f"<span>{inner}</span>"
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"<strong>{body}</strong>"
return f'<button type="button" {attrs}>{body}</button>'
def _render_unit(unit: Mapping[str, Any]) -> str:
"""一句:片段先拼成整句算行内样式,再各自取自己那一截(结构片段不进正文,列表序号由 <li> 自己画)。"""
segs = [s for s in unit.get("segments") or [] if isinstance(s, dict) and s.get("kind") != "structural"]
text = "".join(str(s.get("text") or "") for s in segs)
hide, style = _inline_marks(text)
out: list[str] = []
pos = 0
for seg in segs:
body = _styled_html(text, hide, style, pos, pos + len(str(seg.get("text") or "")))
out.append(_seg_html(seg, body))
pos += len(str(seg.get("text") or ""))
return "".join(out)
def _unit_text(unit: Mapping[str, Any]) -> str:
return "".join(str(s.get("text") or "") for s in unit.get("segments") or []
if isinstance(s, dict) and s.get("kind") != "structural")
def _render_block(block: Mapping[str, Any]) -> str:
"""一块:标题、正文、引用、代码、列表、表格、分隔线。(verifier.js renderBlock 的 Python 版。)"""
units = [u for u in block.get("units") or [] if isinstance(u, dict)]
btype = str(block.get("type") or "paragraph")
if btype == "heading":
level = min(max(int(block.get("level") or 2), 1), 3)
return f'<h{level + 3} class="doc-h doc-h{level}">{"".join(_render_unit(u) for u in units)}</h{level + 3}>'
if btype == "hr":
return "<hr>"
if btype == "quote":
return f'<blockquote>{"".join(_render_unit(u) for u in units)}</blockquote>'
if btype == "code":
return f'<pre><code>{_esc("".join(_unit_text(u) for u in units))}</code></pre>'
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'<li{margin}>{"".join(_render_unit(u) for u in item["units"])}</li>')
return f"<{tag}{start}>{''.join(rows)}</{tag}>"
if btype == "table":
# 表头 row = -1;一格可能被分成几句,按位置归拢(同 verifier.js)
cells: dict[str, list[Mapping[str, Any]]] = {}
row_ids: list[int] = []
cols = 0
for u in units:
loc = u.get("loc") if isinstance(u.get("loc"), dict) else {"row": 0, "col": 0}
key = f'{loc.get("row")}:{loc.get("col")}'
cells.setdefault(key, []).append(u)
cols = max(cols, int(loc.get("col") or 0) + 1)
if int(loc.get("row") or 0) >= 0 and int(loc.get("row") or 0) not in row_ids:
row_ids.append(int(loc.get("row")))
row_ids.sort()
def text_at(r: int, c: int) -> str:
return re.sub(r"[*`]", "", "".join(_unit_text(u) for u in cells.get(f"{r}:{c}", []))).strip()
numeric = []
for c in range(cols):
texts = [text_at(r, c) for r in row_ids if text_at(r, c)]
numeric.append(bool(texts) and all(_NUMERIC_CELL.match(t) for t in texts))
def cell(tag: str, r: int, c: int) -> str:
cls = f' class="n"' if tag == "td" and numeric[c] else ""
return f"<{tag}{cls}>{''.join(_render_unit(u) for u in cells.get(f'{r}:{c}', []))}</{tag}>"
head = "".join(cell("th", -1, c) for c in range(cols))
body = "".join("<tr>" + "".join(cell("td", r, c) for c in range(cols)) + "</tr>" for r in row_ids)
return (f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
f"<tbody>{body}</tbody></table></div>")
return f'<p>{"".join(_render_unit(u) for u in units)}</p>'
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'<div class="doc"><pre>{_esc(doc.get("markdown") or "")}</pre></div>'
return f'<div class="doc">{"".join(_render_block(b) for b in blocks)}</div>'
# --------------------------------------------------------------------------
# 数据块和页面
# --------------------------------------------------------------------------
#: 数据放在 <script type="application/json"> 里:< > & 换成转义,正文里的 </script>、<!-- 截不断数据块
#: (同 bundle/build.py 的 _JSON_ESCAPES)
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
"\u2028": "\\u2028", "\u2029": "\\u2029"})
_PLACEHOLDER = re.compile(r"__REPORT_(TITLE|NAME|SEAL|RUN|BODY|CSP|DATA|SCRIPT)__")
def build_report(
run: Mapping[str, Any],
*,
report_node: str,
doc_artifact: str,
doc_hash_ok: bool | None,
doc: Mapping[str, Any],
seal: Mapping[str, Any],
chains: Mapping[str, Sequence[Mapping[str, Any]]],
refs: Mapping[str, str],
notes: Mapping[str, str],
nodes: Mapping[str, Mapping[str, Any]],
exported_at: str,
) -> dict[str, Any]:
"""页面里的数据(report-data)。
- run:运行记录的几样(id、workflow_id、workflow_name、run_class、status、started_at、finished_at)
- report_node / doc_artifact / doc_hash_ok:选中的报告节点、它的文档工件、取回时哈希核对的结果
- doc:报告文档(正文的服务端渲染另走 render_body,这里不给:数据块只装点开数字要用的东西)
- seal:{_sealed 的 seal 字段里的几样}(sealed、ok、manifest_hash、manifest_seq)
- chains:每处引用的出处链(api/evidence._chain 的原样输出),键是调用方编的引用键
- refs:片段 id → 引用键(点开这个片段看哪条链)
- notes:片段 id → 为什么是现在这个状态(api/evidence._note)
- nodes:节点 id → {label, type}
"""
return {
"schema": SCHEMA,
"meta": {
"run_id": run.get("id"), "workflow_id": run.get("workflow_id"),
"workflow_name": display_name(run), "run_class": run.get("run_class"), "status": run.get("status"),
"started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")),
"report": {"node_id": report_node, "label": (nodes.get(report_node) or {}).get("label") or report_node},
"doc_artifact": doc_artifact, "doc_hash_ok": doc_hash_ok,
"seal": {k: seal.get(k) for k in ("sealed", "ok", "manifest_hash", "manifest_seq") if k in seal},
"exported_at": exported_at,
},
"nodes": dict(nodes),
"chains": {k: [*v] for k, v in chains.items()},
"refs": dict(refs),
"notes": dict(notes),
}
def _iso(value: Any) -> str | None:
return None if value is None else value.isoformat() if hasattr(value, "isoformat") else str(value)
@lru_cache(maxsize=1)
def _assets() -> tuple[str, str]:
here = Path(__file__).parent
return (here / "template.html").read_text(encoding="utf-8"), (here / "runtime.js").read_text(encoding="utf-8")
def _json_text(data: Mapping[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")).translate(_JSON_ESCAPES)
def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str:
"""把正文和数据填进模板:样式、脚本都内联,零外链。
内容安全策略按哈希只放行这一段脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
正文是服务端渲染的 HTML,封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览常常不执行脚本,
也要读得到报告、看得到该拿什么去系统里对照;执行脚本后头部换成完整的信息、数字点得开出处。
占位符一次替换完,填进去的内容不会再被当成占位符。(同 bundle/build.py render_html 的做法。)"""
template, script = _assets()
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
meta = data.get("meta") or {}
name = str(meta.get("workflow_name") or UNSAVED_NAME)
seal = meta.get("seal") or {}
manifest = str(seal.get("manifest_hash") or "")
# 不执行脚本时封存那一格写什么:封存了给哈希(拿去系统对照),没封存照实说
seal_text = f"封存清单哈希 <code>{_esc(manifest)}</code>(运行结束并封存后可用它与系统对照)" if manifest \
else "导出时这次运行尚未封存:出处与封存状态是导出那一刻的记录"
values = {
"TITLE": _esc(f"{name} · 报告"),
"NAME": _esc(name),
"SEAL": seal_text,
"RUN": _esc(str(meta.get("run_id") or "")),
"BODY": render_body(doc),
"CSP": f"'sha256-{digest}'",
"DATA": _json_text(data),
"SCRIPT": script,
}
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)
+465
View File
@@ -0,0 +1,465 @@
/*
* 报告导出的浏览器端:点开正文里的一个数字 / 名称 / 引文,展示它的出处链(口径、来源、计算方式)。
* 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事:
* ① 填页面头部(报告节点、文档工件、封存状态、运行信息);
* ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板;
* ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
* 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、
* 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。
* 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
(() => {
'use strict'
// ------------------------------------------------------------------ 文案
const T = {
schema: (s) => `文件格式 ${s}`,
stateLabel: {
deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实',
unjudged: '未裁判', candidate: '猜测的来源',
},
kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' },
segTitle: (kind, text) => `${kind}「${text}」`,
metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点',
metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间',
classLabel: { formal: '正式运行', exploratory: '探索运行' },
statusLabel: { succeeded: '已完成', failed: '已失败', cancelled: '已取消', running: '进行中', waiting: '等待审批' },
docHashBad: '这份报告文档与哈希不一致(疑似被修改),正文仅供参考,其中的口径和出处未经核对。',
sealNone: '这次运行导出时尚未封存:下列出处来自导出那一刻的系统记录,未经封存核对。',
sealOk: (hash) => `运行已封存,清单哈希 ${hash}。要向系统外的人证明文件未被修改,请导出「可验证报告」(记录页 · 凭证详情),拿封存哈希去系统对照。`,
sealBroken: '封存核对不一致:事件在封存后被修改、删除或插入,下列出处未经核对。',
noChain: '这一处没有可展示的出处链。',
// 指标步骤
metric: '口径卡指标', dName: '指标', dValue: '值', dCaliber: '口径', dExpression: '原式(计算方式)',
dSubstituted: '代入式(代入输入后的算式)', dInputs: '输入', dRecompute: '复算', dUnit: '单位',
dSource: '来源', dPinned: '口径来源', dUpgrade: '升版处置', dDual: '双盲复算', dArtifact: '工件',
dNode: '来源节点', dStatus: '状态', dDecimals: '小数位',
recomputeOk: '复算一致', recomputeBad: '复算不一致:代入式的计算结果与记录值不符', recomputeNone: '无法复算',
incomplete: '结果不完整', incompleteWhy: (t) => `基于被截断的查询结果计算,结果不完整${t ? `:${t}` : ''}`,
sqlCheckFailed: 'SQL 检查未通过', sqlCheckWhy: (t) => `所依据的查询未通过 SQL 检查,结果不可靠${t ? `:${t}` : ''}`,
missingValue: '缺少输入:口径卡本次未计算出该指标的值',
dualLabel: { agreed: '一致', disagreed: '不一致', unchecked: '无法复算', skipped: '未复算' },
dualMore: (t) => `(${t})`,
// 输入步骤
input: '输入', dPath: '路径', dFrom: '取自', dQuery: '查询', dCell: '位置', dMasked: '已遮罩',
maskNote: '取自设置了遮罩的列,值不随文件显示。遮罩只减少暴露,不是安全边界',
maskUnverified: (t) => `遮没遮核对不了:${t}`,
truncated: '查询结果已截断,该字段只含取回的部分行',
// 查询步骤
query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`,
dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分',
truncatedRows: '查询结果被截断,取回的行不完整',
merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告',
mergeNoLineage: '没有逐格来历',
dRowOf: (n) => `第 ${n + 1} 行`,
// 其余步骤
runInput: '运行输入', dField: '字段', entity: '表或字段', quote: '引文', dNameEntity: '名称',
dTable: '表', dColumn: '列', dFields: '字段', dFieldsMore: (n) => `另 ${n} 个字段`,
dQueries: '出现在哪几次查询', dChecked: '查过的地方', dClosest: '最接近的已知名称',
dContent: '原文', dMatch: '比对', dCollection: '知识库', dWhy: '原因',
present: '在', absent: '不在',
matchOk: '原文一致', matchBad: '与原文不一致', matchNone: '无法比对',
// 完整性(每一步都可带)
dIntegrity: '核对', sealedOk: '已封存 · 哈希核对一致', sealedNo: '不在封存范围内',
hashBad: '工件哈希核对失败', hashNone: '未能读取工件',
note: '说明',
close: '关闭', openAria: (text) => `查看出处:${text}`,
}
const MASKED = '已遮罩'
const NONE = '—'
// ------------------------------------------------------------------ 工具
const $ = (id) => document.getElementById(id)
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const own = (obj, key) => (isObj(obj) && typeof key === 'string' && Object.prototype.hasOwnProperty.call(obj, key)
? obj[key] : undefined)
const textOf = (v) => (typeof v === 'string' ? v : isObj(v) || Array.isArray(v) ? JSON.stringify(v) : String(v))
const short = (id) => (typeof id === 'string' && id.length > 12 ? `${id.slice(0, 12)}…` : String(id ?? ''))
const pad = (n) => String(n).padStart(2, '0')
function h(tag, props, ...kids) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(props || {})) {
if (value == null || value === false) continue
if (key === 'class') el.className = value
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value)
else el.setAttribute(key, value === true ? '' : String(value))
}
for (const kid of kids.flat(Infinity)) {
if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : String(kid))
}
return el
}
/** 时间按读者所在时区显示(文件会被转给别的时区的人),后面注明时区 */
function when(iso) {
if (!iso) return NONE
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
const offset = -d.getTimezoneOffset()
const zone = `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad(Math.abs(offset) % 60)}` : ''}`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}(${zone})`
}
const valueText = (v) => (v == null ? '空值(NULL)' : typeof v === 'string' ? `「${v}」` : textOf(v))
function kv(rows) {
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
}
const badge = (kind, text) => h('span', { class: 'badge', 'data-kind': kind }, text)
const ok = (good) => (good === true ? badge('ok', '✓') : good === false ? badge('bad', '✗') : badge('na', '—'))
// ------------------------------------------------------------------ 数据
const view = { data: null, active: null, back: null }
function parseData() {
try {
const data = JSON.parse($('report-data').textContent)
if (isObj(data) && isObj(data.meta)) return data
} catch (e) {
// 数据块坏了:正文还在(服务端渲染),只是点不开出处
}
return null
}
// ------------------------------------------------------------------ 头部
function renderHead(data) {
const meta = data.meta
$('report-label').textContent = `报告节点「${meta.report?.label || meta.report?.node_id || NONE}」`
$('doc-id').textContent = meta.doc_artifact ? `文档 ${short(meta.doc_artifact)}` : ''
if (meta.doc_hash_ok === false) {
const warn = $('doc-warn')
warn.textContent = T.docHashBad
warn.hidden = false
}
const label = (map, value) => map[value] || (value ? String(value) : NONE)
$('meta').replaceChildren(...[...[
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaClass, label(T.classLabel, meta.run_class)],
[T.metaStatus, label(T.statusLabel, meta.status)],
[T.metaReport, String(meta.report?.label || meta.report?.node_id || NONE)],
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
[T.metaExported, when(meta.exported_at)],
].filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])])
const seal = meta.seal || {}
const box = $('seal-text')
if (!seal.sealed) box.textContent = T.sealNone
else if (seal.ok === false) box.textContent = T.sealBroken
else box.textContent = T.sealOk(String(seal.manifest_hash || ''))
$('foot-schema').textContent = T.schema(String(data.schema || ''))
}
// ------------------------------------------------------------------ 每一步的渲染
const nodeLabel = (id) => {
const node = own(view.data.nodes, id)
return isObj(node) && node.label ? String(node.label) : (id ? String(id) : NONE)
}
/** 指标步骤:名称、值、口径(口径卡、版本、钉住的来源、升版处置)、原式、代入式、输入、复算、双盲复算 */
function metricPart(step) {
const body = []
const rows = [
[T.dName, step.name || step.metric || NONE],
step.unit ? [T.dUnit, String(step.unit)] : null,
step.decimals != null ? [T.dDecimals, String(step.decimals)] : null,
]
body.push(kv(rows))
// 口径:口径卡名 + 版本;钉在别的工作流上的写明来自哪一版
const caliber = [`${step.caliber || NONE}`, step.version ? ` ${step.version}` : ''].join('')
const source = isObj(step.source) ? step.source : null
body.push(kv([
[T.dCaliber, h('span', {}, `口径卡「${caliber}」`)],
source ? [T.dPinned, `工作流「${source.workflow_name || source.workflow_id}」的 ${source.caliber} @ ${source.caliber_version}`] : null,
]))
if (source && isObj(step.caliber_upgrade)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dUpgrade}:钉在 ${step.caliber_upgrade.pinned || NONE},上游最新 ${step.caliber_upgrade.latest || NONE},${step.caliber_upgrade.policy_label || step.caliber_upgrade.policy || NONE}`))
}
if (step.status === 'missing_input') body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.missingValue))
if (step.incomplete) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.incompleteWhy(step.incomplete_reason)))
if (step.sql_check_failed) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, T.sqlCheckWhy(step.sql_check_reason)))
// 计算方式:原式 + 代入式
body.push(h('p', { class: 'sub' }, T.dExpression), h('pre', { class: 'sql' }, String(step.expression || NONE)))
body.push(h('p', { class: 'sub' }, T.dSubstituted), h('pre', { class: 'sql' }, String(step.substituted || NONE)))
body.push(h('div', {}, kv([
[T.dRecompute, step.recompute_ok == null ? T.recomputeNone
: step.recompute_ok ? T.recomputeOk : T.recomputeBad],
])))
// 双盲复算(期 4):复核过这个指标才有
if (isObj(step.dual_check)) {
const d = step.dual_check
const more = d.reason || (d.blind_rendered ? `盲算值 ${d.blind_rendered}` : '')
body.push(h('div', { style: 'margin-top:8px' }, kv([
[T.dDual, `${T.dualLabel[d.status] || d.status || NONE}${more ? T.dualMore(more) : ''}`],
])))
}
return h('section', { class: 'part', 'data-step': 'metric' }, h('h3', {}, T.metric),
h('div', { class: 'part-body' }, ...body))
}
/** 输入步骤:路径、值(遮罩的写「已遮罩」)、取自哪个节点、哪次查询的哪一格 */
function inputPart(step) {
const masked = Array.isArray(step.masked) && step.masked.length
const rows = [
[T.dPath, h('code', {}, String(step.path || NONE))],
[T.dValue, step.value === MASKED ? T.dMasked : valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.cell ? [T.dCell, String(step.cell)] : null,
step.via ? [T.dFrom, step.via === 'tool_cell' ? '查询结果的一格' : step.via === 'agent_field' ? 'Agent 交来的字段'
: step.via === 'position' ? '按位置取的一格' : String(step.via)] : null,
]
const bits = []
if (masked) bits.push(badge('na', `${T.dColumnsMasked}:${step.masked.join('、')}`))
if (step.mask_unverified) bits.push(badge('na', T.maskUnverified(step.mask_unverified)))
if (step.status === 'truncated') bits.push(badge('wait', T.truncated))
return h('section', { class: 'part', 'data-step': 'input' },
h('h3', {}, `${T.input}「${step.path || NONE}」`),
h('div', { class: 'part-body' }, kv(rows),
masked ? h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.maskNote) : null,
bits.length ? h('div', { class: 'chips' }, ...bits) : null))
}
/** 查询步骤:SQL、数据源、被引用的行(高亮那一格)、遮罩列、SQL 检查、合并视图 */
function queryPart(step) {
const body = []
const rows = [
step.source ? [T.dSourceName, String(step.source)] : null,
step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.alias ? [T.dQuery, String(step.alias)] : null,
step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql))
if (step.mask_note) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(step.mask_note)))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
// 被引用的行:前后各带几行,引用的那几格高亮;遮罩列写「已遮罩」
if (Array.isArray(step.rows) && step.rows.length && Array.isArray(step.columns)) {
const offset = Number.isInteger(step.row_offset) ? step.row_offset : 0
const index = Array.isArray(step.row_index) ? step.row_index : step.rows.map((_, i) => i + offset)
const cells = (step.highlight && Array.isArray(step.highlight.cells) ? step.highlight.cells : [])
const hit = new Set(cells.map((c) => `${c[0]}:${c[1]}`))
const hitCols = new Set(cells.map((c) => String(c[1])))
const masked = new Set((step.masked || []).map(String))
const table = h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, step.columns.map((c) => h('th', { class: hitCols.has(String(c)) ? 'hit' : null }, String(c))))),
h('tbody', {}, step.rows.map((row, i) => {
const r = index[i]
const record = Array.isArray(row) ? row : isObj(row) ? step.columns.map((c) => own(row, c)) : [row]
return h('tr', {}, record.map((v, j) => h('td', {
class: [hit.has(`${r}:${step.columns[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : '']
.filter(Boolean).join(' ') || null,
}, masked.has(String(step.columns[j])) ? MASKED : v == null ? NONE : textOf(v))))
}))))
body.push(h('p', { class: 'sub' }, `${T.dRows}(${T.dRowOf(offset)} 起,引用的格高亮)`), table)
if (step.window_truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.windowCut))
if (step.truncated) body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, T.truncatedRows))
}
// SQL 检查(查出问题才有)
if (Array.isArray(step.checks) && step.checks.length) {
body.push(h('p', { class: 'sub' }, T.dChecks),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.checks.map((c) => h('li', {}, `${c.level || 'error'} · ${c.code || ''}:${c.message || ''}`))))
}
// 合并查询:输入、合并 SQL、逐格来历、警告
if (isObj(step.merge)) {
body.push(h('p', { class: 'sub' }, T.merge))
if (Array.isArray(step.merge.inputs) && step.merge.inputs.length) {
body.push(h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.inputs.map((i) => h('li', {},
`${i.query || i.alias || NONE} · ${i.label || nodeLabel(i.node_id)} · ${i.rows ?? '?'} 行${i.sealed === false ? ' · 未封存' : ''}`))))
}
if (Array.isArray(step.merge.traced) && step.merge.traced.length) {
body.push(h('p', { class: 'sub' }, T.mergeTraced),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.traced.map((t) => h('li', {}, t.input
? `第 ${t.cell[0] + 1} 行「${t.cell[1]}」← ${t.query || t.input} 的 ${T.dRowOf(t.row)}「${t.column}」`
: `第 ${t.cell[0] + 1} 行「${t.cell[1]}」:${t.note || T.mergeNoLineage}`))))
}
if (Array.isArray(step.merge.warnings) && step.merge.warnings.length) {
body.push(h('p', { class: 'sub' }, T.mergeWarnings),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.merge.warnings.map((w) => h('li', {}, String(w.message || '')))))
}
}
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'query' },
h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`),
h('div', { class: 'part-body' }, ...body))
}
/** 运行输入步骤 */
function runInputPart(step) {
return h('section', { class: 'part', 'data-step': 'run_input' }, h('h3', {}, T.runInput),
h('div', { class: 'part-body' }, kv([
[T.dField, String(step.field || NONE)],
[T.dValue, valueText(step.value)],
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
])))
}
/** 实体(表、字段)步骤:出现在哪几次查询、表结构快照、字段清单;可疑实体的查证和最接近的名字 */
function entityPart(step) {
const body = []
const rows = [
[T.dNameEntity, step.name || NONE],
step.table ? [T.dTable, String(step.table)] : null,
step.column ? [T.dColumn, String(step.column)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (step.reason) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.reason)))
if (Array.isArray(step.sources) && step.sources.length) {
body.push(h('p', { class: 'sub' }, T.dQueries),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.sources.map((s) => h('li', {},
`${s.kind === 'schema' ? '表结构快照' : '查询结果'} ${s.alias || short(s.artifact)} · ${s.sealed ? '已封存' : '未封存'}${s.hash_ok === false ? ' · 哈希核对失败' : ''}${s.present === false ? ` · 名字${T.absent}` : ''}`))))
}
if (Array.isArray(step.fields) && step.fields.length) {
body.push(h('p', { class: 'sub' }, T.dFields),
h('p', { style: 'margin:4px 0 0;font-size:13px' },
step.fields.map((f) => `${f.name}${f.type ? `(${f.type})` : ''}`).join('、')
+ (step.fields_more ? T.dFieldsMore(step.fields_more) : '')))
}
if (step.checked && (step.checked.schemas || step.checked.queries)) {
body.push(h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' },
`${T.dChecked}:表结构快照 ${step.checked.schemas} 份、查询 ${step.checked.queries} 次,都没有这个名字`))
}
if (Array.isArray(step.closest) && step.closest.length) {
body.push(h('p', { class: 'sub' }, T.dClosest),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.closest.map((c) => h('li', {}, `${c.name}${c.table ? `(表 ${c.table})` : ''}`))))
}
return h('section', { class: 'part', 'data-step': 'entity' }, h('h3', {}, `${T.entity}「${step.name || NONE}」`),
h('div', { class: 'part-body' }, ...body))
}
/** 引文步骤:原话在哪次检索的哪条命中里,带上原文好高亮 */
function quotePart(step) {
const body = []
const rows = [
[T.dContent, String(step.text || NONE)],
step.collection ? [T.dCollection, String(step.collection)] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.content === 'string' && step.content) {
const match = isObj(step.match) ? step.match : {}
const start = Number.isInteger(match.start) ? match.start : null
const end = Number.isInteger(match.end) ? match.end : null
const frag = document.createDocumentFragment()
if (start != null && end != null && 0 <= start && start <= end && end <= step.content.length) {
frag.append(step.content.slice(0, start))
frag.append(h('mark', { class: 'hit', style: 'padding:0 1px;border-radius:3px' }, step.content.slice(start, end)))
frag.append(step.content.slice(end))
} else {
frag.append(step.content)
}
body.push(h('p', { class: 'sub' }, T.dContent), h('blockquote', { style: 'margin:4px 0 0' }, frag))
}
body.push(kv([[T.dMatch, step.match_ok === true ? T.matchOk : step.match_ok === false ? T.matchBad : T.matchNone]]))
if (step.note) body.push(h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(step.note)))
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'quote' }, h('h3', {}, `${T.quote}「${String(step.text || '').slice(0, 24)}…」`),
h('div', { class: 'part-body' }, ...body))
}
const PARTS = {
metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart,
entity: entityPart, quote: quotePart,
}
// ------------------------------------------------------------------ 面板
function detailOf(seg, chain, note) {
const kind = T.kindLabel[seg.dataset.kind] || '片段'
const state = seg.dataset.state || ''
const head = h('section', { class: 'part' }, h('h3', {}, `${kind}「${seg.textContent}」`),
h('div', { class: 'part-body' }, kv([
[T.dStatus, T.stateLabel[state] || state || NONE],
[T.dNode, nodeLabel((view.data.meta.report || {}).node_id)],
])))
const parts = [head]
if (chain && chain.length) {
for (const step of chain) {
const render = PARTS[step.step]
if (render) parts.push(render(step))
}
} else {
parts.push(h('p', { class: 'muted' }, T.noChain))
}
if (note) parts.push(h('section', { class: 'part' }, h('h3', {}, T.note), h('div', { class: 'part-body' }, note)))
return parts
}
function openSeg(seg) {
const panel = $('detail')
if (panel.hidden) view.back = document.activeElement
if (view.active) view.active.classList.remove('is-active')
view.active = seg
seg.classList.add('is-active')
const chain = view.data ? (view.data.chains || {})[(view.data.refs || {})[seg.dataset.seg]] : undefined
const note = view.data ? (view.data.notes || {})[seg.dataset.seg] : undefined
$('detail-title').textContent = `${T.segTitle(T.kindLabel[seg.dataset.kind] || '片段', seg.textContent)}`
$('detail-body').replaceChildren(...detailOf(seg, Array.isArray(chain) ? chain : null, note))
panel.hidden = false
panel.setAttribute('data-open', seg.dataset.seg)
document.documentElement.classList.add('detail-open')
$('detail-close').focus({ preventScroll: true })
const sheet = panel.getBoundingClientRect()
const spot = seg.getBoundingClientRect()
if (sheet.top > 0) {
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
} else {
seg.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
function closeDetail() {
const panel = $('detail')
if (panel.hidden) return
panel.hidden = true
panel.removeAttribute('data-open')
document.documentElement.classList.remove('detail-open')
if (view.active) view.active.classList.remove('is-active')
if (view.back && typeof view.back.focus === 'function') view.back.focus()
view.active = null
}
// ------------------------------------------------------------------ 启动
function main() {
const data = parseData()
if (data) {
view.data = data
renderHead(data)
}
const body = $('report')
body.addEventListener('click', (e) => {
const seg = e.target.closest('button[data-seg]')
if (seg) openSeg(seg)
})
$('detail-close').addEventListener('click', closeDetail)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail() })
document.documentElement.setAttribute('data-js', '')
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main, { once: true })
else main()
})()
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src __REPORT_CSP__; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'">
<meta name="color-scheme" content="light dark">
<title>__REPORT_TITLE__</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --panel: #f5f6f8; --raise: #ffffff; --border: #dfe2e8; --fg: #16181d; --dim: #41454e; --faint: #5f6470;
--accent: #2a57c2; --ok: #1b7339; --ok-soft: #e5f4ea; --bad: #b92424; --bad-soft: #fcebeb; --na: #565b66; --na-soft: #eceef2;
--wait: #8a6100; --wait-soft: #fdf3d7; --mark: #fff1b8; --code: #eff1f5; --shadow: 0 10px 32px rgba(16, 20, 28, .16);
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--detail-w: 460px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111318; --panel: #181b21; --raise: #1c1f26; --border: #2e323b; --fg: #e8eaee; --dim: #c1c5ce; --faint: #969ca8;
--accent: #8aaeff; --ok: #62d08c; --ok-soft: #14301f; --bad: #ff8080; --bad-soft: #3c1c1f; --na: #a9afbb; --na-soft: #262a31;
--wait: #e3c06a; --wait-soft: #362c12; --mark: #4d4214; --code: #23262e; --shadow: 0 10px 32px rgba(0, 0, 0, .55);
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.65 var(--sans); overflow-wrap: break-word; }
code, pre, .mono { font-family: var(--mono); }
.page { max-width: 920px; margin: 0 auto; padding: 28px 16px 72px; }
.brand { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; color: var(--accent); }
h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.report-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 10px 0 0; font-size: 13px; color: var(--dim); }
.report-line .mono { font-size: 12px; color: var(--faint); }
.seal { margin: 14px 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel);
font-size: 13px; color: var(--dim); }
.seal code { word-break: break-all; }
.meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 14px 0 0; font-size: 13px; }
.meta dt { color: var(--faint); }
.meta dd { margin: 0; min-width: 0; color: var(--dim); overflow-wrap: anywhere; }
.meta-note { margin: 12px 0 0; font-size: 12px; color: var(--faint); }
.block { margin-top: 30px; }
.block > h2 { margin: 0 0 10px; font-size: 18px; }
.muted { color: var(--faint); }
.warn { margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--wait-soft); color: var(--wait); font-size: 13px; }
.doc { margin-top: 16px; padding: 18px 20px; border: 1px solid var(--border); border-radius: 10px; background: var(--raise);
font-size: 15px; }
.doc p { margin: 8px 0; }
.doc .doc-h { margin: 18px 0 6px; line-height: 1.4; }
.doc .doc-h1 { font-size: 19px; } .doc .doc-h2 { font-size: 17px; } .doc .doc-h3 { font-size: 15px; }
.doc ul, .doc ol { margin: 8px 0; padding-left: 1.6em; }
.doc li { margin: 2px 0; }
.doc blockquote { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--accent); color: var(--dim); }
.doc pre { margin: 8px 0; padding: 10px 12px; border-radius: 8px; background: var(--code); overflow-x: auto; font-size: 13px; line-height: 1.55; }
.doc code { font-size: .92em; padding: 0 4px; border-radius: 4px; background: var(--code); }
.doc pre code { padding: 0; background: none; }
.doc hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
.doc .link { color: var(--accent); }
.table-wrap { max-width: 100%; margin: 8px 0; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
.table-wrap table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table-wrap th, .table-wrap td { padding: 5px 9px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
.table-wrap th { background: var(--panel); color: var(--dim); font-weight: 600; white-space: nowrap; }
.table-wrap tr:last-child td { border-bottom: 0; }
.table-wrap .n { text-align: right; font-variant-numeric: tabular-nums; }
.num { font: inherit; color: inherit; background: none; border: 0; margin: 0; padding: 0 1px; border-radius: 3px; cursor: pointer;
text-decoration-line: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px; }
.num-ok { text-decoration-style: solid; text-decoration-color: var(--ok); }
.num-na { text-decoration-style: dotted; text-decoration-color: var(--na); }
.num-bad { text-decoration-style: wavy; text-decoration-color: var(--bad); }
.num:hover { background: var(--panel); }
.num:focus-visible, .close:focus-visible, .chipbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.num.is-active { background: var(--mark); }
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(var(--detail-w), 100vw); overflow-y: auto;
background: var(--raise); border-left: 1px solid var(--border); box-shadow: var(--shadow); }
.detail-head { position: sticky; top: 0; display: flex; align-items: flex-start; gap: 8px; padding: 14px 16px 10px;
background: var(--raise); border-bottom: 1px solid var(--border); }
.detail-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 17px; overflow-wrap: anywhere; }
.close { flex: none; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
color: var(--dim); font: 18px/1 var(--sans); cursor: pointer; }
.detail-body { padding: 12px 16px 28px; font-size: 14px; }
.part { margin: 0 0 16px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.part > h3 { margin: 0; padding: 8px 12px; background: var(--panel); font-size: 13px; }
.part-body { padding: 10px 12px; }
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 12px; margin: 0; }
.kv dt { color: var(--faint); font-size: 13px; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.sql { margin: 6px 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--code); font-size: 12px; line-height: 1.5;
white-space: pre-wrap; overflow-wrap: anywhere; }
.sub { margin: 14px 0 4px; font-size: 13px; color: var(--faint); font-weight: 600; }
.badge { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600;
line-height: 1.6; white-space: nowrap; }
.badge[data-kind="ok"] { color: var(--ok); background: var(--ok-soft); }
.badge[data-kind="bad"] { color: var(--bad); background: var(--bad-soft); }
.badge[data-kind="na"] { color: var(--na); background: var(--na-soft); }
.badge[data-kind="wait"] { color: var(--wait); background: var(--wait-soft); }
.chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.chipbtn { font: inherit; font-size: 12px; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer;
text-align: left; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.hit { background: var(--mark); }
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
@media print {
.detail, .close { display: none !important; }
.page, html.detail-open .page { max-width: none; margin: 0; padding: 0; }
.num { text-decoration-color: currentColor !important; background: none !important; }
}
/* 宽屏上出处面板停在右侧:打开时正文让出这一栏,表格里右对齐的数字不会被面板盖住 */
@media screen and (min-width: 761px) {
html.detail-open .page { margin-right: min(var(--detail-w), 100vw); }
}
@media (max-width: 760px) {
.detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; }
}
@media (max-width: 520px) {
body { font-size: 14px; }
.page { padding: 20px 16px 56px; }
h1 { font-size: 20px; }
.meta { grid-template-columns: minmax(0, 1fr); gap: 0; }
.meta dt { margin-top: 6px; }
.doc { padding: 12px; }
}
</style>
<script>__REPORT_SCRIPT__</script>
</head>
<body>
<div class="page">
<header>
<p class="brand">报告</p>
<h1 id="title">__REPORT_NAME__</h1>
<p class="report-line">
<span id="report-label">运行 <code class="mono">__REPORT_RUN__</code></span>
<span class="mono" id="doc-id"></span>
</p>
<p id="doc-warn" class="warn" hidden></p>
<dl id="meta" class="meta"></dl>
<section class="seal" id="seal-box" aria-labelledby="seal-label">
<h2 id="seal-label" class="meta-note" style="margin:0 0 4px">封存状态</h2>
<span id="seal-text">__REPORT_SEAL__</span>
</section>
<p class="meta-note" id="record-note">工作流名、版本、起止时间、节点名称和上面的封存状态取自导出那一刻的系统记录。这份文件用于阅读报告和查证数字的口径与出处,不用于证明文件本身未被修改——那要用「可验证报告」(封存哈希核对件),在系统中这次运行的「凭证详情」里导出。</p>
</header>
<main>
<section id="report" class="block" aria-labelledby="report-title"><h2 id="report-title">报告正文</h2>__REPORT_BODY__</section>
</main>
<footer id="foot" class="foot">
<p>数字的显示格式(千分位、单位、小数位)由系统渲染;点开的口径、来源和计算方式是导出那一刻系统里的记录。</p>
<p id="foot-schema"></p>
</footer>
</div>
<aside id="detail" class="detail" role="dialog" aria-modal="false" aria-labelledby="detail-title" hidden>
<div class="detail-head"><h2 id="detail-title"></h2><button type="button" id="detail-close" class="close" aria-label="关闭">×</button></div>
<div id="detail-body" class="detail-body"></div>
</aside>
<script type="application/json" id="report-data">__REPORT_DATA__</script>
</body>
</html>
+43 -16
View File
@@ -12,6 +12,8 @@
- base:一份报告,引用口径卡、门店流水(没有遮罩)、会员消费(有遮罩)各一处,一个没有出处的数,一张门店流水整表
(数字列右对齐,数字与取值共 21 个,超过折叠的条数);一条封存之后追加的按需裁判
- two-reports:另有一份月报,两份报告各自的引用链
- metric-only:只引用口径卡指标的报告——正文没有一处 [[v:]],门店流水的快照从口径卡的输入这一条路随文件附带,
引用链(④)按「已核对的口径卡的输入」认它;点开数字看得到指标、原式、代入式、输入和复算
- legacy:早期运行,封存范围按结束事件推算(运行结束事件在范围外,运行结果无法核对)
- failed:失败结束的运行(没有出具判定),封存说明多一句「继续运行会重新封存」
- continued:早期运行先失败、经继续运行跑到成功:范围里有那条运行失败,之后运行又继续了,成功结束的那条在范围外
@@ -66,8 +68,41 @@ REGIONS = {"columns": ["region", "spend"], "column_types": {"region": "text", "s
"rows": [["华东", 320.0], ["华南", 188.5]], "source": "crm",
"sql": "SELECT region, SUM(spend) AS spend FROM members GROUP BY region ORDER BY region", "truncated": False,
"mask_columns": ["phone"]}
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2",
"metrics": [{"id": "gmv", "name": "销售额", "value": 8381.25, "unit": "元", "decimals": 2, "format": "thousands"}]}
def _num(value: Any) -> str:
"""代入式里的一个数:照 Python 字面量的样子(整型不带 .0);DECIMAL 落成的数字文本原样。"""
if isinstance(value, str):
return value
return str(int(value)) if isinstance(value, float) and value.is_integer() else str(value)
def card_of(shop_id: str) -> dict[str, Any]:
"""周报口径卡:销售额是八家门店流水之和。和真实口径卡一样带着计算方式(原式、代入式)、每个输入
(取自哪次查询的哪一格)和复算结果——可验证报告点开数字时据此展示,引用链据此附上输入指向的查询快照。"""
cells = [f"cell(nodes.fetch, {i}, 'amount')" for i in range(len(STORES))]
gmv = {"id": "gmv", "name": "销售额", "value": round(sum(float(v) for _, v in STORES), 2), "unit": "元",
"decimals": 2, "format": "thousands", "expression": " + ".join(cells),
"substituted": " + ".join(_num(v) for _, v in STORES), "recompute_ok": True,
"inputs": [{"path": f"cell(nodes.fetch, {i}, 'amount')", "value": v, "node_id": "fetch",
"via": "tool_cell", "artifact": shop_id, "locator": {"row": i, "column": "amount"},
"status": "verified"}
for i, (_, v) in enumerate(STORES)]}
return {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "metrics": [gmv]}
def masked_card(card: dict[str, Any], crm_id: str) -> dict[str, Any]:
"""口径卡多两个指标:联系电话位数、首位会员手机号,输入都是会员消费第 0 行的号码(phone 设了遮罩),卡上记着号码原值
和代入式;首位会员手机号的值就是号码。"""
cell, phone = "cell(nodes.members, 0, 'phone')", CRM["rows"][0][1]
read = {"path": cell, "value": phone, "node_id": "members", "via": "tool_cell", "artifact": crm_id,
"locator": {"row": 0, "column": "phone"}, "status": "verified"}
contact = {"id": "contact", "name": "联系电话位数", "value": 8, "unit": "位", "decimals": 0, "format": "thousands",
"expression": f"len({cell})", "substituted": f"len('{phone}')", "inputs": [dict(read)]}
first = {"id": "phone_raw", "name": "首位会员手机号", "value": phone, "unit": "", "decimals": None, "format": "plain",
"expression": cell, "substituted": f"'{phone}'", "inputs": [dict(read)]}
return {**card, "metrics": [*card["metrics"], contact, first]}
WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*。青石店流水 [[v:Q1.r0.amount]],白沙店 [[v:Q1.r1.amount]],"
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
@@ -112,6 +147,8 @@ PROSE = ("## 正文\n\n全场运费统一[[m:orders]],消费合计——[[m:gm
#: 会员周报:Q3 是会员按地区汇总(没碰遮罩列),Q2 是选出了号码的会员消费
MEMBERS = ("## 会员周报\n\n华东会员消费 [[v:Q3.r0.spend]],华南 [[v:Q3.r1.spend]]。[[see:Q3]]\n\n"
"会员林一消费 [[v:Q2.r0.spend]]。")
#: 只引用指标的报告:正文没有一处 [[v:]],门店流水的快照只能从口径卡的输入这一条路随文件附带(引用链 ④)
METRIC_ONLY = "## 门店周报\n\n本周销售额 [[m:gmv]],各门店明细随后补上。"
def stored(obj: Any) -> tuple[str, bytes]:
@@ -133,19 +170,6 @@ def shop(nan: bool = False) -> dict[str, Any]:
MASKED = "已遮罩"
def masked_card(crm_id: str) -> dict[str, Any]:
"""口径卡多两个指标:联系电话位数、首位会员手机号,输入都是会员消费第 0 行的号码(phone 设了遮罩),卡上记着号码原值
和代入式;首位会员手机号的值就是号码。"""
cell, phone = "cell(nodes.members, 0, 'phone')", CRM["rows"][0][1]
read = {"path": cell, "value": phone, "node_id": "members", "via": "tool_cell", "artifact": crm_id,
"locator": {"row": 0, "column": "phone"}, "status": "verified"}
contact = {"id": "contact", "name": "联系电话位数", "value": 8, "unit": "位", "decimals": 0, "format": "thousands",
"expression": f"len({cell})", "substituted": f"len('{phone}')", "inputs": [dict(read)]}
first = {"id": "phone_raw", "name": "首位会员手机号", "value": phone, "unit": "", "decimals": None, "format": "plain",
"expression": cell, "substituted": f"'{phone}'", "inputs": [dict(read)]}
return {**CARD, "metrics": [*CARD["metrics"], contact, first]}
#: masked_card 上值不给的指标(和报告撰写节点的写作目录同一个判断:输入确定取自遮罩格)
MASKED_METRICS = ("m:contact", "m:phone_raw")
@@ -171,7 +195,7 @@ class Sample:
snapshot = table or shop(nan)
self.shop_id = self.put(snapshot)
self.crm_id = self.put(CRM)
self.card = card or (masked_card(self.crm_id) if masked else CARD)
self.card = card or (masked_card(card_of(self.shop_id), self.crm_id) if masked else card_of(self.shop_id))
self.card_id = self.put(self.card)
self.entries = {
"fetch": {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id,
@@ -318,6 +342,9 @@ def bundles() -> dict[str, dict[str, Any]]:
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
out["nan"] = build(n, events, bound)
# 只引用指标的报告:门店流水的快照没被正文引用,从口径卡的输入这一条路随文件附带、引用链认得它
out["metric-only"] = build(s, *s.events({"write": s.doc(METRIC_ONLY, "write")}))
m = Sample(masked=True)
events, bound = m.events({"write": m.doc(WEEKLY, "write", masked=MASKED_METRICS)})
out["masked-card"] = build(m, events, bound)
+16 -3
View File
@@ -463,9 +463,9 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
rendered = pages()
got = {name: json.loads(data_block(page)) for name, page in rendered.items()}
assert set(got) == {"base", "two-reports", "legacy", "failed", "continued", "degraded", "missing-doc",
"missing-card", "nan", "stray", "masked-card", "masked-old-doc", "masked-source", "formats",
"formats-unit", "prose"}
assert set(got) == {"base", "two-reports", "metric-only", "legacy", "failed", "continued", "degraded",
"missing-doc", "missing-card", "nan", "stray", "masked-card", "masked-old-doc", "masked-source",
"formats", "formats-unit", "prose"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
@@ -489,6 +489,19 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
assert next(a for a in got["missing-card"]["artifacts"] if a["kind"] == "metric_set").get("missing") is True
assert got["legacy"]["meta"]["legacy"] is True and "run.finished" in [a["type"] for a in got["legacy"]["appended"]]
assert [r["node_id"] for r in got["two-reports"]["reports"]] == ["write", "monthly"] and segments(got["two-reports"], "monthly")
# 只引用指标的报告:门店流水没被正文引用,从口径卡的输入这一条路随文件附带(点开数字看得到 SQL 和被引用的行);
# 会员消费谁都没引用,不嵌。口径卡带着原式、代入式、输入和复算
only = got["metric-only"]
shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")}
for a in only["artifacts"]}
assert shapes == {("report_doc", "write"): {"b64"}, ("metric_set", "card"): {"b64"}, ("query", "fetch"): {"b64"}}
card = json.loads(base64.b64decode(next(a["b64"] for a in only["artifacts"] if a["kind"] == "metric_set")))
[gmv] = card["metrics"]
from tests.bundle_samples import STORES
assert gmv["expression"].startswith("cell(nodes.fetch, 0, 'amount')") and "1280.5" in gmv["substituted"]
assert gmv["recompute_ok"] is True and len(gmv["inputs"]) == len(STORES)
assert [s["text"] for s in segments(only)] and not [s for s in segments(only) if s.get("cite", {}).get("kind") == "cell"]
failed = got["failed"]
assert failed["meta"]["status"] == "failed" and failed["meta"]["tier"] is None
assert max(failed["events"], key=lambda e: e["seq"])["type"] == "run.failed"
+264
View File
@@ -0,0 +1,264 @@
"""报告导出的接口:GET /api/runs/{run_id}/report.html。
照可验证报告的夹具真跑一次:两个虚构的 SQLite 数据源(门店流水没设遮罩,会员库遮了 phone 列),口径卡、
报告引用两份快照里的格子和一个指标。核对:
- 导出的页面:正文是服务端渲染好的 HTML(数字是带 data-seg 的按钮、表格列表引用代码块都排出来),
不执行脚本也读得到;数据块能解析回来;
- 出处链:数字的链里指标步骤带着口径(口径卡名、版本)、计算方式(原式、代入式)、输入和复算,
查询步骤带着 SQL 和被引用的行;同一处引用只算一条(同样的 [[m:gmv]] 共用);
- 遮罩:会员库上选出了号码的那次查询,行里 phone 列写「已遮罩」;
- 附件名是「<运行名>-报告.html」,中文名按 RFC 5987 编码;
- ?report= 指定报告节点;没有报告文档的运行回 404;
- 报告文档被改过(哈希对不上)回 409;
- 只读:导出前后运行记录、事件一条不多一条不少。
"""
from __future__ import annotations
import json
import re
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import func, select
from app.core.artifact_store import canonical_json
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine.runner import run_manager
from app.main import app
from tests.test_bundle_api import ( # noqa: F401 - 夹具和搭法照可验证报告的
CRM_SQL,
SHOP_SQL,
TEXT,
client,
engine_up,
file_of,
sealed_run,
set_source,
sources,
wait,
weekly,
)
TWO = ("## 门店周报\n\n青石店流水 [[v:Q1.r0.amount]],会员消费 [[v:Q2.r1.spend]],口径卡销售额 [[m:gmv]]。"
"另有 42 笔退款待入账。")
RICH = (TWO + "\n\n- 青石店流水最高\n- 白沙店流水最低\n\n[[table:Q1 cols=store,amount rows=0-1]]")
def data_of(html: str) -> dict:
m = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S)
assert m, "页面里没有数据块"
return json.loads(m.group(1))
def seg_button(html: str, seg_id: str) -> str:
"""正文里这个片段的按钮(服务端渲染的)。"""
m = re.search(rf'<button type="button"[^>]*data-seg="{seg_id}"[^>]*>', html)
return m.group(0) if m else ""
def all_segments(doc: dict) -> list[dict]:
return [seg for block in doc.get("blocks") or [] for unit in block.get("units") or []
for seg in unit.get("segments") or []]
def doc_of_export(page: str) -> dict:
return json.loads(file_of(data_of(page)["meta"]["doc_artifact"]).read_text(encoding="utf-8"))
# --------------------------------------------------------------------------
# 导出
# --------------------------------------------------------------------------
async def test_export_renders_body_and_embeds_chains(client, monkeypatch):
row = await sealed_run(monkeypatch, RICH)
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/html")
header = resp.headers["content-disposition"]
assert header.startswith("attachment;")
assert f"filename*=UTF-8''{quote('未保存的工作流-报告.html', safe='')}" in header
assert resp.headers["cache-control"] == "no-store"
page = resp.text
# 正文是服务端渲染的:不执行脚本也读得到整份报告
for text in ("门店周报", "青石店", "1280.5", "1,280.5", "42"):
assert text in page, f"正文里没有「{text}」"
assert "<table>" in page and "<ul><li" in page.replace("\n", "")
assert 'class="num num-ok"' in page and 'class="num num-na"' in page
data = data_of(page)
assert data["schema"] == "agentlab.report/1"
assert data["meta"]["run_id"] == row.id
assert data["meta"]["report"]["node_id"] == "write"
assert data["meta"]["doc_hash_ok"] is True
assert data["meta"]["seal"]["sealed"] is True and data["meta"]["seal"]["ok"] is True
# 每个有状态的数字、取值都指得到一条链,正文里也画得出按钮
doc = doc_of_export(page)
numbers = [s for s in all_segments(doc) if s.get("kind") in ("number", "value") and s.get("id")]
assert len(numbers) >= 3
for seg in numbers:
assert data["refs"][seg["id"]], f"片段 {seg['id']} 没有出处链"
assert seg_button(page, seg["id"]), f"正文里没有片段 {seg['id']} 的按钮"
# 指标的链:口径、计算方式(原式、代入式)、输入、复算、查询
metric_seg = next(s for s in numbers if (s.get("cite") or {}).get("kind") == "metric")
chain = data["chains"][data["refs"][metric_seg["id"]]]
metric = next(s for s in chain if s["step"] == "metric")
assert metric["caliber"] == "周报口径" and metric["version"] == "v2"
assert metric["expression"] == "cell(nodes.fetch, 0, 'amount')"
assert metric["substituted"] == "1280.5"
assert metric["recompute_ok"] is True
assert metric["sealed"] is True
inputs = [s for s in chain if s["step"] == "input"]
assert len(inputs) == 1 and inputs[0]["path"] == "cell(nodes.fetch, 0, 'amount')"
queries = [s for s in chain if s["step"] == "query"]
assert len(queries) == 1 and queries[0]["sql"] == SHOP_SQL
assert queries[0]["rows"] and "青石店" in [r[1] for r in queries[0]["rows"]]
assert queries[0]["highlight"]["cells"] == [[0, "amount"]]
# 单元格的链:直接到查询快照
cell_seg = next(s for s in numbers if (s.get("cite") or {}).get("kind") == "cell")
chain = data["chains"][data["refs"][cell_seg["id"]]]
assert [s["step"] for s in chain] == ["query"]
assert chain[0]["artifact"] == queries[0]["artifact"]
# 无证据的数字(正文里直接写出的 42):点得开,链是空的,说明写清楚了
bare = next(s for s in all_segments(doc) if s.get("issue") == "uncited_number")
assert data["refs"][bare["id"]]
assert data["chains"][data["refs"][bare["id"]]] == []
assert "没有出处" in data["notes"][bare["id"]]
assert 'data-state="none"' in seg_button(page, bare["id"])
async def test_export_shares_one_chain_per_citation(client, monkeypatch):
"""同一处引用(同样的 [[m:gmv]] 写两遍)共用一条链:数据块里不把整条链抄两遍。"""
from app.providers import mock_model
twice = TEXT + " 再说一遍销售额 [[m:gmv]]。"
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=twice))
row = await wait((await run_manager.start(graph=weekly(), input_payload={})).id)
assert row.status == "succeeded", row.error
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 200, resp.text
page = resp.text
doc = doc_of_export(page)
metrics = [s for s in all_segments(doc) if (s.get("cite") or {}).get("kind") == "metric"]
assert len(metrics) == 2
data = data_of(page)
assert data["refs"][metrics[0]["id"]] == data["refs"][metrics[1]["id"]]
assert len([c for c in data["chains"].values() if c and c[0].get("step") == "metric"]) == 1
async def test_export_masks_masked_columns(client, monkeypatch):
"""会员库遮了 phone 列:查询步骤的行里那一列写「已遮罩」。"""
row = await sealed_run(monkeypatch, TWO)
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 200, resp.text
page = resp.text
doc = doc_of_export(page)
spend = next(s for s in all_segments(doc) if s.get("ref") == "v:Q2.r1.spend")
data = data_of(page)
chain = data["chains"][data["refs"][spend["id"]]]
assert [s["step"] for s in chain] == ["query"]
query = chain[0]
assert query["masked"] == ["phone"]
phone_at = query["columns"].index("phone")
assert all("已遮罩" in str(r[phone_at]) for r in query["rows"])
assert query["sql"] == CRM_SQL
async def test_export_picks_the_named_report(client, monkeypatch):
"""一次运行两份报告:不指定取成果标注的那份,?report= 取指定的;点不存在的节点回 404。"""
from app.providers import mock_model
from tests.test_bundle_api import node
nodes = [n for n in weekly()["nodes"] if n["id"] != "out"]
nodes.append(node("monthly", "report", instructions="写月报", on_violation="flag"))
nodes.append(node("out", "output", fields=[{"name": "月报", "value": "{{ nodes.monthly.text }}"}]))
ids = [n["id"] for n in nodes]
graph = {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
calls = {"n": 0}
def decide(self, messages):
calls["n"] += 1
return AIMessage(content=TEXT if calls["n"] == 1 else "## 月报\n\n本月销售额 [[m:gmv]]。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
row = await wait((await run_manager.start(graph=graph, input_payload={})).id)
assert row.status == "succeeded", row.error
named = await client.get(f"/api/runs/{row.id}/report.html", params={"report": "monthly"})
assert named.status_code == 200, named.text
assert data_of(named.text)["meta"]["report"]["node_id"] == "monthly"
assert "月报" in named.text
assert "门店周报" not in named.text
default = await client.get(f"/api/runs/{row.id}/report.html")
assert default.status_code == 200
assert data_of(default.text)["meta"]["report"]["node_id"] == "monthly" # 成果标注的是月报
missing = await client.get(f"/api/runs/{row.id}/report.html", params={"report": "nope"})
assert missing.status_code == 404
assert missing.json()["code"] == "evidence_report_not_found"
async def test_export_without_report_documents(client, monkeypatch):
"""图里没有报告节点:没有 report.checked 事件,回 404 说没有可导出的报告。"""
from app.providers import mock_model
nodes = [n for n in weekly()["nodes"] if n["type"] != "report"]
nodes = [({**n, "data": {**n["data"], "config": {**n["data"].get("config", {}),
"fields": [{"name": "结果", "value": "没有报告"}]}}}
if n["type"] == "output" else n) for n in nodes]
ids = [n["id"] for n in nodes]
graph = {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content="没有报告"))
row = await wait((await run_manager.start(graph=graph, input_payload={})).id)
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 404, resp.text
assert resp.json()["code"] == "evidence_report_not_found"
async def test_export_refuses_tampered_document(client, monkeypatch):
"""报告文档在封存之后被改过(内容与哈希对不上):不能导出,回 409。"""
row = await sealed_run(monkeypatch)
async with SessionLocal() as session:
[event] = (await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "report.checked"))).scalars()
artifact = event.data["doc_artifact"]
path = file_of(artifact)
keep = path.read_bytes()
try:
path.write_bytes(canonical_json({"blocks": [], "run_id": row.id, "node_id": "write"}).encode())
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 409, resp.text
assert resp.json()["code"] == "evidence_doc_tampered"
finally:
path.write_bytes(keep)
async def test_export_is_read_only(client, monkeypatch):
"""导出前后运行记录、事件一条不多一条不少。"""
row = await sealed_run(monkeypatch)
async def counts() -> tuple:
async with SessionLocal() as session:
events = await session.execute(
select(func.count()).select_from(RunEvent).where(RunEvent.run_id == row.id))
runs = await session.get(Run, row.id)
return events.scalar_one(), runs.status, runs.manifest_seq
before = await counts()
for _ in range(2):
resp = await client.get(f"/api/runs/{row.id}/report.html")
assert resp.status_code == 200
assert await counts() == before
+2
View File
@@ -20,6 +20,7 @@ import { compare } from './compare'
import { inquiry } from './inquiry'
import { schedules } from './schedules'
import { whatif } from './whatif'
import { reportExport } from './reportExport'
// 请求层搬到了 http.ts(见那边的说明)。原先从这里导出的照旧从这里导出,调用方不用改;
// 请求函数也一并转出,给不在 api/ 下、又想直接发请求的地方用。api/ 下的命名空间文件请直接从 http.ts 导入
@@ -754,6 +755,7 @@ export const api = {
bundle,
inquiry,
whatif,
reportExport,
}
/**
+32
View File
@@ -0,0 +1,32 @@
/**
* 报告导出的接口(/api/runs/{run_id}/report.html),挂在 api.reportExport 上。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。Content-Disposition 的文件名
* 解析和可验证报告(./bundle)是同一个拼法,直接复用那边导出的 dispositionName。
*/
import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, failure, report } from './http'
import { dispositionName } from './bundle'
export const reportExport = {
/**
* 下载报告导出(GET /runs/{id}/report.html,附件):回来的是文件本身和服务端给的文件名,没给就是 null,
* 由调用方按问题或答案补。reportNode 点名报告撰写节点;点名的节点这次运行没有、或者这次运行没有报告文档时
* 服务端回 404,报告文档与哈希不一致回 409,ApiError 的 message 是给人看的原因
*/
download: async (runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => {
const path = `/runs/${encodeURIComponent(runId)}/report.html`
+ (reportNode ? `?report=${encodeURIComponent(reportNode)}` : '')
let res: Response
try {
res = await fetch(BASE + path, { headers: actorHeader() })
} catch (e: any) {
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
}
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
report(true)
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
},
}
+52 -7
View File
@@ -10,7 +10,7 @@ import {
} from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { ErrorNotice, Modal, Skeleton, StatusBadge } from '../components/ui'
import { ErrorNotice, Modal, Skeleton, Spinner, StatusBadge, toast } from '../components/ui'
import { humanizeError } from '../lib/errors'
import { explainRunError } from '../lib/explain'
import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format'
@@ -2036,6 +2036,8 @@ function Output({ output, runClass, broken, question, onFollowUp, runId }: {
// 成果(旧运行、没有报告节点的图)一个字段都不会进这里,渲染和以前完全一样
const evidence = useMemo(() => evidenceFields(output), [output])
const firstEvidence = entries.find(([k, v]) => evidence.has(k) && typeof v === 'string')?.[0]
// 第一份带证据的字段出自哪个报告节点:导出 HTML 时点名(服务端按它挑报告文档)
const reportNode = firstEvidence ? evidence.get(firstEvidence)?.report : undefined
const docRef = useRef<EvidenceDocHandle>(null)
// 第一份报告文档取到之后,出具横幅多一行「N/N 数字有出处 · 无证据 M」
const [tallyDoc, setTallyDoc] = useState<EvidenceDocData | null>(null)
@@ -2070,7 +2072,8 @@ function Output({ output, runClass, broken, question, onFollowUp, runId }: {
<div className={clsx('relative', broken && 'border-l-2 pl-2.5')}
style={broken ? { borderColor: 'var(--st-failed)' } : undefined}>
{entries.length > 0 && (
<AnswerActions entries={entries} question={question} issuance={issuance} />
<AnswerActions entries={entries} question={question} issuance={issuance}
runId={firstEvidence ? runId : undefined} reportNode={reportNode} />
)}
<div className="space-y-2">
{entries.map(([key, value]) => (
@@ -2134,25 +2137,67 @@ function answerMarkdown(entries: [string, any][], question?: string, issuance?:
return parts.join('\n\n')
}
/** 答案上的操作:复制、导出 Markdown。悬停时浮出,键盘聚焦时也出来 */
function AnswerActions({ entries, question, issuance }: {
/** 答案上的操作:复制、导出。悬停时浮出,键盘聚焦时也出来。
*
* 成果带逐段证据(报告文档)时,导出从服务端拿自包含的 HTML:正文里的数字点得开口径、来源和计算方式,
* 和网页上一样;Markdown 照旧保留一个小的(贴进周报的还是纯文本)。没有证据的成果(旧运行、没有报告节点
* 的图)照旧只导 Markdown。 */
function AnswerActions({ entries, question, issuance, runId, reportNode }: {
entries: [string, any][]; question?: string; issuance?: any
/** 这份成果出自哪次运行:带证据的报告从服务端导出成 HTML */
runId?: string
/** 报告撰写节点的 id(成果 _evidence 标注的那份);没有时服务端自己挑成果标注的那一份 */
reportNode?: string
}) {
const md = () => answerMarkdown(entries, question, issuance)
const [exporting, setExporting] = useState(false)
const exportHtml = runId ? async () => {
setExporting(true)
try {
const { blob, name } = await api.reportExport.download(runId, reportNode)
const file = name ?? `${fileSafe(question ?? '报告')}-报告.html`
saveBlob(file, blob)
toast.ok(`已导出「${file}」`)
} catch (e) {
toast.error(e, { key: `report-export:${runId}` })
} finally {
setExporting(false)
}
} : null
return (
<div className="absolute right-0 top-0 z-[1] flex items-center gap-0.5 rounded opacity-0 shadow-elev-1 transition-opacity focus-within:opacity-100 group-hover/answer:opacity-100"
style={{ background: 'var(--bg-panel)' }}>
<CopyChip label="复制" text={md} />
{exportHtml ? (
<button type="button"
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-dim transition-colors hover:bg-hover hover:text-fg"
title="导出为 HTML 文件:数字点得开口径、来源和计算方式(和网页上一样)"
disabled={exporting}
onClick={() => void exportHtml()} data-answer-export="html">
{exporting ? <Spinner size={10} aria-hidden /> : <FileDown size={11} aria-hidden />} 导出
</button>
) : null}
<button type="button"
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-dim transition-colors hover:bg-hover hover:text-fg"
title="导出为 Markdown 文件"
onClick={() => download(`${fileSafe(question ?? '答案')}.md`, md(), 'text/markdown;charset=utf-8')}>
<FileDown size={11} aria-hidden /> 导出
title={exportHtml ? '导出为 Markdown 文件(纯文本,不带出处)' : '导出为 Markdown 文件'}
onClick={() => download(`${fileSafe(question ?? '答案')}.md`, md(), 'text/markdown;charset=utf-8')}
data-answer-export={exportHtml ? 'md' : undefined}>
{exportHtml ? 'MD' : (<><FileDown size={11} aria-hidden /> 导出</>)}
</button>
</div>
)
}
/** 存成文件(Blob):对象 URL 交给一个临时的链接点一下,稍后回收(同记录页可验证报告的下载写法) */
function saveBlob(name: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/**
* 追问:从答案里的表格派生,不调模型。
*
+1 -1
View File
@@ -98,7 +98,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33,
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+45 -1
View File
@@ -595,6 +595,15 @@ async function sample(name, fn, opts = {}) {
}
}
/** 点开正文里一个数字,等出处面板出来,交回面板里的全部文字(看完按 Esc 关掉) */
async function detailText(page, locator) {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
await page.keyboard.press('Escape')
return text
}
await section('样本页:各种形状', async () => {
try {
samples = samplePages()
@@ -602,7 +611,7 @@ await section('样本页:各种形状', async () => {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 16, Object.keys(samples).join('、'))
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 17, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s),
@@ -653,6 +662,18 @@ await section('样本页:各种形状', async () => {
check('base:没有出处的数字不注明', !/按显示规则/.test(await detailOf(bare)))
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
// 点开引用口径卡指标的数字:口径、计算方式(原式、代入式)、输入、复算,和输入指向的查询快照
const metric = page.locator('.doc p button.num[data-kind="number"][data-result="pass"]').first()
const mdetail = await detailOf(metric)
check('base:指标数字的出处面板写明指标、口径与版本、原式、代入式、输入、复算',
mdetail.includes('销售额(gmv)') && mdetail.includes('周报口径 v2')
&& mdetail.includes('原式(计算方式)') && mdetail.includes('代入式(代入输入后的算式)')
&& mdetail.includes('输入') && mdetail.includes('复算一致'), mdetail.slice(0, 400))
check('base:每个输入带着取值和出处(哪次查询的哪一行哪一列)',
mdetail.includes("← cell(nodes.fetch, 0, 'amount')") && mdetail.includes('取自 查询 Q1 · 第 1 行 · amount'),
mdetail.slice(0, 400))
check('base:输入指向的查询快照随文件附带,看得到 SQL 和被引用的那一行',
mdetail.includes('SELECT store, amount, ratio FROM orders') && mdetail.includes('快照第 1 行'), mdetail.slice(0, 400))
})
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
@@ -682,6 +703,22 @@ await section('样本页:各种形状', async () => {
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
})
// 只引用指标的报告:正文没引用门店流水,快照从口径卡的输入这一条路随文件附带,④ 认得这条引用
await sample('metric-only', async (s, page) => {
check('metric-only:总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
check('metric-only:引用链写明从已核对的口径卡的输入认这份快照',
s.checks.chain === 'pass' && hasItem(s, 'chain', 'pass', '口径卡「周报口径」的输入(指标 gmv)'), describe(s))
check('metric-only:只嵌报告文档、口径卡和口径卡的输入指向的快照(会员消费谁都没引用,不嵌)',
s.items.filter((i) => i.check === 'artifacts').length === 3
&& !s.items.some((i) => i.check === 'artifacts' && i.text.includes('members')),
String(s.items.filter((i) => i.check === 'artifacts').length))
const detail = await detailText(page, page.locator('button.num[data-kind="number"]').first())
check('metric-only:点开数字看得到指标、口径与版本、原式、代入式、输入和复算',
detail.includes('销售额(gmv)') && detail.includes('周报口径 v2') && detail.includes('原式(计算方式)')
&& detail.includes('代入式(代入输入后的算式)') && detail.includes('复算一致'), detail.slice(0, 400))
check('metric-only:输入指向的查询快照看得到 SQL 和被引用的那一行',
detail.includes('SELECT store, amount, ratio FROM orders') && detail.includes('快照第 1 行'), detail.slice(0, 400))
})
await sample('legacy', async (s, page) => {
check('legacy:总览中性(运行结果无法核对),范围写明按结束事件推算', neutral(s)
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
@@ -902,6 +939,13 @@ await section('样本页:篡改', async () => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
// 只引用指标的报告:拿掉口径卡,数字没了核对的对象(⑤ 变红);它的输入指向的快照还被已核对的
// tool.end 事件引用着,④ 照样通过——事件本身就是一条合法的引用
await tamper(samples['metric-only'], '拿掉被引用的口径卡(只引用指标的报告)', (data) => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['数字无法核对,写明引用的工件不在清单里', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
// 证据目录里写着取自遮罩列的指标的值的老文档,服务端只给哈希、不附带它引用的工件(见样本页 masked-old-doc):伪造者把
// 一份好好的文档也标成遮罩,页面照服务端的说法写原因、正文不显示,可它引用的那几份工件还列着,已核对的事件和文档里却
// 没人引用它们,判不通过——藏不起一份报告还显示通过
+381
View File
@@ -0,0 +1,381 @@
// 报告导出的检查:真服务端导出、断网打开点数字、不执行脚本也读得到正文;外加记录页对话框里那份导出按钮。
//
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-report-export.mjs
//
// 分几段:
// - 导出:从服务端找一份成功结束、有报告文档的运行(GET /api/runs 找成功结束的,再看证据接口),下载 report.html:
// 状态码、类型、附件名(RFC 5987)、页面本身零外链、封存哈希不执行脚本也看得到、导出只读不追加事件。
// 找不到带报告文档的运行时照实报失败并说明原因;
// - 断网打开:拦掉一切网络请求,从本地文件打开:正文(服务端渲染的 HTML)读得到,头部写明报告节点、运行、封存状态;
// 点开带出处的数字看得到出处链——口径卡指标(口径、原式、代入式、输入、复算)、查询(SQL、被引用的行)、
// 数据源遮罩列写「已遮罩」;Esc 关掉面板;没有发出任何请求;
// - 不执行脚本:正文照样读得到(附件预览常常不执行脚本),封存哈希和运行 ID 静态写着;
// - 深浅色、窄屏:390 宽不横向溢出,点开的面板从底部抽出,深色下底是深的、字是浅的。
//
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
// 导出接口只读(不追加事件、不改运行)。找不到能点开指标数字的运行时,只查单元格引用的那条链,原因写明。
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const note = (text) => console.log(` · ${text}`)
/** 等一处元素出现:locator.isVisible() 不等待,提示、按钮恢复要等一会儿才到 */
const appears = (locator, timeout = 5000) => locator.first().waitFor({ state: 'visible', timeout }).then(() => true, () => false)
const TMP = mkdtempSync(join(tmpdir(), 'agentlab-report-'))
// 导出的页面带着真数据:中途崩了也要删掉
process.on('exit', () => rmSync(TMP, { recursive: true, force: true }))
const browser = await chromium.launch({ executablePath: CHROME })
const cleanup = async () => { await browser.close().catch(() => {}); rmSync(TMP, { recursive: true, force: true }) }
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
const getJson = async (path) => {
const res = await fetch(`${API}${path}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
const DATA_RE = /(<script type="application\/json" id="report-data">)([\s\S]*?)(<\/script>)/
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
function writePage(name, html) {
const file = join(TMP, name)
writeFileSync(file, html)
return file
}
/** 断网打开一份本地文件:除了 file:// 本身,任何请求都拦掉并记下来 */
async function openFile(file, { colorScheme = 'light', width = 1280, javaScript = true } = {}) {
const ctx = await browser.newContext({ viewport: { width, height: 900 }, colorScheme, javaScriptEnabled: javaScript })
const requests = []
await ctx.route('**/*', (route) => {
const url = route.request().url()
if (url.startsWith('file:')) return route.continue()
requests.push(url)
return route.abort('blockedbyclient')
})
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`))
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
await page.goto(pathToFileURL(file).href)
return { ctx, page, requests, errors }
}
// ---------------------------------------------------------------- 找一份可导出的运行
async function findRun() {
let seen = 0
let withDoc = 0
let fallback = null
let before = ''
for (let p = 0; p < 5; p++) {
let runs
try {
runs = await getJson(`/runs?status=succeeded&limit=200${before ? `&before=${encodeURIComponent(before)}` : ''}`)
} catch (e) {
return { why: `无法读取运行列表:${e.message}` }
}
for (const run of runs) {
seen++
const graph = await getJson(`/runs/${run.id}/evidence`).catch(() => null)
const reports = (graph?.mode === 'cited' ? graph.reports ?? [] : []).filter((r) => r.doc_artifact && r.hash_ok === true)
if (!reports.length) continue
withDoc++
const cited = reports.reduce((n, r) => n + (r.stats?.numbers_cited ?? 0), 0)
if (cited > 0) return { run, cited, reports: reports.length }
fallback ??= { run, cited, reports: reports.length }
}
if (runs.length < 200) break
before = runs[runs.length - 1].created_at
}
if (fallback) return fallback
if (!seen) return { why: '服务端里没有成功结束的运行' }
return { why: `${seen} 次成功结束的运行里 ${withDoc} 次带报告文档,没有一次有带出处的数字` }
}
let picked = null
let html = ''
// ---------------------------------------------------------------- 导出
await section('导出', async () => {
picked = await findRun()
if (!picked.run) {
check('找到一份成功结束、带报告文档的运行', false, picked.why)
return
}
const { run } = picked
check('找到一份成功结束、带报告文档的运行', true,
`运行 ${run.id.slice(0, 8)},${picked.reports} 份报告、${picked.cited} 个带出处的数字`)
const before = await getJson(`/runs/${run.id}/events`).catch(() => null)
const resp = await fetch(`${API}/runs/${run.id}/report.html`)
const body = await resp.text()
const ok = resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? '')
check('导出接口返回 200 和 text/html', ok, `HTTP ${resp.status},${Math.round(Buffer.byteLength(body) / 1024)} KB`)
html = ok && DATA_RE.test(body) ? body : ''
const disposition = resp.headers.get('content-disposition') ?? ''
const star = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1]
let name = ''
try { name = star ? decodeURIComponent(star) : '' } catch { name = '' }
check('附件名按 RFC 5987 编码,以「-报告.html」结尾', disposition.startsWith('attachment') && name.endsWith('-报告.html'))
check('另带一个纯 ASCII 的文件名给老客户端', /filename="[\x20-\x7e]+"/.test(disposition))
check('导出不缓存(no-store)', resp.headers.get('cache-control') === 'no-store')
// 页面本身(去掉数据块)不许有任何 http(s):// 引用;数据块里的字是数据,浏览器不会去取
const shell = html.replace(DATA_RE, '$1$3')
check('页面零外链:没有 http(s)://、外部脚本、样式表、图片和字体', !/https?:\/\//i.test(shell)
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
const missing = await fetch(`${API}/runs/run-does-not-exist/report.html`)
check('不存在的运行回 404', missing.status === 404)
if (picked.cited === 0) note('这份运行的报告没有带出处的数字(模型没写引用标记),出处面板按无证据数字查')
})
const skipped = () => {
if (html) return false
note('没有拿到导出的页面(原因见上一节),这一节跳过')
return true
}
/** 点开正文里一个数字,等出处面板出来,交回面板里的全部文字(看完按 Esc 关掉) */
async function detailText(page, locator) {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
await page.keyboard.press('Escape')
return text
}
// ---------------------------------------------------------------- 断网打开
await section('断网打开', async () => {
if (skipped()) return
const data = dataOf(html)
const { ctx, page, requests, errors } = await openFile(writePage('report.html', html))
try {
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
// 头部:报告节点、运行、封存状态
const head = await page.locator('header').innerText()
check('头部写明报告节点和运行 ID', /报告节点「/.test(head) && head.includes(data.meta.run_id.slice(0, 8)), head.slice(0, 120))
check('封存状态写明(已封存给清单哈希)', /封存/.test(head) && (data.meta.seal?.sealed ? /清单哈希/.test(head) : true))
// 正文:服务端渲染的 HTML
const docText = await page.locator('#report').innerText()
check('正文整份读得到(服务端渲染)', docText.trim().length > 0, `${docText.trim().length} 字`)
const buttons = page.locator('#report button[data-seg]')
const count = await buttons.count()
check('有状态的数字、取值、名称都画成了按钮', count > 0, `${count} 个`)
const refs = await page.evaluate(() => Object.keys(JSON.parse(document.getElementById('report-data').textContent).refs ?? {}))
check('每个按钮都点得开一条出处链', count === refs.length || refs.length >= count, `按钮 ${count} 个、链 ${refs.length} 条`)
// 点开一个带出处的数字
const cited = page.locator('#report button[data-seg][data-state="deterministic"]').first()
if (await cited.count()) {
const key = await cited.getAttribute('data-seg')
const text = await detailText(page, cited)
const chains = Object.fromEntries(Object.entries(data.chains ?? {}))
const chain = chains[data.refs[key] ?? ''] ?? []
const kinds = chain.map((s) => s.step)
// 每种步骤在面板里的标题(runtime.js 的 PARTS):链里有的,面板都得画出一节
const TITLES = { metric: '口径卡指标', input: '输入', query: '查询', run_input: '运行输入',
entity: '表或字段', quote: '引文' }
check('点开数字面板写明类型和状态', /「/.test(text) && /(有出处|无证据|未裁判|猜测)/.test(text))
check('面板列出出处链的每个步骤', kinds.length > 0
? kinds.every((k) => text.includes(TITLES[k] ?? k)) : /没有可展示的出处链/.test(text),
`${kinds.join('、') || '(空链)'}|${text.slice(0, 160).replace(/\n/g, ' ')}`)
if (kinds.includes('metric')) {
check('指标步骤带口径、计算方式(原式、代入式)、输入和复算',
/口径卡「/.test(text) && /原式(计算方式)/.test(text) && /代入式/.test(text)
&& (/输入/.test(text) || /缺少输入/.test(text)) && /复算/.test(text), text.slice(0, 300))
}
if (kinds.includes('query')) {
check('查询步骤带 SQL 和被引用的行', /SQL/.test(text) && /被引用的行/.test(text), text.slice(0, 300))
const masked = chain.filter((s) => s.step === 'query' && Array.isArray(s.masked) && s.masked.length)
if (masked.length) check('遮罩列的值写「已遮罩」', /已遮罩/.test(text))
}
// 面板开着再点另一个:换到那一个,Esc 关掉
const another = page.locator('#report button[data-seg][data-state="deterministic"]').nth(1)
if (await another.count()) {
await another.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
check('面板开着时点另一个数字,换到那一个', await page.locator('#detail').getAttribute('data-open') === await another.getAttribute('data-seg'))
}
await page.keyboard.press('Escape')
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
} else {
check('报告里有可点的数字', picked?.cited === 0, '证据图说有带出处的数字,页面上却一个都没有')
}
} finally {
await ctx.close()
}
})
// ---------------------------------------------------------------- 不执行脚本
await section('不执行脚本', async () => {
if (skipped()) return
const data = dataOf(html)
const { ctx, page } = await openFile(join(TMP, 'report.html'), { javaScript: false })
try {
const docText = await page.locator('#report').innerText()
check('正文照样整份读得到(附件预览常常不执行脚本)', docText.trim().length > 0, `${docText.trim().length} 字`)
const head = await page.locator('header').innerText()
check('运行 ID 和封存哈希静态写着', head.includes(data.meta.run_id.slice(0, 8))
&& (!data.meta.seal?.manifest_hash || head.includes(data.meta.seal.manifest_hash.slice(0, 12))), head.slice(0, 160))
} finally {
await ctx.close()
}
})
// ---------------------------------------------------------------- 深浅色与窄屏
await section('深浅色与窄屏', async () => {
if (skipped()) return
const dark = await openFile(join(TMP, 'report.html'), { colorScheme: 'dark' })
try {
const deep = await dark.page.evaluate(() => getComputedStyle(document.body).backgroundColor)
const [r, g, b] = (/\((\d+), (\d+), (\d+)/.exec(deep) ?? []).slice(1).map(Number)
check('深色下底是深的、字是浅的', r + g + b < 200, deep)
} finally {
await dark.ctx.close()
}
const narrow = await openFile(join(TMP, 'report.html'), { width: 390 })
try {
check('390 宽不横向溢出', await narrow.page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
const first = narrow.page.locator('#report button[data-seg]').first()
if (await first.count()) {
await first.click()
await narrow.page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const fromBottom = await narrow.page.evaluate(() => {
const r = document.getElementById('detail').getBoundingClientRect()
return r.bottom >= window.innerHeight - 2 || r.top > window.innerHeight * 0.2
})
check('窄屏上面板从底部抽出', fromBottom)
await narrow.page.keyboard.press('Escape')
} else {
note('正文里没有可点的片段,窄屏面板没查')
}
} finally {
await narrow.ctx.close()
}
})
// ---------------------------------------------------------------- 问数据成果上的导出按钮
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const SAMPLE_NAME = '门店周报-报告.html'
/** 预览页 ?syn=evidence(带逐段证据的一轮):报告文档和导出接口由 page.route 伪造,非 GET 一律掐掉 */
async function preview(syn, { reportHtml } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true })
const page = await ctx.newPage()
const hits = []
// 后注册的路由先匹配:兜底的先注册,假装报告文档和导出接口的后注册,请求才落到假接口上
await page.route('**/api/**', (route) => (route.request().method() === 'GET' ? route.continue() : route.abort()))
const fx = JSON.parse(readFileSync(join(ROOT, 'frontend/src/run/__tests__/evidence-doc.json'), 'utf8'))
// 报告文档和导出接口的后注册才落到假接口上。导出接口带查询串(?report=…),模式末尾的 * 把它吞进匹配
await page.route(`**/api/artifacts/${fx.doc_artifact}`, (route) => route.fulfill({
contentType: 'application/json', body: JSON.stringify({ id: fx.doc_artifact, content: fx.doc }),
}))
await page.route('**/api/runs/*/report.html*', (route) => {
const url = new URL(route.request().url())
hits.push(url.pathname + url.search)
return route.fulfill(reportHtml ?? {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8',
'content-disposition': `attachment; filename="agentlab-runevid-report.html"; filename*=UTF-8''${encodeURIComponent(SAMPLE_NAME)}` },
body: '<!doctype html><title>report</title>',
})
})
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto(`${WEB}/preview.html?syn=${syn}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-turn]', { timeout: 15_000 })
return { ctx, page, hits, errors, fx }
}
await section('问数据成果上的导出按钮', async () => {
if (!WEB) {
note('没有前端地址(AGENTLAB_WEB),这一节跳过')
return
}
{
const { ctx, page, hits, errors, fx } = await preview('evidence')
try {
const html = page.locator('[data-turn="evidence"] [data-answer-export="html"]')
check('带证据的成果:导出按钮在,悬停写明数字点得开出处', await html.count() === 1
&& (await html.getAttribute('title')).includes('口径、来源和计算方式'))
// 按钮浮在答案右上角、悬停才显形:先挪上去再点,点击本身才落在按钮上
await page.locator('[data-turn="evidence"]').hover()
await html.hover()
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), html.click()])
check('下载的文件名同服务端(RFC 5987 的中文名)', download.suggestedFilename() === SAMPLE_NAME, download.suggestedFilename())
check('请求的是这次运行、点名的报告节点的导出接口', hits.length === 1
&& hits[0] === `/api/runs/${fx.run_id}/report.html?report=write`, hits.join('、'))
check('导出后提示文件名', await page.getByText(`已导出「${SAMPLE_NAME}」`).first().isVisible())
check('页面没有报错', !errors.length, errors.slice(0, 2).join(' | '))
const md = page.locator('[data-turn="evidence"] [data-answer-export="md"]')
check('Markdown 照旧留着一个小的', await md.count() === 1 && (await md.innerText()).trim() === 'MD')
} finally {
await ctx.close()
}
}
{
// 没有证据的旧答案:只有 Markdown 导出,没有 HTML 按钮
const { ctx, page, hits } = await preview('evidence')
try {
const legacy = page.locator('[data-turn="evidence-legacy"]')
check('旧运行(没有 _evidence):没有 HTML 导出按钮', await legacy.locator('[data-answer-export="html"]').count() === 0)
const md = legacy.locator('button[title="导出为 Markdown 文件"]')
check('旧运行:照旧只有 Markdown 导出', await md.count() === 1)
await legacy.hover()
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), md.click()])
check('旧运行:下载的是 .md 文件', download.suggestedFilename().endsWith('.md'), download.suggestedFilename())
check('旧运行:不请求导出接口', hits.length === 0, hits.join('、'))
} finally {
await ctx.close()
}
}
{
// 服务端回 409(文档被改过):原样提示原因,按钮恢复可点
const refusal = { status: 409, contentType: 'application/json',
body: JSON.stringify({ detail: '报告文档与哈希不一致,疑似被修改,不能导出', code: 'evidence_doc_tampered' }) }
const { ctx, page } = await preview('evidence', { reportHtml: refusal })
try {
await page.locator('[data-turn="evidence"]').hover()
await page.locator('[data-turn="evidence"] [data-answer-export="html"]').click()
check('服务端回 409 时原样提示原因',
await appears(page.getByText('报告文档与哈希不一致,疑似被修改,不能导出')))
check('提示之后按钮恢复可点',
await appears(page.locator('[data-turn="evidence"] [data-answer-export="html"]:enabled'), 8000))
} finally {
await ctx.close()
}
}
})
await cleanup()
console.log(failed ? `\n${failed} 项不通过` : '\n全部通过')
process.exit(failed ? 1 : 0)