mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
合入报告导出为自包含 HTML(数字点得开口径、来源、计算方式)与可验证报告数字详情里的口径卡一节;和可验证报告的遮罩改造合并:口径卡只给 id 的规则(有指标的值取自遮罩列、文档里写成「已遮罩」的)照旧,随文件附带的卡才附上它的输入指向的查询快照;数字详情保留显示文字核对和超过 2^53 的数照 JSON 原文显示,口径卡一节里输入的值同样照原文;样本页从 16 份变成 17 份
This commit is contained in:
@@ -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 不监听文件,跑完、出错或被中断都收干净。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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('、')}` : '']
|
||||
|
||||
@@ -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)的分工:那边导出的是封存核对件,收件人核的是「没被改过」;这边导出的是报告本身,
|
||||
读的人看的是每个数字的口径、来源和计算方式,封存状态照网页上的样子逐项标明。要向系统外的人证明文件没被改过,
|
||||
用可验证报告;要让人读报告、查数字的来历,用这份。
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})()
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')) }
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 追问:从答案里的表格派生,不调模型。
|
||||
*
|
||||
|
||||
@@ -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 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
@@ -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):伪造者把
|
||||
// 一份好好的文档也标成遮罩,页面照服务端的说法写原因、正文不显示,可它引用的那几份工件还列着,已核对的事件和文档里却
|
||||
// 没人引用它们,判不通过——藏不起一份报告还显示通过
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user