Files
AgentLab/backend/app/bundle/build.py
T
ThearchyHelios faf21894a3 可验证报告:数据源设了遮罩时,能证明没碰遮罩列的查询快照照常随文件附带
以前只要查询当时记下了数据源的遮罩列,或者数据源现在设了遮罩,整份快照就只给哈希——哪怕这条查询只按地区汇总了
金额。数据源里只要有一列设了遮罩(客户手机号),报告里引用查询格子的数字就全成了「无法核对」,对外几乎没法核。

现在 build._untouched 全部满足才嵌入,任何一条不满足、判断过程出错都照旧只给哈希:
- 认得这份快照:数据源查询工具落的那种、哈希对得上;不是合并查询;没有不认识的键(以后核查 agent 在查询时就遮过、
  另记下遮了哪些列的快照,照现有规则只给哈希);
- 结果的列名对不上遮罩列;
- SQL 原文里没写到遮罩列,条件里、COUNT 里也不行:SQL 随快照一起附带,WHERE phone = '…' 的字面量就是遮罩值;
  MySQL 会执行的注释、Postgres 的 Unicode 转义名字这类语法树认不准的写法也不行;
- SQL 解析得了(不知道是哪种库,按每种支持的库的读法各解析一遍,读得通的每一种都要过关),没有认不出结果里哪一列
  取自哪一列的写法:列名表、整行引用、可能展开出遮罩列的 *(展开了数据源里的表,又不在只按名字往外传的派生来源、
  EXISTS / IN 子查询里);按 SQL 认遮罩列的那一套(inquiry._derived_hidden,双盲复算、质询同一个)也认不出哪个
  结果列取自遮罩列。

查询事件(tool.end)的规范字符串跟着快照走:快照随文件附带的,事件也带内容。新测试覆盖按地区汇总(查询时记下的、
数据源现在设的遮罩各一种)、WITH / 子查询 / LATERAL / EXISTS 里的 *、反引号,以及选出遮罩列、别名、子查询、CTE、
SELECT *、函数包着、只在条件里、整行引用、列名表、解析不了、改过的、不认识的键、合并查询、判断出错;每一条规则都做过
去掉实现测试变红的验证。
2026-10-06 17:04:45 +08:00

