mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
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 *、函数包着、只在条件里、整行引用、列名表、解析不了、改过的、不认识的键、合并查询、判断出错;每一条规则都做过 去掉实现测试变红的验证。
760 lines
42 KiB
Python
760 lines
42 KiB
Python
"""可验证报告的构建:把一次已封存运行的报告、证据和运行记录打成一个自包含的 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)
|