Files
AgentLab/backend/app/api/bundle.py
T
ThearchyHelios 5b4bd91476 口径账本内核:运行封存后在后台比对已发布口径、逐个数标口径、按运行读的账本接口、来源链和导出留痕、统计 E1–E4
为什么:问数据的答案要和已治理口径接起来(next-round 02 做法 2、3、9、10;总方案 X1、X2、X3、X12,拍板 L2、L4、L8)。

- 比对(ledger/match.py):探索运行(chat、canvas、trial、personal_app)成功结束、封存完、有查询快照时,在 runner 封存之后和
  notify_run 同一个位置放到后台;候选口径是每个工作流最新的已发布未退役版本里查库的调用工具节点(受管、已发布都算),按数据源
  和表预筛;逐个用 match_template 比,schema 用查询当时冻结的表结构、列名取同一份快照里冻结的数据目录。每次运行限时 8 秒、在
  线程里、同时最多 2 个;超时、快照被改过或读不出、SQL 解析不了照实记 skipped。启动时(run_manager.setup,和调度循环同一个开关)
  补比最近 7 天封存了却没比过的运行,只读工件和版本表。
- 记法(ledger/store.py,拍板 L8):内容寻址工件 kind=caliber_match(agentlab.caliber_match/1)+ run_append 的 sealed 方式追加
  ledger.matched(core/events.py 登记);工件里不放时间和工作流名,同一份封存再比一次哈希一样;meta 记 calibers / related / counts。
- 标签(ledger/labels.py):单元格按查询的比对状态;指标要每个输入都等价于同一份口径同一版、绑定一致,式子按「哪个取数节点、
  第几行、哪一列」换完和口径卡上的一字不差。caliber(how:independent 等价已证明;composed 助手选用留给第 3 波)/ related /
  reasoning / unknown;整题判断附还缺的入口字段。
- 读(ledger/view.py):GET /api/runs/{id}/ledger(ledger_api.runs_router,和证据同一道门),没比对过、能比的按需排上;证据
  片段接口加 ledger 键(没比对过的运行没有这个键,返回一字不差);报告导出的面板最上面多「口径」一步并写明「封存后比对,不在
  封存范围内」。遮罩按读的那一刻重滤:绑定的值是这次运行查到过的遮罩值时不给值,差异条目和逐行对照走 _Shown。
- 来源链:POST /api/runs 加 origin_ref(只在 origin=chat 时收,服务端核对对话访问权和轮次归属,写进 Run.origin 和封存的
  run.origin);WorkflowIn 加 origin(run_id、conversation_id、turn_id,核对规则同 from-run);报告导出和可验证报告下载各记一条
  审计 report.exported。可验证报告列封存后追加的事件时给 ledger.matched 一句只有条数的说明。
- 统计(ledger/stats.py):E1–E4 从工件、运行、查询快照、审计现算,GET /api/ledger/stats(搭建者);E3 用 app/chat/questions
  的聚类加按(数据源、normalize_sql 文本)的确定性重复;评测直接调 compute_stats(可点名 run_ids)。对账反馈、认证转化、按发布
  的召回在后面几波,照实写没有数据。

测试:test_ledger_match.py(真跑探索运行:等价、相关、没有相关口径、写死的码值、非探索运行、超时、快照被改、解析失败、预筛、
指标两种、级别、只认最新版、补比)、test_ledger_api.py(账本接口、按需比、遮罩、证据片段、导出、审计、统计)、
test_run_origin_ref.py(origin_ref 与画布来源的核对)。
2026-10-16 14:02:55 +08:00