760 lines
42 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.
"""可验证报告的构建:把一次已封存运行的报告、证据和运行记录打成一个自包含的 HTML 文件。
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对(verifier.js):
- 事件:封存范围内(seq ≤ manifest_seq)的每一条都带 {seq, type, node_id, data_sha256},data_sha256 是
sha256(canonical_json(data)),和 runner 封存时算 manifest_hash 用的是同一个算法,浏览器按同样的拼法重算
清单哈希、和封存哈希比。白名单里的事件另带服务端算好的规范字符串 canonical(逐字等于 canonical_json(data)),
浏览器先核它的哈希,再从里面读出报告文档、口径卡、查询快照的 id。其余事件只给哈希:工具返回、节点产出的
预览里可能有遮罩列的原值和提示词。
- 工件:每份报告文档(各报告节点最后一次自查记下的 doc_artifact)、它引用到的口径卡和查询快照,按原字节
base64 嵌入,浏览器重算 sha256 和 id 比。数据源设了遮罩的查询快照,能证明这条查询没碰遮罩列的(_untouched)照常
嵌入,别的不嵌内容、只给 id。口径卡上记着每个输入的值和代入式,有输入取自遮罩列(cell() 的格、Agent 字段、按位置
取的 vars.res.rows[0][2],SQL 里改了名字的遮罩列也算)的整张卡同样只给 id,认法和证据面板、推演同一个
(recompute.input_origin);服务端文件被改过的卡照样按卡上记的输入认,解析不出一张卡的整张只给 id。只给 id 的工件,
服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被报告
引用的工件不嵌。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次
运行的封存核对(GET /api/runs/{id}/verify)对照,页面上醒目地写着这一点。
主体是纯函数:不碰数据库,工件经 read 取原字节,数据源现在设的遮罩由调用方查好传进来(source_masks)。
"""
from __future__ import annotations
import base64
import hashlib
import html
import json
import logging
import re
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from urllib.parse import quote
from app.core.artifact_store import canonical_json, content_hash
from app.core.events import EventType
from app.engine.evidence import iter_units, query_entry_fields
from app.engine.merge_query import MERGE_SOURCE
from app.engine.recompute import input_origin
from app.engine.schema import NodeType
from app.engine.toolcalls import QUERY_PREFIX
logger = logging.getLogger(__name__)
SCHEMA = "agentlab.bundle/1"
#: 不论哪个节点都带规范字符串的事件:运行开始(实际生效的运行参数)、报告自查(报告文档的 id)、
#: 口径卡升版处置、出具判定(档位)
_SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, EventType.CALIBER_UPGRADE,
EventType.ISSUANCE})
#: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文)、口径卡(指标集的 id 记在台账里)
_SHOWN_NODES = frozenset({NodeType.REPORT, NodeType.METRICS})
#: 目录条目的种类 → 嵌进来的工件种类。运行输入、检索、表和字段的条目不嵌:前者没有工件,后几样不在核对范围里
_CITED_KINDS = {"metric": "metric_set", "query": "query"}
#: 没挂在工作流上的运行(画布、问数据当场搭的):老数据叫「临时图」,新的叫「未保存的工作流」,也可能是空的。
#: 和前端 lib/terms.ts 的 runName 同一个规则
UNSAVED_NAME = "未保存的工作流"
_UNSAVED_NAMES = frozenset({"", "临时图", UNSAVED_NAME})
FILE_SUFFIX = "-可验证报告.html"
#: 文件名里不能出现的字(Windows 最严,按它来)和控制字符
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
_NAME_MAX = 80
Reader = Callable[[str], "bytes | None"]
# --------------------------------------------------------------------------
# 工件:原字节、解析、遮罩
# --------------------------------------------------------------------------
class _Store:
"""这一次构建里读过的工件:原字节、解析出来的内容、查询快照带不带遮罩。每份只读一次盘。"""
def __init__(self, read: Reader, source_masks: Mapping[str, Any]) -> None:
self._read = read
self._source_masks = source_masks
self._raw: dict[str, bytes | None] = {}
self._json: dict[str, Any] = {}
self._masked: dict[str, bool] = {}
def raw(self, artifact: str) -> bytes | None:
if artifact not in self._raw:
try:
data = self._read(artifact)
except (OSError, ValueError):
data = None
self._raw[artifact] = data if isinstance(data, (bytes, bytearray)) else None
return self._raw[artifact]
def content(self, artifact: str) -> Any:
"""解析出来的内容,读不出来、不是 JSON 的是 None。不看哈希:被改过的快照也要知道它记的遮罩。"""
if artifact not in self._json:
raw = self.raw(artifact)
try:
self._json[artifact] = json.loads(raw) if raw is not None else None
except ValueError:
self._json[artifact] = None
return self._json[artifact]
def intact(self, artifact: str) -> bool:
raw = self.raw(artifact)
return raw is not None and content_hash(bytes(raw)) == artifact
def masked(self, artifact: str) -> bool:
"""这份查询快照算不算带遮罩(整份不随文件附带、只给 id):查询当时记下了遮罩列(mask_columns),或者它的数据源
现在设了遮罩(合并查询看它合并的那几个数据源),而且证明不了这条查询没碰遮罩列(_untouched)。比证据面板粗:
面板只遮那几列,这里要么原字节整份附带、要么只给 id——拿掉几格哈希就对不上了;文件会离开系统,遮罩只减少暴露,
宁可多挡。读不出来的快照不知道带没带遮罩,按带遮罩算;服务端的文件和 id 对不上的,里面的 SQL、列名都不可信,
也按带遮罩算。"""
if artifact not in self._masked:
self._masked[artifact] = _snapshot_masked(self.content(artifact), self._source_masks,
intact=self.intact(artifact))
return self._masked[artifact]
def card_masked(self, artifact: str, events: Sequence[tuple[int, str, str | None, Any]]) -> bool:
"""这张口径卡算不算带遮罩:卡上有一个输入取自遮罩列,或者遮没遮核对不了。卡上记着每个输入的值(cell() 取的格、
Agent 字段、按位置取的 vars.res.rows[0][2])和代入式,整张卡不随文件附带——没法只拿掉几个值,拿掉了哈希就对不上。
认哪个输入取自哪份查询结果的哪几列,和证据面板、推演同一个认法(recompute.input_origin);按位置取的一格顺着
取值链从写变量的节点在口径卡算这一次之前的产出起走。核对不了的宁可多挡:快照读不出来、取值链上的产出读不出来、
那份结果没存快照只带着数据源名、定位里没写列。认得出、可结果既没带快照的 id 也没带数据源名的(数据整形改过形状)
认不出出处,不算。
卡本身按解析出来的内容认,不看哈希(同 content):服务端的文件被改过,嵌进页面的照样是这份原字节,卡上记着的号码
也照样在里面。读得出字节、解析不出一张卡的(截断的 JSON、不是对象)认不出有哪些输入,整张按带遮罩算,和读不出来的
快照按带遮罩算(_snapshot_masked)是同一个道理。字节都读不出来的卡本来就不嵌(只给 id、标缺失),这里不管。"""
key = f"card:{artifact}"
if key not in self._masked:
card = self.content(artifact)
if not isinstance(card, dict):
self._masked[key] = self.raw(artifact) is not None
return self._masked[key]
outputs = _card_outputs(events, artifact)
def output(node_id: Any) -> Any:
found = outputs.get(str(node_id or ""))
return self.content(found) if found and self.intact(found) else None
items = [item for metric in card.get("metrics") or [] if isinstance(metric, dict)
for item in metric.get("inputs") or [] if isinstance(item, dict)]
self._masked[key] = any(self._input_masked(item, output) for item in items)
return self._masked[key]
def _input_masked(self, item: dict[str, Any], output: Callable[[Any], Any]) -> bool:
origin = input_origin(item, output)
if origin is None:
return False
if origin.unverified is not None:
return True
if origin.artifact is None:
return origin.source is not None # 没存快照:查询当时记下的遮罩无从核对
content = self.content(origin.artifact)
if not self.intact(origin.artifact) or not isinstance(content, dict):
return True
if not origin.columns:
return self.masked(origin.artifact)
hidden = _snapshot_hidden(content, self._source_masks)
return any(c.lower() in hidden for c in origin.columns)
def _snapshot_masked(content: Any, source_masks: Mapping[str, Any], *, intact: bool) -> bool:
if not isinstance(content, dict):
return True
recorded = content.get("mask_columns")
sources = content.get("sources") if content.get("source") == MERGE_SOURCE else [content.get("source")]
if not (isinstance(recorded, list) and any(isinstance(c, str) and c.strip() for c in recorded)) \
and not any(source_masks.get(str(name or "")) for name in sources or []):
return False
return not (intact and _untouched(content, _snapshot_hidden(content, source_masks)))
def _snapshot_hidden(content: dict[str, Any], source_masks: Mapping[str, Any]) -> set[str]:
"""一份读得出来的快照要遮的列(小写):查询当时记下的,加上它的数据源(合并查询看它合并的那几个)现在设的。"""
recorded = content.get("mask_columns")
hidden = {c.lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set()
sources = content.get("sources") if content.get("source") == MERGE_SOURCE else [content.get("source")]
for name in sources if isinstance(sources, list) else []:
now = source_masks.get(str(name or ""))
hidden |= {str(c).lower() for c in now} if isinstance(now, (list, tuple, set, frozenset)) else set()
return hidden
# --------------------------------------------------------------------------
# 设了遮罩的数据源上的查询:能不能证明没碰遮罩列
# --------------------------------------------------------------------------
#: 数据源查询工具(tools/datasource.py)落的查询快照里会有的键。有别的键的(比如核查 agent 在查询时就把遮罩列换成
#: 「已遮罩」、另记下了遮了哪些列)不知道它记着什么,照现有规则只给 id
_QUERY_KEYS = frozenset({"columns", "rows", "row_count", "truncated", "elapsed_ms", "sql", "column_types", "source",
"checks", "schema_artifact", "mask_columns", "data_version"})
#: 导出时只知道数据源设的遮罩、不知道是哪种库:SQL 按每种支持的库的读法各解析一遍(None 是 sqlglot 的通用读法),
#: 至少一种读得通,读得通的每一种都要过关
_READINGS = (None, "sqlite", "mysql", "postgres", "oracle")
#: 语法树和数据库实际执行的对不上的写法:MySQL 会执行的注释(/*! … */,解析器当注释扔掉),Postgres 按 Unicode 转义
#: 写的名字(U&"…",原文里看不出是哪一列)
_OPAQUE_SQL = re.compile(r'/\*!|u&"', re.I)
def _untouched(snapshot: dict[str, Any], hidden: set[str]) -> bool:
"""数据源设了遮罩的一份查询快照,能不能证明这条查询没碰遮罩列(快照里不会有遮罩值)。全部满足才算,能证明的照常
随文件附带:
- 认得这份快照:数据源查询工具落的那种(不是合并查询,没有不认识的键),SQL、列名、行都在;
- 结果的列名对不上遮罩列(按数据库交回的列名,不论大小写);
- SQL 原文里没写到遮罩列,条件里、COUNT 里也不行:SQL 随快照一起附带,WHERE phone = '…' 的字面量就是遮罩值。
连字符串、注释里写到的也算(宁可多挡);语法树认不准的写法(_OPAQUE_SQL)也不行;
- SQL 解析得了;每种读得通的读法下,没有认不出结果里哪一列取自哪一列的写法(_stray_columns:列名表、整行引用、
可能展开出遮罩列的 *),按 SQL 认遮罩列的那一套(inquiry._derived_hidden,双盲复算、审批质询用的同一个)也认不出
哪个结果列取自遮罩列。SQL 里没写到遮罩列时它本来就认不出,这一条是同一套认法的第二道关,那一套以后认得更多时
跟着收紧。
判断过程出错按碰了算。和证据面板一样只按列名认遮罩:视图里换了名字的遮罩列认不出(遮罩只减少暴露,不是安全边界)。
hidden 是要遮的列(小写,_snapshot_hidden)。"""
names = {name.strip() for name in hidden if name.strip()}
sql, columns = snapshot.get("sql"), snapshot.get("columns")
if not names or snapshot.get("source") == MERGE_SOURCE or not set(snapshot) <= _QUERY_KEYS \
or not isinstance(sql, str) or not sql.strip() or not isinstance(snapshot.get("rows"), list) \
or not isinstance(columns, list) or not all(isinstance(c, str) for c in columns):
return False
result = {c.strip().lower() for c in columns}
if result & names or _writes_any(sql, names) or _OPAQUE_SQL.search(sql):
return False
try:
from app.data.sql_semantics import _parse, _read
from app.engine.inquiry import _derived_hidden # inquiry 导入了 engine.nodes 里的模块:用到时再导入
readings = [(kind, tree) for kind in _READINGS for tree in [_parse(sql, _read(kind))[0]] if tree is not None]
return bool(readings) and not any(
_stray_columns(tree) or result & {c.lower() for c in _derived_hidden(dict(snapshot), set(names), kind)[0]}
for kind, tree in readings)
except Exception: # noqa: BLE001 - 解析器、认遮罩列的那一套出了意外:按碰了遮罩列算(宁可多挡)
logger.warning("可验证报告:判断查询快照有没有碰遮罩列时出错,按碰了处理", exc_info=True)
return False
def _writes_any(sql: str, names: Iterable[str]) -> bool:
"""SQL 原文里有没有写到这些名字:不论大小写,前后不是字母、数字、下划线(引号、括号、点都算隔开)。"""
return any(re.search(rf"(?<![0-9A-Za-z_]){re.escape(n)}(?![0-9A-Za-z_])", sql, re.I) for n in names if n)
def _stray_columns(tree: Any) -> bool:
"""语法树里有没有认不出结果里哪一列取自哪一列的写法。有的话,就算 SQL 里没写到遮罩列、结果的列名也对不上,结果里
照样可能是遮罩列的值:
- 列名表(members AS m(a, b, c)、(…) AS t(a, b)、WITH t(a, b)):按位置给列改了名;
- 整行引用(Postgres 的 SELECT m、to_json(m),_whole_row):一整行装进一列;
- 可能展开出遮罩列的 *:展开了数据源里的表(隔着几层派生来源也算,_star_reaches),又不是只按名字往外传的那几种
(_contained)。COUNT(*) 只数个数,不算。"""
from sqlglot import exp
if any(alias.columns for alias in tree.find_all(exp.TableAlias)):
return True
if any(_whole_row(column) for column in tree.find_all(exp.Column)):
return True
memo: dict[int, bool] = {}
return any(_star_reaches(node, memo) and not _contained(node) for node in _stars(tree))
def _sources(select: Any) -> list[Any]:
"""一条 SELECT 的 FROM、JOIN 里的来源节点。"""
from_ = select.args.get("from_")
out = [from_.this] if from_ is not None and from_.this is not None else []
return out + [join.this for join in select.args.get("joins") or [] if join.this is not None]
def _derived(source: Any) -> Any:
"""FROM、JOIN 里的一个来源是派生来源(子查询、LATERAL、指向 WITH 里一段的名字)时,它的那块查询;数据源里的表、表函数、
VALUES 这类是 None(有哪些列、装着什么,SQL 里看不出)。"""
from sqlglot import exp
from app.data.sql_semantics import _visible_ctes
node = source.this if isinstance(source, exp.Lateral) else source
if isinstance(node, exp.Table):
if node.args.get("db") or node.args.get("catalog") or not isinstance(node.this, exp.Identifier):
return None
cte = _visible_ctes(node).get(node.name.lower())
if cte is None:
return None
node = cte.this
while isinstance(node, exp.Subquery):
node = node.this
return node if isinstance(node, (exp.Select, exp.SetOperation)) else None
def _leaves(block: Any) -> list[Any]:
"""一块查询的每一支,按先后:UNION 这类摊开,套在外面的括号去掉。"""
from sqlglot import exp
if isinstance(block, exp.Subquery):
return _leaves(block.this)
if isinstance(block, exp.SetOperation):
return _leaves(block.this) + _leaves(block.expression)
return [block]
def _whole_row(column: Any) -> bool:
"""一个没写表名的名字是不是整行引用(Postgres 的 SELECT m、to_json(m):一整行装进一列)。Postgres 先按列认:所在
SELECT 的派生来源明写出了叫这个名字的列,就是那一列;不然对上它看得见的某个来源的名字(别名、表名、WITH 里的一段)
就算。看得见的是所在 SELECT 和外层 SELECT 的 FROM、JOIN 里的来源;WITH 里的一段、FROM 里的子查询看不见同一层的来源
(LATERAL 看得见)。数据源里的表有哪些列这里不知道:碰巧和表、别名同名的列也算(宁可多挡)。"""
from sqlglot import exp
name = column.name.lower()
select = column.find_ancestor(exp.Select)
if column.table or isinstance(column.this, exp.Star) or not name or select is None:
return False
for body in filter(None, map(_derived, _sources(select))):
first = _leaves(body)[0]
if isinstance(first, exp.Select) and any(
isinstance(item, (exp.Alias, exp.Column)) and not isinstance(item.this, exp.Star)
and item.alias_or_name.lower() == name for item in first.expressions):
return False
child, node = column, column.parent
while node is not None:
sees = child.arg_key not in ("with_", "from_", "joins") or isinstance(child.this, exp.Lateral)
if isinstance(node, exp.Select) and sees \
and any((source.alias_or_name or "").lower() == name for source in _sources(node)):
return True
child, node = node, node.parent
return False
def _stars(tree: Any) -> list[Any]:
"""语法树里的 *(t.* 取包着它的 Column),COUNT(*)、COUNT(t.*) 不算:只数个数。"""
from sqlglot import exp
nodes = [star.parent if isinstance(star.parent, exp.Column) else star for star in tree.find_all(exp.Star)]
return [node for node in nodes if not isinstance(node.parent, exp.Count)]
def _star_reaches(node: Any, memo: dict[int, bool]) -> bool:
"""这个 *(t.* 是 Column 包着的 *)展开了数据源里的表没有:t.* 看 FROM、JOIN 里叫 t 的那一个来源,单独的 * 看所在
SELECT 的全部来源。数据源里的表、表函数、VALUES 这类来源都算展开了;派生来源看它每一支选出项里的 * 展开了没有
(隔几层都一样)。t 对不上任何来源(外层的别名)、找不到所在的 SELECT,按展开了算。"""
from sqlglot import exp
select = node.find_ancestor(exp.Select)
if select is None:
return True
sources = _sources(select)
if isinstance(node, exp.Column):
sources = [s for s in sources if (s.alias_or_name or "").lower() == node.table.lower()]
if not sources:
return True
for source in sources:
block = _derived(source)
if block is None:
return True
if id(block) not in memo:
# 先记成展开了:递归的 WITH 绕回自己时按展开了算
memo[id(block)] = True
memo[id(block)] = any(not isinstance(leaf, exp.Select) or any(
_star_reaches(star, memo) for item in leaf.expressions for star in _stars(item))
for leaf in _leaves(block))
if memo[id(block)]:
return True
return False
def _contained(node: Any) -> bool:
"""展开出来的列只按名字往外传的 *:它就是一条 SELECT 的选出项本身(不套在表达式里),这条 SELECT 不在 UNION 这类
里,而且是 FROM / JOIN 里的派生来源、LATERAL、WITH 里的一段(外层只能按名字取它的列:写到遮罩列的名字原文里就有,
再经 * 展开它又会被那个 * 认出来),或者 EXISTS / IN 里的子查询(只决定有没有、在不在)。最外层的 *(结果就是它
展开的列)、UNION 里的、标量子查询里的都不算。"""
from sqlglot import exp
select = node.parent
if not isinstance(select, exp.Select) or node.arg_key != "expressions":
return False
outer = select
while isinstance(outer.parent, exp.Subquery):
outer = outer.parent
parent = outer.parent
if isinstance(parent, (exp.CTE, exp.Lateral, exp.Exists)):
return True
if isinstance(parent, (exp.From, exp.Join)):
return outer is not select
return isinstance(parent, exp.In) and outer.arg_key == "query"
def _card_outputs(events: Sequence[tuple[int, str, str | None, Any]], card: str) -> dict[str, str]:
"""算出这份指标集的那一次口径卡执行开始之前,每个节点最后一次的产出工件:卡上 vars.*、nodes.* 取到的就是它们
(和证据接口 api/evidence._card_outputs 同一个认法)。认不出是哪一次执行时取封存范围内每个节点最后一次的产出。"""
found = next(((seq, node_id) for seq, etype, node_id, data in events
if etype == EventType.NODE_FINISHED and isinstance(data, dict)
and any(isinstance(e, dict) and e.get("kind") == "metric_set" and e.get("artifact") == card
for e in data.get("evidence") or [])), None)
bound = None
if found is not None:
bound = max((seq for seq, etype, node_id, _ in events
if etype == EventType.NODE_STARTED and node_id == found[1] and seq < found[0]), default=found[0])
out: dict[str, str] = {}
for seq, etype, node_id, data in events:
if bound is not None and seq >= bound:
break
if etype == EventType.NODE_FINISHED and node_id and isinstance(data, dict) and data.get("artifact"):
out[node_id] = str(data["artifact"])
return out
def _query_artifacts(events: Iterable[tuple[int, str, str | None, Any]]) -> list[str]:
"""封存范围内交回过的查询快照:tool.end / merge.end 的 query_artifact,节点台账里的 query 条目。"""
out: list[str] = []
for _, etype, _, data in events:
if not isinstance(data, dict):
continue
if etype in (EventType.TOOL_END, EventType.MERGE_END) and isinstance(data.get("query_artifact"), str):
out.append(data["query_artifact"])
elif etype == EventType.NODE_FINISHED:
out += [str(e["artifact"]) for e in data.get("evidence") or []
if isinstance(e, dict) and e.get("kind") == "query" and e.get("artifact")]
return list(dict.fromkeys(a for a in out if a))
_UNSEEN = object()
def _query_returned(data: Mapping[str, Any], store: _Store) -> tuple[str, str | None]:
"""一次数据源查询(db_query__ 工具的 tool.end)交回了什么:("snapshot", 查询快照 id);("none", None) 没交回数据
(查询失败、SQL 被拒的那句原话);认不出来是 ("unknown", None)。
新运行在 query_artifact 里直接记了快照 id。没记的有三种:查询没成功;升级前的运行(证据台账之前,tool.end 只有
工具快照,查询快照的 id 嵌在工具快照记下的返回里,照证据接口 _legacy_pool 的办法认);协作团队成员的查询(连工具
快照都不落,只能看事件里的预览,长一点的返回被截断,解析不了)。"""
artifact = data.get("query_artifact")
if isinstance(artifact, str) and artifact:
return "snapshot", artifact
returned: Any = _UNSEEN
snapshot = data.get("artifact")
# 工具快照要哈希对得上、记的是同一个工具:被改过的不能拿来说这次查询没有遮罩
if isinstance(snapshot, str) and snapshot and store.intact(snapshot):
content = store.content(snapshot)
if isinstance(content, dict) and content.get("tool") == data.get("tool") and "result" in content:
returned = content["result"]
if returned is _UNSEEN:
preview = data.get("preview")
if not isinstance(preview, str):
return "unknown", None
returned = preview
if preview.startswith('"'):
# 调用工具节点的预览是把返回值再 json.dumps 一遍(文本外面套了一层引号),截断了就剥不开
try:
returned = json.loads(preview)
except ValueError:
return "unknown", None
fields = query_entry_fields(returned)
if fields:
return "snapshot", str(fields["artifact"])
# 数据源工具成功时交回的是 JSON 对象;不是对象的是一句原话,对象里没有 rows 的是报错
if isinstance(returned, str) and not returned.lstrip().startswith("{"):
return "none", None
if isinstance(returned, dict) and "rows" not in returned:
return "none", None
return "unknown", None
def _masked_run(events: Sequence[tuple[int, str, str | None, Any]], store: _Store, cited: Iterable[str],
source_masks: Mapping[str, Any]) -> bool:
"""运行里有没有哪次查询的结果可能带着遮罩列。有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希。
宁严勿宽:封存范围内每一次数据源查询都要对得上一份没有遮罩的查询快照,或者确实没交回数据,才算没有。数据源
现在设了遮罩的,按工具名就知道,不必去认快照;认不出查了什么的,按带遮罩算。"""
known = [*_query_artifacts(events), *cited]
for _, etype, _, data in events:
if etype != EventType.TOOL_END or not isinstance(data, dict):
continue
tool = str(data.get("tool") or "")
if not tool.startswith(QUERY_PREFIX):
continue
if source_masks.get(tool[len(QUERY_PREFIX):]):
return True
kind, artifact = _query_returned(data, store)
if kind == "unknown":
return True
if artifact:
known.append(artifact)
return any(store.masked(a) for a in dict.fromkeys(known))
# --------------------------------------------------------------------------
# 报告文档和它引用到的工件
# --------------------------------------------------------------------------
def _report_events(events: Iterable[tuple[int, str, str | None, Any]]) -> list[dict[str, Any]]:
"""每个报告节点最后一次自查记下的文档,按第一次出现的顺序。和证据接口(api/evidence._reports)同一个认法:
报告重写过的,认最后一次。"""
latest: dict[str, dict[str, Any]] = {}
for seq, etype, node_id, data in events:
if etype == EventType.REPORT_CHECKED and node_id and isinstance(data, dict) and data.get("doc_artifact"):
latest[node_id] = {"node_id": node_id, "doc_artifact": str(data["doc_artifact"]), "seq": seq}
return list(latest.values())
def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
"""报告正文引用到的口径卡和查询快照:(工件 id, 种类, 来源节点),按正文顺序、去重。
看每个解析成功的引用(行内的数字、单元格,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡,
查询条目指向快照。整表在组装时已经展开成每格一个单元格引用,不用另看。"""
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
out: list[tuple[str, str, str | None]] = []
for _, unit in iter_units(doc):
if not isinstance(unit, dict):
continue
cites = [s.get("cite") for s in unit.get("segments") or [] if isinstance(s, dict)]
for cite in [*cites, *(unit.get("see") or [])]:
if not isinstance(cite, dict) or cite.get("status") != "resolved":
continue
entry = catalog.get(cite.get("alias"))
kind = _CITED_KINDS.get(entry.get("kind")) if isinstance(entry, dict) else None
if kind and isinstance(entry.get("artifact"), str) and entry["artifact"]:
owner = entry.get("node_id")
out.append((entry["artifact"], kind, str(owner) if owner else None))
return list(dict.fromkeys(out))
class _Artifacts:
"""嵌进页面的工件清单,按先后:报告文档在前,接着是它引用到的。同一份只列一次。events 是封存范围内的事件
(认口径卡带不带遮罩时找写变量的节点当时的产出)。"""
def __init__(self, store: _Store, events: Sequence[tuple[int, str, str | None, Any]] = ()) -> None:
self.store = store
self.events = events
self.items: list[dict[str, Any]] = []
self._seen: set[str] = set()
def add(self, artifact: str, kind: str, node_id: str | None) -> None:
if artifact in self._seen:
return
self._seen.add(artifact)
item: dict[str, Any] = {"id": artifact, "kind": kind}
if node_id:
item["node_id"] = node_id
raw = self.store.raw(artifact)
if raw is None:
# 服务端的文件不在了:只给 id,浏览器照实说核对不了
item["missing"] = True
elif (kind == "query" and self.store.masked(artifact)) \
or (kind == "metric_set" and self.store.card_masked(artifact, self.events)):
item["masked"] = True
# 不嵌内容,浏览器就算不出这份文件对不对得上:服务端自己看到的照实带上,核对页据此判不通过、说清原因。
# 以前被改过的卡原样嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢
if not self.store.intact(artifact):
item["mismatch"] = True
if not isinstance(self.store.content(artifact), dict):
item["unparsable"] = True
else:
# 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上
item["b64"] = base64.b64encode(bytes(raw)).decode("ascii")
self.items.append(item)
# --------------------------------------------------------------------------
# 事件
# --------------------------------------------------------------------------
def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mapping[str, str], store: _Store,
masked_run: bool) -> bool:
"""这条封存范围内的事件带不带规范字符串(白名单)。"""
if etype in _SHOWN_EVENTS:
return True
if etype == EventType.RUN_FINISHED:
# 运行结束事件里是成果:字段可以直接引用查询结果,运行里有带遮罩的快照时只给哈希
return not masked_run
if etype == EventType.NODE_FINISHED:
kind = node_types.get(node_id or "")
return kind in _SHOWN_NODES or (kind == NodeType.OUTPUT and not masked_run)
if etype == EventType.TOOL_END:
# 查询类:把查询快照的 id 和运行记录连起来。预览里是查询结果的前一截,数据源带遮罩的只给哈希
artifact = data.get("query_artifact") if isinstance(data, dict) else None
return isinstance(artifact, str) and bool(artifact) and not store.masked(artifact)
return False
def _clip(text: Any, limit: int = 60) -> str:
flat = re.sub(r"\s+", " ", str(text or "")).strip()
return flat if len(flat) <= limit else flat[:limit] + "…"
def _summary(etype: str, data: Any) -> str:
"""封存之后追加的事件说一句是什么。只挑不会带出数据的字段:判了几句、问的是什么、日志那一句。"""
if not isinstance(data, dict):
return ""
if etype == EventType.EVIDENCE_JUDGED:
verdicts = data.get("verdicts")
count = len(verdicts) if isinstance(verdicts, dict) else 0
model = data.get("model")
return f"按需裁判 {count} 句结论" + (f"(模型 {_clip(model, 40)})" if isinstance(model, str) and model else "")
if etype == EventType.APPROVAL_INQUIRY:
question = data.get("question")
return f"质询:{_clip(question)}" if isinstance(question, str) and question.strip() else "审批质询"
if etype == EventType.LOG and isinstance(data.get("message"), str):
return _clip(data["message"])
return ""
def _tier(events: Sequence[tuple[int, str, str | None, Any]]) -> dict[str, Any] | None:
"""出具档位:封存范围内最后一条出具判定事件里的 tier,带上序号(浏览器拿那条已核对的事件对照)。"""
for seq, etype, _, data in reversed(events):
if etype == EventType.ISSUANCE and isinstance(data, dict) and isinstance(data.get("tier"), str):
return {"value": data["tier"], "seq": seq}
return None
def _iso(value: Any) -> str | None:
if value is None:
return None
return value.isoformat() if isinstance(value, datetime) else str(value)
# --------------------------------------------------------------------------
# 构建
# --------------------------------------------------------------------------
def display_name(run: Mapping[str, Any]) -> str:
"""页面标题、附件名用的运行名:工作流名;没保存的工作流一律叫「未保存的工作流」。"""
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 的名字。"""
ascii_id = re.sub(r"[^A-Za-z0-9]", "", run_id)[:8]
return f"attachment; filename=\"agentlab-{ascii_id}-bundle.html\"; filename*=UTF-8''{quote(filename, safe='')}"
def build_bundle(
run: Mapping[str, Any],
events: Sequence[tuple[int, str, str | None, Any]],
*,
manifest_seq: int,
manifest_hash: str,
read: Reader,
exported_at: str,
legacy: bool = False,
graph: Mapping[str, Any] | None = None,
source_masks: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""一次已封存运行 → 页面里的数据(bundle-data)。
- run:运行记录的几样(id、workflow_id、workflow_name、version、version_hash、run_class、status、
started_at、finished_at)
- events:这次运行的全部事件 (seq, type, node_id, data),按数据库里 seq 的顺序。和封存核对
(runner.verify_manifest)读的是同一个顺序:seq 相同的几条,浏览器按这里的先后排,清单才算得一样
- manifest_seq / manifest_hash:封存范围(老运行是按结束事件推算的那个,legacy 为真)和封存哈希
- read:工件 id → 原字节(不复验哈希),没有就是 None
- source_masks:数据源名 → 它现在设的遮罩列(只列设了的)
"""
graph = graph if isinstance(graph, Mapping) else {}
node_types: dict[str, str] = {}
nodes: dict[str, dict[str, str]] = {}
for item in graph.get("nodes") or []:
if isinstance(item, Mapping) and item.get("id"):
nid = str(item["id"])
data = item.get("data") if isinstance(item.get("data"), Mapping) else {}
node_types[nid] = str(item.get("type") or "")
nodes[nid] = {"label": str(data.get("label") or nid), "type": node_types[nid]}
sealed = [e for e in events if e[0] <= manifest_seq]
later = [e for e in events if e[0] > manifest_seq]
store = _Store(read, source_masks or {})
artifacts = _Artifacts(store, sealed)
reports = _report_events(sealed)
cited_queries: list[str] = []
for report in reports:
doc_id, node_id = report["doc_artifact"], report["node_id"]
artifacts.add(doc_id, "report_doc", node_id)
doc = store.content(doc_id) if store.intact(doc_id) else None
# 文档要真是这次运行里这个节点写的:内容寻址只保证没被改,不保证没被张冠李戴。对不上的照实嵌进去让
# 浏览器标出来,但它的目录不可信,不顺着它去找别的工件
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)
if kind == "query":
cited_queries.append(artifact)
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
shown_events = []
for seq, etype, node_id, data in sealed:
text = canonical_json(data)
item: dict[str, Any] = {"seq": seq, "type": etype, "node_id": node_id, "data_sha256": content_hash(text)}
if _shows_content(etype, node_id, data, node_types, store, masked_run):
item["canonical"] = text
shown_events.append(item)
return {
"schema": SCHEMA,
"meta": {
"run_id": run.get("id"), "workflow_id": run.get("workflow_id"), "workflow_name": display_name(run),
"version": run.get("version"), "version_hash": run.get("version_hash"),
"run_class": run.get("run_class"), "status": run.get("status"),
"started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")),
"manifest_seq": manifest_seq, "manifest_hash": manifest_hash, "legacy": bool(legacy),
"tier": _tier(sealed), "exported_at": exported_at,
},
"nodes": nodes,
"events": shown_events,
"appended": [{"seq": seq, "type": etype, "node_id": node_id, "summary": _summary(etype, data)}
for seq, etype, node_id, data in later],
"reports": reports,
"artifacts": artifacts.items,
}
# --------------------------------------------------------------------------
# 页面
# --------------------------------------------------------------------------
#: 数据放在 <script type="application/json"> 里:< > & 换成转义,正文里的 </script>、<!-- 截不断数据块;
#: U+2028 / U+2029 在老的 JS 解析器里是换行,一并转义。这几个字只会出现在 JSON 的字符串里,转义后 JSON 不变
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
"\u2028": "\\u2028", "\u2029": "\\u2029"})
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|NAME|SEAL|RUN|FAILED|CSP|DATA|SCRIPT)__")
@lru_cache(maxsize=1)
def _assets() -> tuple[str, str]:
here = Path(__file__).parent
return (here / "template.html").read_text(encoding="utf-8"), (here / "verifier.js").read_text(encoding="utf-8")
def bundle_json(bundle: Mapping[str, Any]) -> str:
"""页面数据块里的 JSON 文本。"""
return json.dumps(bundle, ensure_ascii=False, separators=(",", ":")).translate(_JSON_ESCAPES)
def render_html(bundle: Mapping[str, Any]) -> str:
"""把数据和核对脚本填进模板:样式、脚本都内联,零外链。
内容安全策略按哈希只放行这一段核对脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
工作流名、封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览和系统的快速查看常常不执行脚本,也要看得到
该拿什么去系统里对照。脚本执行后拿数据里的封存哈希核对,再核一遍这里写出的和它一致。失败结束的运行另写一句:
之后在系统中继续运行会重新封存,这个文件就再也对不上了。
占位符一次替换完,填进去的内容不会再被当成占位符。"""
template, script = _assets()
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
meta = bundle.get("meta") or {}
name = str(meta.get("workflow_name") or UNSAVED_NAME)
values = {"TITLE": html.escape(f"{name} · 可验证报告"), "NAME": html.escape(name),
"SEAL": html.escape(str(meta.get("manifest_hash") or "")), "RUN": html.escape(str(meta.get("run_id") or "")),
"FAILED": "" if meta.get("status") == "failed" else " hidden",
"CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle), "SCRIPT": script}
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)