mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
ce71f7d5c1
复核第 1 轮(abb2707)找到四处:写作目录按证据面板宁可多遮的那一套认指标出处,SQL 认不准时没碰号码列的销售额也成了
「已遮罩」,正式运行引用它就判失败(相对基线回退);「紧挨着负号」连破折号「——」「—」和汉字「一」都算,「运费统一10元」
「合计——7,036.50元」原样导出变红(相对基线回退);紧挨着改字只堵住了数字、负号、小数点,接「万」、画删除线、从右往左
书写的字母、l / I / O 照样全绿,根子在总览把无法核对的项也给绿色;第二张口径卡转抄遮罩指标(nodes.<卡>.metrics[0].value、
vars.<assign_to>.metrics[0].value、nodes.<卡>.text)时号码照样进写作目录、文档证据目录和可验证报告。
取自遮罩格的指标只按确定的那一套认(写作目录、报告自查、可验证报告同一个判断)
- 写作目录(api/evidence.py 的 withheld_metrics)认指标出处时遮罩列只认确定取自遮罩列的(_InputMasks 的 sure:读得出来的
快照按单元格引用自查的 _cited_masks,读不出来的按数据源现在设的列名),和 [[v:…]] 引用同一格的自查同一个口径:WITH 写了
列名表、VALUES 当表、子查询里有没起别名的号码表达式、只装着分档标签的列,指标照常显示、照常引用;改了名字、套了函数的
号码列照旧「已遮罩」。证据面板、推演照旧宁可多遮。报告自查按目录里记的 masked 认,跟着同一个判断
- 可验证报告认老文档(证据目录里还写着原值,整份只给哈希)按同一个判断:api/bundle.py 先按证据面板的那一套认好每份查询
快照里确定取自遮罩列的列和这次运行查到过的遮罩值(certain_masks),交给 build_bundle(sure_columns、sure_secrets),
_Store.metric_word 的 sure 按它们认。认口径卡能不能随文件附带照旧宁可多遮(卡上记着输入的值),SQL 认不准的卡同基线只给 id
转抄上游口径卡的指标按上游那个指标认
- recompute.card_input 认出输入转抄了哪张卡的哪几个指标:nodes.<卡>.metrics[i].value、vars.<卡的 assign_to>.metrics[i]、
整个指标、输入、渲染算转抄那个指标;整张卡、清单文字、整份指标列表、下标不是字面量的同整份查询结果(值里有遮罩值才不给,
数指标个数的照给);只取名称、单位、口径名的不算
- 证据面板 / 写作目录(_InputMasks._copied)、可验证报告(_Store._copied_kind)都用同一个 _metric_masks / metric_word 往上追,
上游取自遮罩格,转抄的也不给;追了 CARD_DEPTH(8)张还没追到头(循环里抄自己上一轮的产出)的宁可多遮
- 写作目录读快照前把运行状态里每张口径卡的输入一并读好,不在事件循环里读盘
显示文字核对(verifier.js)
- 判不通过只剩紧挨着的数字、小数点,和真当负号用的三个(半角连字符减号、数学减号、全角减号)紧挨着数字开头、前面又不是
字母数字时(「SKU-12」「A-3」的连字符是编号的一部分)。破折号(——、—、–、―,小号的、竖排的)、波浪线、汉字「一」、
假名长音、制表符横线这些易混字当普通的字。连字符、不断行连字符、上下标负号、粗减号这类平时不当负号用的短横紧挨着数字
开头判无法核对(以前判不通过;基线也是无法核对)
- 判无法核对(带原因):后面接着量级单位(万、亿、千、百、兆、萬、億、%、‰、‱、k、K、M、B 及全角,中间隔着空格也算;
核对过的字以「万」「亿」结尾再接一个也算),核对过的字画着删除线或紧挨着画了删除线的字,紧挨着长得像数字的字母和数字
符号(l、I、O、上下标数字、圈码、罗马数字、汉字〇),从右往左书写的字母和方向控制字符(照旧)
- 总览(overallOf):一项无法核对都没有、每份报告都核对上才是绿色的「全部通过」;有无法核对的写中性的「可核对的项全部通过,
另有 N 项无法核对」,下面醒目地写明这个文件判断不了真伪,要在系统中核对封存(运行 ID、GET /api/runs/<id>/verify、定时任务
通知里的清单哈希前 12 位)。以前无法核对的项照样给绿色,伪造者只要让读者看到的那个数落进无法核对就行
- 交出 layoutOf 给 node 里逐条核排法;开发数据 39 份报告 1591 处、验收数据 58 份报告 1695 处引用按新规则扫过,没有一处判
不通过或无法核对
测试
- 新增 tests/test_report_masked_metric_sure.py(四种 SQL 认不准的销售额、分档标签照给照引、确定取自号码列的照旧遮、老文档
照常附带、写作目录和可验证报告逐个指标同一个判断)、tests/test_report_masked_card_copies.py(三种转抄写法在写作目录、文档、
自查、证据面板、可验证报告里都不给号码,没有报告的运行、老文档,转抄普通指标照给,认转抄的各种写法,追不到头的宁可多遮)、
tests/test_bundle_prose_export.py(「统一」「——」「—」「SKU-」「共…家」「–」真跑报告撰写节点、从接口导出,全部通过、总览绿)
- test_bundle_shown:负号只认三个、前面是汉字的照判不通过;像负号的短横、量级单位、删除线、长得像数字的字母各自判无法核对、
总览中性;正常写法原样导出全绿;总览只在一项无法核对都没有时是绿的
- 样本页新增 prose(写作者自己写在引用旁边的普通文字,原样导出全绿);check-bundle 补数学减号、紧挨着汉字加负号判不通过,
接万(隔空格)、接亿元、接 %、接 k、订单号接万、画删除线另写一个数、前面加小写 L、后面接大写 O、长得像负号的连字符判无法
核对,各自点名原因、总览中性并写明去系统里核对;有无法核对项的样本页(base、legacy、continued、missing-card、masked-card、
masked-source、nan)总览改断言中性,从右往左书写的字母那条不再断言总览是绿的
- 每处改动都做了「去掉实现、测试变红」:写作目录不用 sure、老文档不用 sure、sure 列不用、转抄认不出(三处各自)、追不到头
按核对不了、负号的字母数字例外、负号恢复成 \p{Pd} 全部、数学减号全角减号不算、量级单位、隔空格、以万结尾、删除线、像数字
的字母、像负号的短横、总览有无法核对照样绿
78 lines
5.1 KiB
Python
78 lines
5.1 KiB
Python
"""可验证报告:GET /api/runs/{run_id}/bundle.html,把一次已封存运行的报告导出成一个自包含的 HTML 文件。
|
||
|
||
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对每个数字的出处、运行记录和工件有没有被改过
|
||
(页面怎么核见 bundle/verifier.js,数据怎么拼见 bundle/build.py)。
|
||
|
||
只给已封存、而且封存核对一致的运行:封存判定照搬证据接口(api/evidence._sealed),不另写一套。没封存的
|
||
运行事件还会变,导出去的东西马上就过时;封存核对不一致的,导出去的页面只会把一条断掉的链画成核对通过。
|
||
另外要求运行已经结束(成功、失败、取消):失败后接着跑的运行留着上一次的封存,跑完会重新封存、换掉清单哈希,
|
||
早年的运行停在审批时也封存过——这时导出去的文件,封存哈希和系统里之后记的对不上,页面上「去系统里对照」
|
||
那一步就作废了。
|
||
只读:不追加任何事件,不改运行记录。路径挂在 /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, "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)))
|
||
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",
|
||
})
|