83 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""可验证报告:GET /api/runs/{run_id}/bundle.html,把一次已封存运行的报告导出成一个自包含的 HTML 文件。
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对每个数字的出处、运行记录和工件有没有被改过
(页面怎么核见 bundle/verifier.js,数据怎么拼见 bundle/build.py)。
只给已封存、而且封存核对一致的运行:封存判定照搬证据接口(api/evidence._sealed),不另写一套。没封存的
运行事件还会变,导出去的东西马上就过时;封存核对不一致的,导出去的页面只会把一条断掉的链画成核对通过。
另外要求运行已经结束(成功、失败、取消):失败后接着跑的运行留着上一次的封存,跑完会重新封存、换掉清单哈希,
早年的运行停在审批时也封存过——这时导出去的文件,封存哈希和系统里之后记的对不上,页面上「去系统里对照」
那一步就作废了。
不追加运行事件、不改运行记录,只记一条导出审计(report.exported,format=bundle:召回要找得到谁下载过可验证报告,
next-round 02 做法 9)。路径挂在 /api/runs/{run_id}/… 下面,runs.py 里已经有 /{run_id}/verify,这里叫 bundle.html,不撞名。
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi.responses import Response
from sqlalchemy import select
from app.api.coded import CodedHTTPException
from app.api.evidence import RUN_NOT_FOUND, _mask_fingerprint, _Masks, _sealed, certain_masks
from app.bundle.build import attachment_name, build_bundle, content_disposition, render_html
from app.core.artifact_store import read_bytes
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
router = APIRouter(prefix="/api/runs", tags=["bundle"])
BUNDLE_UNSEALED = "bundle_unsealed"
BUNDLE_SEAL_BROKEN = "bundle_seal_broken"
#: 运行结束的状态:封存是最后一次的,之后不会再换(除非失败的运行又被接着跑,那是另一次封存)
_FINISHED = frozenset({"succeeded", "failed", "cancelled"})
UNSEALED = "运行尚未结束封存,结束后才能导出"
#: 原样出现在记录页的错误提示里,导出按钮旁边就是「核对」。不拼封存核对的结论原句:那句自带冒号和「不一致」
SEAL_BROKEN = "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果"
@router.get("/{run_id}/bundle.html")
async def export_bundle(run_id: str) -> Response:
"""可验证报告的 HTML 文件,作为附件下载(附件名「<运行名>-可验证报告.html」)。"""
sealed = await _sealed(run_id)
if not sealed.sealed or sealed.status not in _FINISHED:
raise CodedHTTPException(409, UNSEALED, BUNDLE_UNSEALED)
if not sealed.trusted:
raise CodedHTTPException(409, SEAL_BROKEN, BUNDLE_SEAL_BROKEN)
bound = sealed.seal["manifest_seq"]
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,
"version": run.version, "version_hash": run.version_hash, "run_class": run.run_class,
"status": run.status, "end_reason": run.end_reason, "started_at": run.started_at,
"finished_at": run.finished_at}
# 封存之后追加的事件(按需裁判、审批质询):_sealed 只留了封存范围内的,这几条另取,单列在页面上
later = [tuple(r) for r in await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id, RunEvent.seq > bound).order_by(RunEvent.seq))]
# 数据源现在设的遮罩:事后加的遮罩同样生效,和证据面板一个道理
masks = await _mask_fingerprint()
# 每份查询快照里确定取自遮罩列的列、这次运行查到过的遮罩值:认老文档时指标的值给不给,和报告撰写节点的写作目录
# 同一个口径(SQL 认不准时不因此算遮罩列)。要查库、按 SQL 血缘认,在这里认好交进去
sure, secrets = await certain_masks(sealed, _Masks(sealed))
exported_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
# 读工件、算哈希、base64 是同步的活,快照大的时候不该占着事件循环
page = await asyncio.to_thread(lambda: render_html(build_bundle(
info, [*sealed.events, *later], manifest_seq=bound, manifest_hash=str(sealed.manifest_hash or ""),
legacy=bool(sealed.seal.get("legacy")), graph=sealed.graph, source_masks=masks,
sure_columns=sure, sure_secrets=secrets,
read=lambda artifact: read_bytes(artifact, verify=False), exported_at=exported_at)))
from app.api.evidence import _reports, record_export
await record_export(run_id, "bundle", reports=[{"report_node": r.node_id, "doc_artifact": r.doc_artifact}
for r in _reports(sealed)])
return Response(page, media_type="text/html; charset=utf-8", headers={
"Content-Disposition": content_disposition(attachment_name(info), run_id=run_id),
# 导出的是这一刻的封存件,不该被哪一层缓存下来、换给下一个请求
"Cache-Control": "no-store",
})