From 265ba0c91dcd17864042bfcb099e921a5c60396d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 10 Oct 2026 12:59:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA=E4=B8=BA?= =?UTF-8?q?=E8=87=AA=E5=8C=85=E5=90=AB=20HTML=EF=BC=88=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E7=82=B9=E5=BE=97=E5=BC=80=E5=8F=A3=E5=BE=84=E3=80=81=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E3=80=81=E8=AE=A1=E7=AE=97=E6=96=B9=E5=BC=8F=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A=E7=9A=84?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E8=AF=A6=E6=83=85=E8=A1=A5=E9=BD=90=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=8D=A1=E4=B8=80=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事,一条心:把「网页上点数字看得到的效果」带出系统外。 ① 报告导出:新增 GET /api/runs/{run_id}/report.html(app/report_export:build + 模板 + 浏览器端脚本)。 正文由服务端按报告文档的块、句、片段渲染成 HTML——不执行脚本也读得到整份报告(附件预览常常不执行脚本); 执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开,面板是网页上证据面板同一个拼法的 出处链(api/evidence._chain):指标步骤带口径卡名与版本、原式、代入式、每个输入取自哪次查询的哪一格、 复算、双盲复算;查询步骤带 SQL 和被引用的行;数据源遮罩列照网页上的规矩写「已遮罩」。封存状态逐项标明; 没封存的运行照常导出(和网页上读报告一个道理)。同样的引用共用一条链,长报告不把整份快照抄几十遍。 入口在问数据答案和运行详情成果上的「导出」(带证据的报告给 HTML,Markdown 留一个小的;旧运行照旧只有 Markdown)——api.reportExport + AssistantStream 的 AnswerActions。 ② 可验证报告(bundle)的数字详情补齐:点开数字原来只有出处和值,现在多一节口径卡——指标(名 + id)、 单位、原式、代入式、输入(取值 ← 路径、取自哪次查询的哪一行哪一列)、复算;输入指向的查询快照也随文件 附带(遮罩规则同嵌入那一套,build._card_input_queries),④ 引用链按「已核对的口径卡的输入」认它们 (哈希对不上的卡不认,免得替被改过的卡圆)。收件人不连服务也能看到每个数怎么算出来的。 测试:后端 tests/test_report_export.py(真跑运行导出,正文、出处链、遮罩、只读、404/409、共用链); bundle_samples 的口径卡换成带原式/代入式/输入/复算的真实形状,另加 metric-only 样本页(只引用指标的 报告,快照从口径卡的输入这一条路附带);check-bundle 断言数字详情和这条引用链,加上篡改;新增 scripts/check-report-export.mjs(真导出断网点开、不执行脚本也读得到、成果上的导出按钮、旧答案没有 HTML 按钮、409 提示),挂进 check-all。README 补「导出两份」一节。全量 pytest 通过;check-bundle、 check-report-export、check-stream、check-evidence、check-chat 全部通过(check-runs 5 项失败为基线 上就有的沙箱数据形态问题,与本次无关)。 --- README.md | 16 +- backend/app/api/evidence.py | 98 ++++- backend/app/bundle/build.py | 47 ++- backend/app/bundle/verifier.js | 107 +++++- backend/app/report_export/__init__.py | 13 + backend/app/report_export/build.py | 390 ++++++++++++++++++++ backend/app/report_export/runtime.js | 465 ++++++++++++++++++++++++ backend/app/report_export/template.html | 156 ++++++++ backend/tests/bundle_samples.py | 53 ++- backend/tests/test_bundle_build.py | 17 +- backend/tests/test_report_export.py | 264 ++++++++++++++ frontend/src/api/client.ts | 2 + frontend/src/api/reportExport.ts | 32 ++ frontend/src/run/AssistantStream.tsx | 59 ++- scripts/check-all.mjs | 2 +- scripts/check-bundle.mjs | 46 ++- scripts/check-report-export.mjs | 381 +++++++++++++++++++ 17 files changed, 2101 insertions(+), 47 deletions(-) create mode 100644 backend/app/report_export/__init__.py create mode 100644 backend/app/report_export/build.py create mode 100644 backend/app/report_export/runtime.js create mode 100644 backend/app/report_export/template.html create mode 100644 backend/tests/test_report_export.py create mode 100644 frontend/src/api/reportExport.ts create mode 100644 scripts/check-report-export.mjs diff --git a/README.md b/README.md index 2bb46f40..eff62429 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建 | **产出复核** | 运行结束后规则扫描事件流,发现异常才调用模型复核;复核可重组答案但**不得引入原答案中不存在的数字** | | **追踪与成本** | 全量事件落库可回放;逐节点耗时、token 用量、按目录价折算的成本 | | **三档出具** | 口径卡(受控指标集)+ 出具契约:叙述中每个数字回指指标集,或由报告撰写节点按引用逐段核对,formal / degraded / withheld 三档判定 | -| **可点击证据** | 报告撰写节点只写引用标记,数字和表格由系统照快照填值;数字、表名字段名、知识库原话都点得开,一路指到查询结果里的那一格;结论句可要求挂依据或交给另一个模型裁判;证据清单可导出 | +| **可点击证据** | 报告撰写节点只写引用标记,数字和表格由系统照快照填值;数字、表名字段名、知识库原话都点得开,一路指到查询结果里的那一格;结论句可要求挂依据或交给另一个模型裁判;证据清单可导出,报告导出为自包含 HTML(数字点得开口径与出处),另有封存哈希核对件「可验证报告」 | | **发布前检查** | 受管级别加证据门禁 G1–G5;问题给出一键修复 / 选项 / 交给 Copilot 三类修法,一律先预览、不许降低要求;老工作流一键升级为可追溯结构 | | **正式 / 探索分级** | 正式运行仅从已发布的不可变版本发起(按图哈希钉死);画布试跑自动标记为探索性 | @@ -228,6 +228,15 @@ flowchart LR 问数据的 input → agent → output 中间插一个报告撰写,配了 `output_schema` 的 agent 打开 `cite_fields`; 沙箱代码喂口径卡的只建议标成 `source`,不替人改。升级只给预览,人确认后才保存。 +**导出两份。** 问数据答案和运行详情成果上的「导出」把报告导出成一个自包含的 HTML 文件 +(`GET /api/runs/{id}/report.html`):正文由服务端渲染,不执行脚本也读得到(邮件附件预览常常不执行脚本); +执行脚本后每个数字、无证据的数字、可疑的表名字段名都点得开——面板是网页上证据面板同一个拼法的 +出处链(口径、来源、计算方式、SQL、被引用的行),封存状态逐项标明;遮罩列照网页上的规矩写「已遮罩」。 +没封存的运行照常导出(和网页上读报告一个道理)。记录页「凭证详情」里的「导出可验证报告」 +(`GET /api/runs/{id}/bundle.html`)是另一份:封存核对件,收件人不连服务、在浏览器里重算哈希,核对每个 +数字、文档和运行记录没被改过;数字的出处面板同样带口径、原式、代入式、输入和复算,口径卡输入指向的 +查询快照也随文件附带、引用链认得这条引用。要证明没被改过用可验证报告,要让人读报告、查数字的来历用报告导出。 + ### 受管门禁与发布前修复 受管级别在治理 lint 之外加了证据门禁,只看图和配置,让「每个数都点得开」在结构上成立: @@ -679,7 +688,7 @@ cd backend && env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_T # 前端类型检查 cd frontend && npx tsc -b -# 前端检查:20 项,默认 8 道并行,约 8 分钟。连 5273 / 8000,先起 ./scripts/dev.sh +# 前端检查:28 项,默认 8 道并行。连 5273 / 8000,先起 ./scripts/dev.sh node scripts/check-all.mjs # 另起 2 套只给检查用的「后端 + vite」,各用一份数据拷贝,约 6.5 分钟;边改代码边跑也不被打断 CHECK_DATA_SRC=<数据目录> node scripts/check-all.mjs --stacks 2 --lanes 12 @@ -690,7 +699,7 @@ node scripts/check-all.mjs evidence publish pytest 前面的 `env -u` 不能省:没配 Key 的 provider 会退回环境变量里的密钥,启动时的 seed 也会拿它种一个 provider——环境里留着真密钥,测试就可能真的去调接口。 -`check-all` 的 20 项按 `scripts/check-all.mjs` 里 `ORDER` 的顺序排: +`check-all` 的 28 项按 `scripts/check-all.mjs` 里 `ORDER` 的顺序排: | 类别 | 检查 | |---|---| @@ -698,6 +707,7 @@ provider——环境里留着真密钥,测试就可能真的去调接口。 | 走线、组件与护栏 | `canvas-layout` `ui-kit` `guards` | | 外壳、回答流与运行态 | `shell` `stream` `evidence`(可点击证据)`run-states`(节点卡)`canvas-fx`(画布表层) | | 各页面 | `studio` `publish`(发布前检查与自动修复)`chat` `runs` `manage` `versions`(上传表格的版本)`catalog`(数据目录)`ui`(端到端,连真数据) | +| 复核套件与导出 | `schedules`(定时任务)`compare`(运行对比)`verify`(双盲复算、反方审稿)`inquiry`(审批质询)`challenge`(反方审稿)`bundle`(可验证报告)`whatif`(数字推演)`report-export`(报告导出) | 完整输出写进 `/tmp/agentlab-checks/<时间>/`,终端只给汇总;并行时没过的最后单独重跑一遍,重跑才过的单列成「偶发」。 `--stacks` 每套拷一份数据、端口自动挑空闲的,后端不热重载、vite 不监听文件,跑完、出错或被中断都收干净。 diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index a67d4e20..e387e2a6 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -2,7 +2,6 @@ 一件证据可信,当且仅当它能从封存范围内的事件(seq ≤ manifest_seq)出发走到, 并且取回时哈希复验通过: - - 报告文档:report.checked 事件里的 doc_artifact - 口径卡的指标集:口径卡节点 node.finished 事件里的 evidence 台账 - 查询快照:tool.end 事件里的 query_artifact,或者 node.finished.evidence 里的 query 条目 @@ -21,6 +20,10 @@ artifacts 表可以事后插行,run.output 是可以改写的一列,封存 options.mask_columns 里的列换成「已遮罩」——在有身份体系之前,这只减少暴露,不是 安全边界:知道工件 id 的人照样能取到整份快照。 +报告导出(GET /api/runs/{run_id}/report.html,app/report_export)用的也是这套出处链: +把选中的报告文档渲染成自包含的 HTML,每个数字点得开和网页上同一个拼法的面板 +(口径、来源、计算方式),同样的引用共用一条链。 + 期 4 的核查状态同样只读封存范围内的东西:指标步骤带双盲复算的结果(_dual_check_of,读双盲复算节点的产出和台账 条目),句子带反方审稿的判断(_challenge_of,读反方审稿节点的产出和审稿记录)。它们是核查,不是证据:没有就不带这两个键。 证据图的每份报告另带一张被反方审过的句子 → 判断的表(_challenge_verdicts,和 _challenge_of 同一个读法),正文据此挂 @@ -1568,6 +1571,99 @@ async def evidence_provenance(run_id: str, segment_id: str, report: str | None = return asdict(out) +# -------------------------------------------------------------------------- +# GET /api/runs/{run_id}/report.html(报告导出:自包含的 HTML,数字点得开口径、来源、计算方式) +# -------------------------------------------------------------------------- + + +def _export_cite_key(seg: dict[str, Any]) -> tuple | None: + """数字 / 取值片段的引用键:同一处引用(同一个指标、同一格、同样的换算)共用一条出处链。 + 别的片段(表名、引文)的链跟着片段走,不归并。""" + if seg.get("kind") not in ("number", "value"): + return None + cite = seg.get("cite") + if not isinstance(cite, dict): + return None + loc = cite.get("locator") if isinstance(cite.get("locator"), dict) else {} + return (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"), + loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue")) + + +@router.get("/{run_id}/report.html") +async def export_report_html(run_id: str, report: str | None = None) -> Response: + """把一次运行的报告导出成一个自包含的 HTML 文件(app/report_export)。 + + 正文由服务端按报告文档渲染(不执行脚本也读得到);带出处链的数据嵌在页面里,执行脚本后每个数字、 + 无证据的数字、可疑的表名字段名都点得开——面板是网页上证据面板同一个拼法(_chain):口径卡、口径与 + 版本、原式、代入式、输入、复算、SQL、被引用的行。封存状态逐项标明;没封存的运行照常导出(和网页上 + 读报告一个道理)。只读:不追加任何事件,不改运行记录。 + + 一次运行里有好几份报告时用 ?report=<节点 id> 指定;不指定取成果里标注的那一份(和片段接口同一个选法)。 + 选报告、错误码和片段接口相同(run_not_found、evidence_report_not_found、evidence_doc_tampered、 + evidence_doc_missing)。 + """ + from datetime import datetime, timezone + + from app.report_export.build import ( + attachment_name, build_report, content_disposition, render_html, segment_state, + ) + + sealed = await _sealed(run_id) + reports = _reports(sealed) + if not reports: + raise CodedHTTPException(404, "本次运行没有报告文档,没有可导出的报告", REPORT_NOT_FOUND) + chosen = _choose(reports, report, sealed) + if chosen.hash_ok is False: + raise CodedHTTPException(409, "报告文档与哈希不一致,疑似被修改,不能导出", DOC_TAMPERED) + if chosen.doc is None: + raise CodedHTTPException(404, "无法读取报告文档", DOC_MISSING) + doc = chosen.doc + nodes: dict[str, dict[str, str]] = {} + for item in sealed.graph.get("nodes") or []: + if isinstance(item, dict) and item.get("id"): + data = item.get("data") if isinstance(item.get("data"), dict) else {} + nodes[str(item["id"])] = {"label": str(data.get("label") or item["id"]), + "type": str(item.get("type") or "")} + # 每个有状态的片段一条出处链(和网页上点开的同一个 _chain);同样的引用共用一条,别把整份快照抄几十遍 + masks = _Masks() + chains: dict[str, list[dict[str, Any]]] = {} + refs: dict[str, str] = {} + notes: dict[str, str] = {} + memo: dict[tuple, str] = {} + for _, unit in iter_units(doc): + for seg in unit.get("segments") or []: + if not isinstance(seg, dict) or not seg.get("id") or segment_state(seg) is None: + continue + sid = str(seg["id"]) + if note := _note(seg, unit): + notes[sid] = note + key: tuple | None = _export_cite_key(seg) + if key is not None and key in memo: + refs[sid] = memo[key] + continue + chain = await _chain(seg, doc, sealed, chosen.node_id) + ck = key if key is not None else (sid,) + chains[str(ck)] = chain + refs[sid] = str(ck) + if key is not None: + memo[key] = str(ck) + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: # 两次读之间被删掉了 + raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND) + info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name, + "run_class": run.run_class, "status": run.status, + "started_at": run.started_at, "finished_at": run.finished_at} + data = build_report(info, report_node=chosen.node_id, doc_artifact=chosen.doc_artifact, + doc_hash_ok=chosen.hash_ok, doc=doc, seal=sealed.seal, chains=chains, refs=refs, + notes=notes, nodes=nodes, exported_at=datetime.now(timezone.utc).isoformat(timespec="seconds")) + return Response(render_html(data, doc), media_type="text/html; charset=utf-8", headers={ + "Content-Disposition": content_disposition(attachment_name(info), run_id=run_id), + # 导出的是这一刻的报告和出处,不该被哪一层缓存下来、换给下一个请求 + "Cache-Control": "no-store", + }) + + #: R14 之后(绑定快照、取引擎、编译核对、回查、行级核对)会抛的三类异常,按 _snapshot_failure 的顺序映射 _SNAPSHOT_ERRORS: tuple[type[BaseException], ...] = (SnapshotMissing, SnapshotTampered) diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index a0214469..5c1bc668 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -12,8 +12,10 @@ 嵌入,别的不嵌内容、只给 id。口径卡上记着每个输入的值和代入式,有输入取自遮罩列(cell() 的格、Agent 字段、按位置 取的 vars.res.rows[0][2],SQL 里改了名字的遮罩列也算,SQL 认不出结果里哪一列取自哪一列的整份快照的列都算)的整张卡 同样只给 id,认法和证据面板、推演同一个(recompute.input_origin);服务端文件被改过的卡照样按卡上记的输入认,解析 - 不出一张卡的整张只给 id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable, - 核对页照实说(对不上的判不通过)。没被报告引用的工件不嵌。 + 不出一张卡的整张只给 id。随文件附带的口径卡,它记着的输入指向的查询快照也一并附上(遮罩规则同上):点开数字时 + 看得到每个输入的 SQL 和被引用的行,引用链(④)按「已核对的口径卡的输入」认它们。只给 id 的工件,服务端的文件和 + id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被报告引用、也没被 + 附带的口径卡的输入引用的工件不嵌。 - 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。 它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次 @@ -762,6 +764,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 是封存范围内的事件 (认口径卡带不带遮罩时找写变量的节点当时的产出)。""" @@ -770,11 +796,13 @@ 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) -> None: + def add(self, artifact: str, kind: str, node_id: str | None) -> dict[str, Any] | None: + """列进清单的一份工件,交回它的条目(同一份只列一次,第二次交回第一次的)。""" 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: @@ -796,6 +824,8 @@ class _Artifacts: # 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上 item["b64"] = base64.b64encode(bytes(raw)).decode("ascii") self.items.append(item) + self._by_id[artifact] = item + return item # -------------------------------------------------------------------------- @@ -930,9 +960,16 @@ def build_bundle( if not isinstance(doc, dict) or doc.get("run_id") != run.get("id") or doc.get("node_id") != node_id: continue for artifact, kind, owner in _cited_artifacts(doc): - artifacts.add(artifact, kind, owner) + item = artifacts.add(artifact, kind, owner) if kind == "query": cited_queries.append(artifact) + continue + # 口径卡随文件附带了(没遮、文件在):它记着的输入——哪个指标的值取自哪次查询的哪一格—— + # 那几次查询的快照也一并附上(设了遮罩的照旧只给 id)。点开数字看得到输入的 SQL 和被引用的行; + # 引用链(④)按「已核对的口径卡的输入」认它们,被改过的卡(intact 不过)不带,免得替它圆 + if kind == "metric_set" and item is not None and "b64" in item and store.intact(artifact): + for query, query_owner in _card_input_queries(store.content(artifact)): + artifacts.add(query, "query", query_owner) masked_run = _masked_run(sealed, store, cited_queries, source_masks or {}) shown_events = [] diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 9f6d9a5a..2ea4ef7f 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -117,6 +117,7 @@ chainDoc: (label) => `报告「${label}」的文档`, chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`, chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`, + chainByCard: (label, metric) => `口径卡「${label}」的输入(指标 ${metric})`, chainFrom: '引用自', chainNoCheckEvent: '找不到这个报告节点的报告自查事件', chainCheckEventFailed: '报告自查事件的内容没有通过核对', @@ -149,6 +150,12 @@ numNoCell: (why) => `快照里找不到这一格:${why}`, numCellDiffers: '引用的值与快照里那一格的值不一致', 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: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对', @@ -413,6 +420,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 @@ -564,12 +572,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) } @@ -636,6 +665,8 @@ const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : [] const metric = metrics.find((m) => isObj(m) && m.id === metricId) info.card = art.json + // 指标本身:详情里据此展示指标名、原式、代入式、输入和复算(计算方式) + info.metric = metric || null if (!metric) return { ...info, result: FAIL, reason: T.numNoMetric } info.truth = metric.value return sameValue(cite.value, metric.value) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numMetricDiffers } @@ -963,9 +994,28 @@ return [' ', badge(item.result), item.reason ? h('span', { class: 'muted' }, ` ${item.reason}`) : null] } + /** 一份随文件附带的查询快照:SQL + 被引用的那一行(引用的格高亮)。数字详情里单元格引用和口径卡的输入共用 */ + function snapshotBlock(snap, row, column) { + 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 cellAt = (c, i) => (isObj(record) ? own(record, c) : record[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 }, + cellAt(c, i) == null ? '—' : textOf(cellAt(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 // 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文 const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved' const rows = [ @@ -973,6 +1023,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, valueText(cite.value)] : null, 'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null, [T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]], @@ -980,19 +1032,45 @@ [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)) { - // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮 - let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String) - if (isObj(record) && !columns.length) columns = Object.keys(record) - const cellAt = (c, i) => (isObj(record) ? own(record, c) : record[i]) - 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 }, - cellAt(c, i) == null ? '—' : textOf(cellAt(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 || '—'))) + const inputs = Array.isArray(metric.inputs) ? metric.inputs.filter(isObj) : [] + if (inputs.length) { + parts.push(h('p', { class: 'sub' }, T.dInputs), + h('ul', { class: 'items' }, inputs.map((input) => { + 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} `, valueText(input.value), + 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)) + } + } + 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)) return parts } @@ -1150,6 +1228,7 @@ return [T.chainDoc(nodeLabel(c.report.node_id)), ' ', h('code', {}, short(c.report.doc_artifact)), from] } const refs = [...c.docs.map((d) => T.chainByDoc(nodeLabel(d.report.node_id), d.alias)), + ...c.cards.map((k) => T.chainByCard(nodeLabel(k.card.a.node_id), k.metric)), ...c.events.map((ev) => T.chainByEvent(ev.seq, eventWhat(ev)))] return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)), refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : ''] diff --git a/backend/app/report_export/__init__.py b/backend/app/report_export/__init__.py new file mode 100644 index 00000000..6acb897b --- /dev/null +++ b/backend/app/report_export/__init__.py @@ -0,0 +1,13 @@ +"""报告导出:把一次运行的报告导出成一个自包含的 HTML 文件,正文里的数字点得开口径、来源和计算方式。 + +- build.py:把报告文档渲染成正文 HTML(不执行脚本也读得到),连同每个数字的出处链一起填进模板(纯函数,不碰数据库) +- template.html:页面骨架和样式 +- runtime.js:浏览器端的出处面板(原生 JS,无依赖;正文是服务端渲染好的,脚本只负责点开数字) + +接口在 api/evidence.py(GET /api/runs/{run_id}/report.html)——出处链和证据面板(点开报告里一个片段的接口) +用的是同一套拼法,页面上看到的就是网页上看到的。 + +和可验证报告(app/bundle)的分工:那边导出的是封存核对件,收件人核的是「没被改过」;这边导出的是报告本身, +读的人看的是每个数字的口径、来源和计算方式,封存状态照网页上的样子逐项标明。要向系统外的人证明文件没被改过, +用可验证报告;要让人读报告、查数字的来历,用这份。 +""" diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py new file mode 100644 index 00000000..df2ccb88 --- /dev/null +++ b/backend/app/report_export/build.py @@ -0,0 +1,390 @@ +"""报告导出的构建:报告文档 → 自包含的 HTML(正文服务端渲染 + 每个数字的出处链嵌进数据块)。 + +拿到文件的人在浏览器里打开:不执行脚本也读得到整份报告(正文是服务端按文档的块、句、片段渲染好的 HTML); +执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的 +出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、 +输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查), +以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。 + +这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希 +(可验证报告,app/bundle)。页面页脚写明这一点。 + +主体是纯函数:不碰数据库。调用方(api/evidence 的路由)取好报告文档、逐个片段算好出处链再交进来。 +""" +from __future__ import annotations + +import base64 +import hashlib +import html +import json +import re +from collections.abc import Mapping, Sequence +from functools import lru_cache +from pathlib import Path +from typing import Any +from urllib.parse import quote + +SCHEMA = "agentlab.report/1" + +#: 和 bundle/build.py 的附件名同一个规矩:文件名里不能有的字(Windows 最严,按它来)换成下划线 +_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]') +_NAME_MAX = 80 +FILE_SUFFIX = "-报告.html" +#: 没挂在工作流上的运行叫「未保存的工作流」(同 bundle/build.py、前端 lib/terms.ts 的 runName) +UNSAVED_NAME = "未保存的工作流" +_UNSAVED_NAMES = frozenset({"", "临时图", UNSAVED_NAME}) + + +def display_name(run: Mapping[str, Any]) -> str: + """页面标题、附件名用的运行名:工作流名;没保存的工作流一律叫「未保存的工作流」。(同 bundle/build.py)""" + name = str(run.get("workflow_name") or "").strip() + if not run.get("workflow_id") and name in _UNSAVED_NAMES: + return UNSAVED_NAME + return name or f"运行 {str(run.get('id') or '')[:8]}".strip() + + +def attachment_name(run: Mapping[str, Any]) -> str: + """附件名「<运行名>-报告.html」。""" + name = _BAD_FILENAME.sub("_", display_name(run)).strip(" .")[:_NAME_MAX].strip(" .") + return f"{name or UNSAVED_NAME}{FILE_SUFFIX}" + + +def content_disposition(filename: str, *, run_id: str) -> str: + """附件头:中文名按 RFC 5987 写在 filename* 里;filename 给不认 filename* 的老客户端一个纯 ASCII 的名字。 + (同 bundle/build.py 的拼法,脚本 check-bundle.mjs 对这份文件同样断言附件名。)""" + ascii_id = re.sub(r"[^A-Za-z0-9]", "", run_id)[:8] + return f"attachment; filename=\"agentlab-{ascii_id}-report.html\"; filename*=UTF-8''{quote(filename, safe='')}" + + +# -------------------------------------------------------------------------- +# 正文:报告文档 → HTML(服务端渲染) +# -------------------------------------------------------------------------- + +#: 行内语法的写法和 bundle/verifier.js、前端 run/Markdown.tsx 同一套:代码(反引号)、链接(只显示文字: +#: 页面不连网)、粗体、斜体、删除线。斜体左边紧挨 * 的不算(否则 **粗体** 会先被它拆掉) +_INLINE = re.compile( + r"(`+)([^`]+?)\1|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|\*\*([^*]+?)\*\*|__([^_]+?)__" + r"|\*([^*\n]+?)\*(?!\*)|~~([^~]+?)~~") +_CODE, _LINK, _BOLD, _EM, _STRIKE = 1, 2, 4, 8, 16 +#: 列表项的开头:行首的 -、*、+ 或者序号(同 verifier.js 的 ITEM_START) +_ITEM_START = re.compile(r"(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$") +#: 表格里看着是数的列(右对齐):同 verifier.js 的 NUMERIC_CELL +_NUMERIC_CELL = re.compile(r"^[-+−]?[\d,]+(?:\.\d+)?%?[^\d\s]{0,4}$") + +#: 片段的状态 → 正文里的线型(class)。同网页上证据文档的颜色语言:实线绿是有出处,点线是核对不了, +#: 波浪红是可疑(反引号里发现的编造名字这类)。probabilistic / candidate 按未裁判、候选画 +_STATE_CLASS = { + "deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad", + "unjudged": "na", "candidate": "na", +} +_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"} + + +def _esc(text: Any) -> str: + return html.escape(str(text if text is not None else "")) + + +def _inline_matches(part: str) -> list[re.Match[str]]: + """一句里的行内语法,按先后。斜体左边紧挨着 * 的跳过、从下一个字接着找(同 verifier.js)。""" + out: list[re.Match[str]] = [] + pos = 0 + while True: + m = _INLINE.search(part, pos) + if m is None: + return out + if m.group(7) is not None and m.start() > 0 and part[m.start() - 1] == "*": + pos = m.start() + 1 + continue + out.append(m) + pos = m.end() + + +def _inline_marks(text: str) -> tuple[bytearray, bytearray]: + """整句算一次行内样式:hide 是语法符号占的位置(不显示),style 是每个可见字的样式位。 + 粗体、代码可能跨片段(服务端把一句切成了好几个片段),所以按整句算、每个片段取自己那一截。 + ( verifier.js inlineStyles 的 Python 版,规则一致。)""" + hide = bytearray(len(text)) + style = bytearray(len(text)) + + def walk(frm: int, to: int, inherited: int, depth: int) -> None: + part = text[frm:to] + last = 0 + for m in _inline_matches(part): + code, link_text, bold1, bold2, italic, strike = ( + m.group(2), m.group(3), m.group(5), m.group(6), m.group(7), m.group(8)) + for i in range(last, m.start()): + style[frm + i] = inherited + inner = next((v for v in (code, link_text, bold1, bold2, italic, strike) if v is not None), "") or "" + base = frm + m.start() + whole = m.group(0) + off = len(m.group(1) or "") if code is not None else whole.find(inner) + for i in range(len(whole)): + hide[base + i] = 1 + for i in range(len(inner)): + hide[base + off + i] = 0 + bit = _CODE if code is not None else _LINK if link_text is not None \ + else _BOLD if bold1 is not None or bold2 is not None \ + else _EM if italic is not None else _STRIKE + if code is not None or link_text is not None or depth >= 3: + for i in range(len(inner)): + style[base + off + i] = inherited | bit + else: + walk(base + off, base + off + len(inner), inherited | bit, depth + 1) + last = m.end() + for i in range(last, len(part)): + style[frm + i] = inherited + + walk(0, len(text), 0, 0) + return hide, style + + +def _styled_html(text: str, hide: bytearray, style: bytearray, from_: int, to: int) -> str: + """一句里 [from_, to) 那一段按算好的样式写成 HTML。""" + out: list[str] = [] + i = from_ + while i < to: + if hide[i]: + i += 1 + continue + st = style[i] + j = i + 1 + while j < to and not hide[j] and style[j] == st: + j += 1 + piece = _esc(text[i:j]) + if st & _CODE: + piece = f"{piece}" + if st & _EM: + piece = f"{piece}" + if st & _BOLD: + piece = f"{piece}" + if st & _STRIKE: + piece = f"{piece}" + if st & _LINK: + piece = f'{piece}' + out.append(piece) + i = j + return "".join(out) + + +def segment_state(seg: Mapping[str, Any]) -> str | None: + """片段的状态(同前端 segmentState 的分法):文字、结构片段是 None(不画线、点不开)。 + 导出的路由按它决定哪个片段要算出处链,正文渲染按它画线,两边同一个分法。""" + if seg.get("kind") == "structural": + return None + state = seg.get("state") + if state == "deterministic": + return "deterministic" + if state == "none": + if seg.get("issue") == "unknown_entity" or (seg.get("cite") or {}).get("unknown"): + return "suspect" + if seg.get("issue") == "unverified_entity" or (seg.get("cite") or {}).get("unverified"): + return "unverified" + return "none" + if state in ("probabilistic", "candidate"): + return "unjudged" if state == "probabilistic" else "candidate" + return None + + +def _seg_html(seg: Mapping[str, Any], body: str) -> str: + """一个片段:有状态的是按钮(点开出处面板),文字、结构片段照字渲染。""" + state = segment_state(seg) + kind = str(seg.get("kind") or "") + if state is None: + inner = f"{body}" if seg.get("strong") else body + return f"{inner}" + cls = _STATE_CLASS.get(state, "na") + label = _KIND_LABEL.get(kind, "片段") + title = f"{label}「{_esc(seg.get('text'))}」:点开查看出处" + attrs = (f'class="num num-{cls}" data-seg="{_esc(seg["id"])}" data-state="{_esc(state)}"' + f' data-kind="{_esc(kind)}" title="{title}"') + if seg.get("strong"): + body = f"{body}" + return f'' + + +def _render_unit(unit: Mapping[str, Any]) -> str: + """一句:片段先拼成整句算行内样式,再各自取自己那一截(结构片段不进正文,列表序号由
  • 自己画)。""" + 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'{"".join(_render_unit(u) for u in units)}' + if btype == "hr": + return "
    " + if btype == "quote": + return f'
    {"".join(_render_unit(u) for u in units)}
    ' + if btype == "code": + return f'
    {_esc("".join(_unit_text(u) for u in units))}
    ' + if btype == "list": + items: list[dict[str, Any]] = [] + for u in units: + lead = (u.get("segments") or [None])[0] + starts = not items or (isinstance(lead, dict) and lead.get("kind") == "structural" + and _ITEM_START.match(str(lead.get("text") or ""))) + if starts: + items.append({"depth": int(u.get("depth") or 0), "units": [u]}) + else: + items[-1]["units"].append(u) + tag = "ol" if block.get("ordered") else "ul" + start = f' start="{int(block["start"])}"' if block.get("ordered") \ + and isinstance(block.get("start"), int) and block["start"] != 1 else "" + rows = [] + for item in items: + margin = f' style="margin-left:{item["depth"] * 14}px"' if item["depth"] else "" + rows.append(f'{"".join(_render_unit(u) for u in item["units"])}
  • ') + return f"<{tag}{start}>{''.join(rows)}" + 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}', []))}" + + head = "".join(cell("th", -1, c) for c in range(cols)) + body = "".join("" + "".join(cell("td", r, c) for c in range(cols)) + "" for r in row_ids) + return (f'
    {head}' + f"{body}
    ") + return f'

    {"".join(_render_unit(u) for u in units)}

    ' + + +def render_body(doc: Mapping[str, Any]) -> str: + """报告文档的正文 HTML。块都读不出来时退回 markdown 原文(等宽显示,至少读得到)。""" + blocks = [b for b in doc.get("blocks") or [] if isinstance(b, dict)] + if not blocks: + return f'
    {_esc(doc.get("markdown") or "")}
    ' + return f'
    {"".join(_render_block(b) for b in blocks)}
    ' + + +# -------------------------------------------------------------------------- +# 数据块和页面 +# -------------------------------------------------------------------------- + +#: 数据放在 、