期 5 验收修复 N1:取自遮罩格的口径卡指标不进报告文档和可验证报告;显示文字核对堵住紧挨着改字的绕过

验收第二轮发现:口径卡 phone_raw = cell(q_cust, 0, 'phone') 被 [[m:phone_raw]] 引用时自查不拦,号码写进正文;写作目录、
文档的证据目录收着上游口径卡的全部指标和原值;可验证报告里口径卡的节点完成事件(预览逐条写着「指标(名称)= 值」)一律
附规范串,没有报告的运行也照样带出号码;另外只改紧挨着被引用数字的普通正文(前面补一位数、加负号、后面接「0 万」)、重算
全部哈希,读者看到的是另一个数,核对页只标无法核对,总览仍是绿的。

报告撰写节点
- 写作之前按证据面板指标步骤的那一套认出值取自遮罩格的指标(api/evidence.py 的 withheld_metrics → _metric_masks,
  recompute.withheld_value):cell() 取的遮罩格、改了名字的遮罩列、按位置取的一格一行、整份结果里带着遮罩值的。写作
  目录、存进文档的证据目录里这些指标的值、渲染、标签都写「已遮罩」并记 masked(evidence.withhold_metrics,没被引用的
  也一样),写作目录另说不要引用
- 只按出处认(by_value=False):认不出出处、值碰巧等于遮罩列某一格的普通指标(写死的目标 20000)不算,不然它在报告里
  成了「已遮罩」、引用它的正式运行判失败、写作者也改不掉(自查第 3 轮去掉按值认是同一个道理);证据面板展示上照旧多遮
- 引用这类指标照样解析、正文写「已遮罩」(换算不再算),自查记 masked_cell(evidence.masked_metrics),和引用遮罩格同一
  套处置:交回重写,仍不改按「违规处置」办;缺口、日志、判失败的下一步说「指标」
- 写成「已遮罩」的别名记进节点产出(masked_metrics),出口契约复核按同一份重建目录(report_catalog),正文里的「已遮罩」
  对得上,不说文档被改过;写作时按上游状态建目录,循环里重跑不沿用上一次记下的那份
- 证据面板点开这一处:值、渲染都是「已遮罩」,按文档写的比渲染,不报渲染不一致

可验证报告(bundle/build.py、verifier.js)
- 口径卡的节点完成事件:卡只给 id(有输入取自遮罩列,或者有指标的值不给)的只给哈希,和报告引不引用无关;没有台账的
  老运行按预览里的 artifact、节点产出工件认,都读不出来的按运行里有没有带遮罩的快照
- 卡只给 id 的判断另按指标认(_Store.metric_word,和写作目录同一个判断):整行、下标不是字面量的一格、整份结果转成的
  文字,以前只认字面量下标的一格,卡原样嵌了进去
- 证据目录里还写着这类原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给哈希、不附带它引用的工件,
  报告节点的完成事件也只给哈希;快照读不出来时按它的数据源现在设的遮罩认,卡读不出来时按这次运行查到过的遮罩值认。
  核对页照实说明原因(中性色),这份报告算未能核对,总览不是绿的;文档里写成「已遮罩」的指标所在的卡一律只给 id
- 被引用的数字紧挨着(中间没有空白、标点)数字、负号、小数点判不通过,点名「正文里紧挨着这个数的字改变了读者看到的
  数」;排在看不见、改变方向的字前面判,同一句里再放一个这种字也降不成无法核对。服务端组装的文档两个数之间补了空格,
  原样导出照旧全绿(开发数据里 39 份报告、505 处引用,没有一处紧挨着)

测试
- 新增 tests/test_report_masked_metrics.py(目录、自查、重写、标注 / 失败、出口契约、证据面板、循环)、
  tests/test_bundle_masked_metrics.py(没有报告的运行、新文档、老文档连同核对页、普通卡、和写作目录同一个判断、手搭的
  几条分支);样本页 masked-card 改成现在的报告撰写节点写的文档,新增 masked-old-doc;check-bundle 补数字前加数字、加
  负号、后面接「0 万」三种篡改,各自变红、点名,原有的紧挨着补位、连字符两条改成不通过,报告文档标成遮罩那条改成总览
  不是绿的
- test_report_masked_values 的 [[m:phone_raw]] 改成按出处拦、写死的热线号码照常引用;几处按旧的报告正文(「13位」
  「11位」「26位」)找片段的测试改成按引用找(test_evidence_metric_masks、test_evidence_masked_aliases、
  test_whatif_masked_values),断言照新的正文写
This commit is contained in:
ThearchyHelios
2026-10-12 17:23:27 +08:00
parent 652576bab3
commit abb2707904
18 changed files with 1428 additions and 154 deletions
+85 -12
View File
@@ -20,7 +20,8 @@ artifacts 表可以事后插行,run.output 是可以改写的一列,封存
查询步骤只给被引用的行和前后各 2 行,完整快照仍走 /api/artifacts/{id}。数据源
options.mask_columns 里的列换成「已遮罩」,SQL 里给它们起了别名、套了函数得来的结果列也换
(_mask_sets,和可验证报告同一个函数,合并查询另按合并 SQL 认);用到这些列的指标,指标步骤的值、
证据图的标签也不给(_metric_masks,和推演同一个判断);SQL、合并警告里写着的遮罩值(这次运行里确定取自遮罩列的值)
证据图的标签也不给(_metric_masks,和推演同一个判断;报告撰写节点写作之前按同一个判断把这类指标在目录里写成「已遮罩」,
withheld_metrics);SQL、合并警告里写着的遮罩值(这次运行里确定取自遮罩列的值)
展示时换掉。一份快照的遮罩分析、收到的值按快照工件记在进程里(_MEMO),之后的请求不再读盘、不再分析
——在有身份体系之前,这只减少暴露,不是安全边界:知道工件 id 的人照样能取到整份快照。
@@ -46,7 +47,7 @@ import re
import sqlite3
import threading
from collections import OrderedDict
from collections.abc import Callable, Iterator
from collections.abc import Callable, Iterable, Iterator
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import Any, get_args
@@ -877,8 +878,12 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
metric_id = (cite.get("locator") or {}).get("metric")
card, hash_ok = _load_card(artifact) if artifact else (None, False)
metric = _metric_in(card, metric_id)
# 报告撰写节点把这个指标的值换成了「已遮罩」(文档的证据目录里记着 masked):正文写的就是目录里那个字,按它比渲染,
# 不拿卡上的原值去比(比了只会报渲染不一致)
written_masked = bool(entry.get("masked"))
try:
rendered_now = render_metric(metric, cite.get("conv")) if metric else None
rendered_now = str(entry.get("rendered") or "") if written_masked \
else render_metric(metric, cite.get("conv")) if metric else None
except RenderError:
rendered_now = None
source = metric or entry
@@ -928,10 +933,11 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
# 代入式里那几处同样写「已遮罩」(遮没遮核对不了的写「无法核对」):不然标签里遮着、代入式里亮着,同一个值换个
# 地方照样看得到。整份结果当输入的不换:代入式里本来就只留着它的路径
step["substituted"] = recompute.substituted_without(metric or {}, withheld)
if step["value"] is not None and word is not None:
if step["value"] is not None and (word is not None or written_masked):
# 指标的值本身也不给,和推演的原值同一个判断(_metric_masks):指标可能就是遮罩列的那一格(cell(nodes.q, 0,
# 'phone')),输入里遮着、这里亮着等于没遮。报告上的字(rendered)是读者已经看到的,照旧
step["value"] = word
# 'phone')),输入里遮着、这里亮着等于没遮。报告上的字(rendered)是读者已经看到的,照旧——写作时就认出来的,
# 目录、正文里本来就是「已遮罩」。文档记着不给、这里却认不出的(数据源事后撤了遮罩),照文档不给
step["value"] = word or MASKED
queries = [s for a, cells in wanted.items() for s in await _query_steps(a, cells, doc, sealed, masks)]
return [step, *inputs, *queries]
@@ -973,17 +979,22 @@ def _held_word(held: _Held) -> str:
async def _metric_masks(metric: dict[str, Any], card_node: str, artifact: str, sealed: _Sealed,
masks: _Masks) -> tuple[dict[str, _Held], str | None]:
masks: _Masks, *, output: Callable[[Any], Any] | None = None, by_value: bool = True
) -> tuple[dict[str, _Held], str | None]:
"""一个指标的输入里哪些不给值(路径 → _Held),指标本身的值给不给(None 照给,否则是写在值那里的字:「已遮罩」
「无法核对」)。证据面板的指标步骤、证据图的标签都按这一个函数认,和推演同一套分类、同一个判断
(recompute.withheld_value):
「无法核对」)。证据面板的指标步骤、证据图的标签、报告撰写节点的写作目录(withheld_metrics)都按这一个函数认,和推演
同一套分类、同一个判断(recompute.withheld_value):
- 输入取自哪份结果的哪几列按 _InputMasks 认(recompute.input_origin 的 wide:下标不是字面量的、整行、整份结果都认)
- 认不出出处的输入(数据整形算出来再赋给变量的一格),值就是这次运行查到过的遮罩值的,按值认(masks.run_secrets);
运行输入是发起运行的人自己填的,不认
- 指标的值:取自遮罩列的一格、一行的不给;最外层的输入遮没遮核对不了的写「无法核对」;整份结果当输入、有认不出出处
的输入的,值里有遮罩值才不给(len(vars.res.rows) 照给)"""
hide = _InputMasks(sealed, masks, card_node, artifact)
的输入的,值里有遮罩值才不给(len(vars.res.rows) 照给)
output(节点 id → 那个节点的产出)给了的,按位置取的一格顺着它走取值链,不去封存的事件里找口径卡算这一次之前的产出:
报告撰写节点写作时运行还没封存,产出就在运行状态里。by_value 为假(报告撰写节点的写作目录):认不出出处的输入不按值认,
也不因此按值认指标的值——只按出处认,见 withheld_metrics。"""
hide = _InputMasks(sealed, masks, card_node, artifact, output=output)
held: dict[str, _Held] = {}
uncertain = False
for item in metric.get("inputs") or []:
@@ -991,6 +1002,8 @@ async def _metric_masks(metric: dict[str, Any], card_node: str, artifact: str, s
continue
path = str(item.get("path"))
found, traced = await hide.check(item)
if found is None and not traced and not by_value:
continue
if found is None and not traced:
uncertain = True
value = item.get("value")
@@ -1009,6 +1022,54 @@ async def _metric_masks(metric: dict[str, Any], card_node: str, artifact: str, s
return held, {"masked": MASKED, "unverified": UNVERIFIED_VALUE}.get(word or "")
async def withheld_metrics(catalog: dict[str, Any], *, nodes: dict[str, Any], ledger: list[Any]) -> list[str]:
"""报告撰写节点写作之前:目录里值取自遮罩格的指标(别名,按目录的先后),交给 evidence.withhold_metrics 换成「已遮罩」。
和证据面板指标步骤的值、推演的原值同一个判断(_metric_masks 判「已遮罩」的:cell() 取的遮罩格、取自改了名字的遮罩列、
按位置取自遮罩列的一格、一行,整份结果里带着遮罩列、值里就有这次运行查到过的遮罩值的)。以前写作目录、文档的证据目录
原样写着这些值,证据面板写「已遮罩」,同一个号码从报告正文和可验证报告里出去了。
只按出处认(by_value=False):认不出出处的输入(写死的目标、数据整形算出来的数)不因为值碰巧等于遮罩列里的某一格就
算取自遮罩列。证据面板为了展示按值多遮一层,这里跟着多算,「单笔目标 20,000元」这种普通指标就在报告里成了「已遮罩」,
引用它的正式运行判失败、写作者也改不掉(报告自查第 3 轮去掉按值认是同一个道理)。数据整形把号码取出来再赋给变量的,
出处认不出,这里照给(已知的缺口,同证据面板对改过形状的结果)。这时运行还没封存,不看事件:
- 卡上的指标取自运行状态里口径卡节点的产出(nodes,和台账里那份指标集是同一份内容),按位置取的一格顺着运行状态里的
产出走取值链(口径卡算的时候读到的就是它们)
- 查询快照的数据源按证据台账认(ledger 的 query 条目),这次运行查到过的遮罩值从台账里的查询快照收
遮没遮核对不了的(「无法核对」)不算:写作时快照和产出刚落下,读不出来的只能是坏了的文件,宁可照给、不让写作者因此
改不掉;可验证报告那边另有兜底(bundle/build.py 只给哈希)。"""
queries = {str(e["artifact"]): {k: e[k] for k in ("node_id", "tool", "source", "via", "call_id", "exec")
if e.get(k) is not None}
for e in ledger if isinstance(e, dict) and e.get("kind") == "query" and e.get("artifact")}
view = _Sealed(run_id="", graph={}, events=[], queries=queries,
seal={"sealed": False, "ok": None, "manifest_seq": None, "legacy": False})
masks = _Masks(view)
found: list[tuple[str, str, str, dict[str, Any]]] = []
for alias, entry in catalog.items():
if not isinstance(entry, dict) or entry.get("kind") != "metric" or entry.get("value") is None:
continue
card_node = str(entry.get("node_id") or "")
card = nodes.get(card_node)
metric = _metric_in(card if isinstance(card, dict) else None, (entry.get("locator") or {}).get("metric"))
if isinstance(metric, dict) and metric.get("inputs"):
found.append((alias, card_node, str(entry.get("artifact") or ""), metric))
if not found:
return []
# cell()、Agent 字段落在的快照先在线程里读好(读盘、复验哈希):在运行里认,不占着事件循环
await masks.prefetch(str(item["artifact"]) for *_, metric in found for item in metric.get("inputs") or []
if isinstance(item, dict) and isinstance(item.get("artifact"), str) and item["artifact"])
def output(node_id: Any) -> Any:
return nodes.get(str(node_id or ""))
out: list[str] = []
for alias, card_node, artifact, metric in found:
_, word = await _metric_masks(metric, card_node, artifact, view, masks, output=output, by_value=False)
if word == MASKED:
out.append(alias)
return out
#: tool_cell、Agent 字段落在的查询快照读不出来:只知道数据源现在设的遮罩,查询当时记下的在快照里,遮没遮核对不了
SNAPSHOT_UNVERIFIED = "查询快照无法读取,核对不了这一格是否取自设置了遮罩的列"
@@ -1029,10 +1090,13 @@ class _InputMasks:
节点那几 MB 的产出重读十几遍;证据图给每个指标认一遍输入,也不必把同一份快照反复读盘。
"""
def __init__(self, sealed: _Sealed, masks: _Masks, card_node: str, artifact: str) -> None:
def __init__(self, sealed: _Sealed, masks: _Masks, card_node: str, artifact: str, *,
output: Callable[[Any], Any] | None = None) -> None:
self._sealed, self._masks = sealed, masks
self._card, self._artifact = card_node, artifact
self._outputs: dict[str, str] | None = None
# 报告撰写节点写作时(withheld_metrics):节点的产出就在运行状态里,不从封存的事件里找
self._given = output
def _fetch(self, artifact: str) -> tuple[Any, bool | None]:
return self._masks.fetch(artifact)
@@ -1060,6 +1124,8 @@ class _InputMasks:
return None, True
def _output(self, node_id: Any) -> Any:
if self._given is not None:
return self._given(node_id)
if self._outputs is None:
self._outputs = _card_outputs(self._sealed, self._card, self._artifact)
artifact = self._outputs.get(str(node_id or ""))
@@ -1391,6 +1457,13 @@ class _Masks:
self._read[artifact] = _fetch(artifact)
return self._read[artifact]
async def prefetch(self, artifacts: Iterable[str]) -> None:
"""先在线程里把这几件工件取回来(_fetch:读盘、复验哈希),之后 fetch 直接用。报告撰写节点在运行里认遮罩
(withheld_metrics),几 MB 的快照不该占着事件循环。"""
todo = [a for a in dict.fromkeys(artifacts) if a and a not in self._read]
if todo:
self._read.update(await asyncio.to_thread(lambda: {a: _fetch(a) for a in todo}))
async def sets(self, artifact: str | None, source: Any, snap: dict[str, Any] | None) -> _MaskSets:
"""一份查询快照的遮罩(_mask_sets),一次请求里同一份(按工件和数据源认)只认一次。snap 是这件工件读出来的内容,
读不出来的为 None。"""
+199 -23
View File
@@ -6,14 +6,18 @@
sha256(canonical_json(data)),和 runner 封存时算 manifest_hash 用的是同一个算法,浏览器按同样的拼法重算
清单哈希、和封存哈希比。白名单里的事件另带服务端算好的规范字符串 canonical(逐字等于 canonical_json(data)),
浏览器先核它的哈希,再从里面读出报告文档、口径卡、查询快照的 id。其余事件只给哈希:工具返回、节点产出的
预览里可能有遮罩列的原值和提示词。
预览里可能有遮罩列的原值和提示词。口径卡、报告的节点完成事件在白名单里,可它们的预览就是卡上每个指标的值、报告正文:
卡只给 id 的(和报告引不引用无关)、文档只给 id 的,这两条也只给哈希(_card_shown)。
- 工件:每份报告文档(各报告节点最后一次自查记下的 doc_artifact)、它引用到的口径卡和查询快照,按原字节
base64 嵌入,浏览器重算 sha256 和 id 比。数据源设了遮罩的查询快照,能证明这条查询没碰遮罩列的(_untouched)照常
嵌入,别的不嵌内容、只给 id。口径卡上记着每个输入的值和代入式,有输入取自遮罩列(cell() 的格、Agent 字段、按位置
取的 vars.res.rows[0][2],SQL 里改了名字的遮罩列也算,SQL 认不出结果里哪一列取自哪一列的整份快照的列都算)的整张卡
同样只给 id,认法和证据面板、推演同一个(recompute.input_origin);服务端文件被改过的卡照样按卡上记的输入认,解析
不出一张卡的整张只给 id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,
核对页照实说(对不上的判不通过)。没被报告引用的工件不嵌。
同样只给 id,认法和证据面板、推演同一个(recompute.input_origin);有指标的值不给的(metric_word:值取自遮罩列的
一格、一行,整行、下标不是字面量的也算,和报告撰写节点的写作目录同一个判断)也一样;服务端文件被改过的卡照样按卡上
记的输入认,解析不出一张卡的整张只给 id。报告文档的证据目录收着上游口径卡的全部指标,现在的报告撰写节点把值不给的
写成「已遮罩」;还写着原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给 id、不附带它引用的工件,
文档里写成「已遮罩」的指标所在的口径卡一律只给 id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析
不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被报告引用的工件不嵌。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次
@@ -41,7 +45,7 @@ from app.core.events import EventType
from app.engine.evidence import iter_units, query_entry_fields
from app.engine.masking import OPAQUE_SQL, SYSTEM_SCHEMAS, indirect_reads
from app.engine.merge_query import MERGE_SOURCE
from app.engine.recompute import input_origin
from app.engine.recompute import carries_masked, input_origin, withheld_value
from app.engine.schema import NodeType
from app.engine.toolcalls import QUERY_PREFIX
@@ -53,8 +57,8 @@ SCHEMA = "agentlab.bundle/1"
#: 口径卡升版处置、出具判定(档位)
_SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, EventType.CALIBER_UPGRADE,
EventType.ISSUANCE})
#: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文)、口径卡(指标集的 id 记在台账里)
_SHOWN_NODES = frozenset({NodeType.REPORT, NodeType.METRICS})
#: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文,文档只给哈希的不带)、口径卡(指标集的 id
#: 记在台账里;卡上有值不给的指标、有输入取自遮罩列的不带,见 _card_shown)。成果节点在运行里没有带遮罩的快照时带
#: 目录条目的种类 → 嵌进来的工件种类。运行输入、检索、表和字段的条目不嵌:前者没有工件,后几样不在核对范围里
_CITED_KINDS = {"metric": "metric_set", "query": "query"}
@@ -89,6 +93,9 @@ class _Store:
self._masked: dict[str, bool] = {}
self._hidden: dict[str, set[str]] = {}
self._seen: Any = None
self._words: dict[tuple[str, int], str | None] = {}
self._docs: dict[str, bool] = {}
self._sources: dict[str, str] | None = None
def raw(self, artifact: str) -> bytes | None:
if artifact not in self._raw:
@@ -176,11 +183,108 @@ class _Store:
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)
metrics = [metric for metric in card.get("metrics") or [] if isinstance(metric, dict)]
items = [item for metric in metrics for item in metric.get("inputs") or [] if isinstance(item, dict)]
# 另按指标认:值不给的指标(metric_word,和报告撰写节点的写作目录同一个判断)——按位置取的整行、下标不是字面量
# 的一格,整份结果里带着遮罩列、值里就是号码的,上面只认字面量下标一格的那一套认不出,卡上记着的值照样是号码
self._masked[key] = any(self._input_masked(item, output) for item in items) \
or any(self.metric_word(artifact, metric, events) is not None for metric in metrics)
return self._masked[key]
def metric_word(self, card: str, metric: dict[str, Any], events: Sequence[tuple[int, str, str | None, Any]]
) -> str | None:
"""口径卡 card 上一个指标的值给不给:None 照给;"masked" 不给(值取自遮罩列的一格、一行,或者整份结果里带着遮罩列、
值里就有这次运行查到过的遮罩值);"unverified" 遮没遮核对不了。和报告撰写节点的写作目录同一个判断(api/evidence.py
的 _metric_masks,by_value=False:输入的出处按 recompute.input_origin 的 wide 认,最后交给 recompute.withheld_value),
只是遮罩列按这份文件自己的认法(hidden;快照读不出来、和 id 对不上的,按它的数据源现在设的遮罩),遮罩值用这次运行
查到过的(seen)。只按出处认:认不出出处的输入(写死的目标、数据整形算出来的数)值碰巧等于遮罩列里的某一格,不因此
算——不然普通报告里的这种指标在文件里也核对不了,老文档也整份只给哈希。"""
key = (card, id(metric))
if key not in self._words:
outputs = _card_outputs(events, card)
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
masked: list[str] = []
unverified: list[str] = []
uncertain = False
for item in metric.get("inputs") or []:
if not isinstance(item, dict):
continue
path = str(item.get("path"))
kind, _ = self._input_kind(item, output)
if kind == "masked":
masked.append(path)
elif kind == "unverified":
unverified.append(path)
uncertain = uncertain or kind == "table"
self._words[key] = withheld_value(metric, metric.get("value"), masked=masked, unverified=unverified,
uncertain=uncertain, secrets=self.seen)
return self._words[key]
def _input_kind(self, item: dict[str, Any], output: Callable[[Any], Any]) -> tuple[str | None, bool]:
"""(一个输入不给值的原因, 认不认得出出处),同 api/evidence.py 的 _InputMasks.check:取自遮罩列的一格、一行是
"masked",整份结果是 "table"(它不一定把哪一格带进指标,指标的值另按值认),遮没遮核对不了的是 "unverified"
(取值链上的产出读不出来、快照读不出来、结果没存快照只带着数据源名),给值的是 None。认不出出处的(常量、运行输入、
数据整形算出来的一格)第二项为假。"""
origin = input_origin(item, output, wide=True)
if origin is None:
return None, False
if origin.unverified is not None:
return "unverified", True
used = list(dict.fromkeys(origin.columns))
if not used:
return None, True
readable = bool(origin.artifact) and self.intact(str(origin.artifact)) \
and isinstance(self.content(str(origin.artifact)), dict)
if readable:
hidden = self.hidden(str(origin.artifact))
else:
# 读不出来、和 id 对不上的快照:查询当时记下的遮罩在快照里,只能按它的数据源现在设的认
source = origin.source or (self.query_source(origin.artifact) if origin.artifact else None)
now = self._source_masks.get(str(source or ""))
hidden = {str(c).lower() for c in now} if isinstance(now, (list, tuple, set, frozenset)) else set()
if any(c.lower() in hidden for c in used):
return ("table" if origin.table else "masked"), True
if (origin.artifact and not readable) or (not origin.artifact and origin.source):
return ("table" if origin.table else "unverified"), True
return None, True
def query_source(self, artifact: str) -> str | None:
"""一份查询快照的数据源名,按封存范围内的事件认(_query_sources):快照读不出来时认它的数据源现在设的遮罩。"""
if self._sources is None:
self._sources = _query_sources(self._events)
return self._sources.get(artifact)
def doc_withheld(self, doc_id: str) -> bool:
"""这份报告文档的证据目录里还带着值不给的指标的原值:升级前的报告撰写节点生成的老文档(或者生成之后数据源才设的
遮罩),目录收着上游口径卡的全部指标(没被引用的也收),值、渲染、标签原样写着,正文引用了的话正文里也是。整份只给
哈希、不嵌入,它的正文也不随节点完成事件带出去。现在的报告撰写节点把这些指标写成「已遮罩」、记 masked
(evidence.withhold_metrics),不算。
哪些指标的值不给按 metric_word 认,只算「不给」("masked")的,和写作时报告撰写节点认的一样;遮没遮核对不了的、
目录里的口径卡读不出来或找不到这个指标的,按值认:值就是这次运行查到过的遮罩值才算(seen)。按解析出来的内容认,
不看哈希(同 card_masked):服务端的文件被改过,嵌进页面的照样是这份原字节。"""
if doc_id not in self._docs:
doc = self.content(doc_id)
catalog = doc.get("catalog") if isinstance(doc, dict) else None
self._docs[doc_id] = isinstance(catalog, dict) and any(
self._carries_withheld(entry) for entry in catalog.values()
if isinstance(entry, dict) and entry.get("kind") == "metric" and not entry.get("masked")
and entry.get("value") is not None)
return self._docs[doc_id]
def _carries_withheld(self, entry: dict[str, Any]) -> bool:
artifact = entry.get("artifact")
card = self.content(artifact) if isinstance(artifact, str) and artifact else None
wanted = (entry.get("locator") or {}).get("metric") if isinstance(entry.get("locator"), dict) else None
metric = next((m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") == wanted), None) \
if isinstance(card, dict) else None
word = self.metric_word(str(artifact), metric, self._events) if metric is not None else "unverified"
return word == "masked" or (word == "unverified" and carries_masked(entry.get("value"), self.seen()))
def _input_masked(self, item: dict[str, Any], output: Callable[[Any], Any]) -> bool:
origin = input_origin(item, output)
if origin is None:
@@ -604,6 +708,25 @@ def _card_outputs(events: Sequence[tuple[int, str, str | None, Any]], card: str)
return out
def _query_sources(events: Iterable[tuple[int, str, str | None, Any]]) -> dict[str, str]:
"""封存范围内每份查询快照的数据源名:节点台账 query 条目记下的 source,数据源查询工具(db_query__<数据源>)交回的
快照按工具名认。快照读不出来时按它认数据源现在设的遮罩(同证据面板按台账认的 sealed.queries)。"""
out: dict[str, str] = {}
for _, etype, _, data in events:
if not isinstance(data, dict):
continue
if etype == EventType.TOOL_END and isinstance(data.get("query_artifact"), str):
tool = str(data.get("tool") or "")
if tool.startswith(QUERY_PREFIX) and tool[len(QUERY_PREFIX):]:
out.setdefault(data["query_artifact"], tool[len(QUERY_PREFIX):])
elif etype == EventType.NODE_FINISHED:
for entry in data.get("evidence") or []:
if isinstance(entry, dict) and entry.get("kind") == "query" and entry.get("artifact") \
and isinstance(entry.get("source"), str) and entry["source"]:
out[str(entry["artifact"])] = entry["source"]
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] = []
@@ -742,7 +865,9 @@ class _Artifacts:
self.items: list[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, *, masked: bool = False) -> None:
"""masked:调用方另认出不能附带的(报告文档的证据目录里带着值不给的指标的原值;报告文档里写成「已遮罩」的指标
所在的口径卡),只给 id。"""
if artifact in self._seen:
return
self._seen.add(artifact)
@@ -753,7 +878,7 @@ class _Artifacts:
if raw is None:
# 服务端的文件不在了:只给 id,浏览器照实说核对不了
item["missing"] = True
elif (kind == "query" and self.store.masked(artifact)) \
elif masked or (kind == "query" and self.store.masked(artifact)) \
or (kind == "metric_set" and self.store.card_masked(artifact, self.events)):
item["masked"] = True
# 不嵌内容,浏览器就算不出这份文件对不对得上:服务端自己看到的照实带上,核对页据此判不通过、说清原因。
@@ -774,8 +899,11 @@ class _Artifacts:
def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mapping[str, str], store: _Store,
masked_run: bool) -> bool:
"""这条封存范围内的事件带不带规范字符串(白名单)。"""
masked_run: bool, events: Sequence[tuple[int, str, str | None, Any]] = (),
withheld: frozenset[str] = frozenset(), forced: frozenset[str] = frozenset()) -> bool:
"""这条封存范围内的事件带不带规范字符串(白名单)。events 是封存范围内的事件(认口径卡带不带遮罩时用),withheld
是最后一份文档只给哈希的报告节点(_Store.doc_withheld),forced 是报告文档里写成「已遮罩」的指标所在的口径卡
(_masked_cards)。"""
if etype in _SHOWN_EVENTS:
return True
if etype == EventType.RUN_FINISHED:
@@ -783,7 +911,14 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi
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 kind == NodeType.METRICS:
return _card_shown(data, store, masked_run, events, forced)
if kind == NodeType.REPORT:
# 预览就是那份文档正文的前一截:文档只给哈希的,这条也只给哈希
preview = data.get("preview") if isinstance(data, dict) else None
doc = preview.get("doc_artifact") if isinstance(preview, dict) else None
return not (store.doc_withheld(doc) if isinstance(doc, str) and doc else (node_id or "") in withheld)
return kind == NodeType.OUTPUT and not masked_run
if etype == EventType.TOOL_END:
# 查询类:把查询快照的 id 和运行记录连起来。预览里是查询结果的前一截,数据源带遮罩的只给哈希
artifact = data.get("query_artifact") if isinstance(data, dict) else None
@@ -791,6 +926,39 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi
return False
def _card_shown(data: Any, store: _Store, masked_run: bool, events: Sequence[tuple[int, str, str | None, Any]],
forced: frozenset[str] = frozenset()) -> bool:
"""口径卡的节点完成事件带不带规范字符串。预览里逐条写着「指标(名称)= 值」(metrics 节点的 text),赋了变量的另有
变量的预览:卡上有值不给的指标、有输入取自遮罩列(card_masked,卡本身因此只给 id)的,这条也只给哈希——和报告引不
引用无关,没有报告的运行照样导出。指标集的 id 记在台账里(evidence 的 metric_set),升级前的运行没有台账,认预览里
记的 artifact,再没有就认节点产出工件(内容就是这张卡加上 text)。一张都读不出来的不知道带没带遮罩:运行里有带遮罩
的快照时只给哈希(同成果节点)。报告文档里写成「已遮罩」的指标所在的卡(forced)同样只给哈希:卡本身因此只给 id。
卡被报告引用时,报告文档的证据目录照样记着它的 id,引用链不靠这条事件。"""
if not isinstance(data, dict):
return not masked_run
cards = [str(e["artifact"]) for e in data.get("evidence") or []
if isinstance(e, dict) and e.get("kind") == "metric_set" and isinstance(e.get("artifact"), str)
and e["artifact"]]
preview = data.get("preview")
if not cards and isinstance(preview, dict) and isinstance(preview.get("artifact"), str) and preview["artifact"]:
cards = [preview["artifact"]]
if not cards and isinstance(data.get("artifact"), str) and data["artifact"]:
cards = [data["artifact"]]
if any(card in forced for card in cards):
return False
known = [card for card in dict.fromkeys(cards) if store.raw(card) is not None]
if not known:
return not masked_run
return not any(store.card_masked(card, events) for card in known)
def _masked_cards(doc: dict[str, Any]) -> set[str]:
"""报告文档的证据目录里写成「已遮罩」的指标(withhold_metrics 记了 masked)所在的口径卡。"""
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
return {str(e["artifact"]) for e in catalog.values() if isinstance(e, dict) and e.get("kind") == "metric"
and e.get("masked") and isinstance(e.get("artifact"), str) and e["artifact"]}
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] + "…"
@@ -891,16 +1059,24 @@ def build_bundle(
artifacts = _Artifacts(store, sealed)
reports = _report_events(sealed)
cited_queries: list[str] = []
# 证据目录还带着值不给的指标原值的老文档只给哈希,也不顺着它的目录去附带它引用的口径卡和快照(附带了,浏览器在
# 已核对的事件和文档里都找不到谁引用了它们)
withheld = frozenset(r["node_id"] for r in reports if store.doc_withheld(r["doc_artifact"]))
docs: list[tuple[dict[str, Any], dict[str, Any]]] = []
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
doc = store.content(report["doc_artifact"]) if store.intact(report["doc_artifact"]) 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 report["node_id"] not in withheld and isinstance(doc, dict) and doc.get("run_id") == run.get("id") \
and doc.get("node_id") == report["node_id"]:
docs.append((report, doc))
# 文档里写成「已遮罩」的指标所在的口径卡只给 id:报告撰写节点写作时认出值不给,这里的认法认不出的(数据源事后撤了
# 遮罩这类),卡上记着的原值也不该随文件出去
forced = frozenset().union(*(_masked_cards(doc) for _, doc in docs))
for report in reports:
artifacts.add(report["doc_artifact"], "report_doc", report["node_id"], masked=report["node_id"] in withheld)
for artifact, kind, owner in next((_cited_artifacts(doc) for r, doc in docs if r is report), []):
artifacts.add(artifact, kind, owner, masked=kind == "metric_set" and artifact in forced)
if kind == "query":
cited_queries.append(artifact)
@@ -909,7 +1085,7 @@ def build_bundle(
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):
if _shows_content(etype, node_id, data, node_types, store, masked_run, sealed, withheld, forced):
item["canonical"] = text
shown_events.append(item)
+1 -1
View File
@@ -56,7 +56,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.bad { color: var(--bad); }
.note { margin: 6px 0 12px; font-size: 13px; color: var(--dim); }
.warn { margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); font-size: 13px; }
.warn[data-why="missing"], .warn[data-why="unreadable"] { background: var(--na-soft); color: var(--na); }
.warn[data-why="missing"], .warn[data-why="unreadable"], .warn[data-why="masked"] { background: var(--na-soft); color: var(--na); }
.report { margin: 14px 0; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--raise); }
.report-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 10px; }
.report-head h3 { margin: 0; font-size: 15px; }
+41 -21
View File
@@ -13,12 +13,14 @@
* render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字,
* 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字
* 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过,
* 紧挨着还有数字、负号或显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对;
* 列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数;
* 紧挨着写着数字、负号、小数点(读者看到的是另一个数)的判不通过,紧挨着显示成什么说不准的字,或者同一句里有改变
* 文字方向的字,读起来可能不是这个值的判无法核对;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数;
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
* 绑不进封存,页面上写明它们取自运行记录。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩)
* 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、检索快照这类工件标着
* 遮罩)一律算不通过:只可能是文件被改过。报告文档的证据目录里写着取自遮罩列的指标的值时,服务端只给它的哈希
* (升级前生成的文档,或者生成之后数据源才设的遮罩),页面照实说明,这份报告算未能核对。这些只证明内部一致,真伪要拿
* 封存哈希去系统里对照,页面上写明。
*
* SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现,
* 两者结果一致。纯 JS 实现启动时先拿标准测试向量自检,自检不过就不下任何结论。
@@ -83,6 +85,8 @@
docUntrusted: '这份报告文档没有通过核对(见核对明细 ③ ④),正文仅供参考,其中的数字不予核对。',
docUnreadable: '这份报告文档的哈希一致,但内容无法解析,无法显示正文。',
docMissing: '导出时服务端已没有这份报告文档,无法显示正文,其中的数字也无法核对。',
docMasked: '这份报告文档的证据目录里写着取自遮罩列的指标的值(生成时没有按遮罩换掉),未随文件附带,无法显示正文,'
+ '其中的数字也无法核对。',
docAbsent: '文件里没有附带这份报告文档。',
checkEvents: '① 事件内容与哈希',
checkManifest: '② 封存清单',
@@ -116,7 +120,9 @@
masked: '含遮罩列,未随文件附带,只能核对到哈希',
maskedMismatch: '导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过;按含遮罩列处理,未随文件附带',
maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带',
maskedOdd: '只有查询快照和口径卡会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
/** 报告文档只给哈希:证据目录里写着值取自遮罩列的指标的原值(升级前的报告撰写节点生成的,或者生成之后数据源才设的遮罩) */
maskedDoc: '证据目录里写着取自遮罩列的指标的值(生成时没有按遮罩换掉),未随文件附带,只能核对到哈希',
maskedOdd: '只有查询快照、口径卡和报告文档会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
artifactMissing: '导出时服务端已没有这份工件的文件',
artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过',
chainDoc: (label) => `报告「${label}」的文档`,
@@ -129,6 +135,7 @@
chainDocNotListed: '文件的工件清单里没有这份报告文档:导出时不会出现这种情况,文件可能被改过',
chainDocUnverified: '文档内容没有通过核对,无法确认它记下的运行和节点',
chainDocMissing: '导出时服务端已没有这份报告文档,无法确认它记下的运行和节点',
chainDocMasked: '文档的证据目录里写着取自遮罩列的指标的值,未随文件附带,无法确认它记下的运行和节点',
chainDocUnparsable: '文档的哈希一致,但内容无法解析,无法确认它记下的运行和节点',
chainDocElsewhere: '文档记下的运行或节点与这次不符',
chainDocLeftOut: '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档',
@@ -174,7 +181,7 @@
shownOld: '这个浏览器版本较旧,请用新版浏览器打开本文件',
shownBroken: '本页按系统的显示规则处理这个值时出错',
/** 正文的排法(checkLayout):显示文字核对过了,读起来却不是这个值 */
shownGlued: '紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值',
numGlued: '正文里紧挨着这个数的字改变了读者看到的数:前后紧挨着的数字、负号或小数点会和它连成另一个数',
shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值',
shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样',
numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过',
@@ -208,7 +215,7 @@
shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致',
renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)'
+ '把口径卡或快照里的值(运行输入按引用的值)重新渲染一遍,与显示的文字逐字比对;核对通过的字在正文里原样显示,'
+ '紧挨着它还有数字、负号或看不见的字,或者同一句里有改变文字方向的字,判无法核对。',
+ '紧挨着它写着数字、负号或小数点的判不通过,紧挨着看不见的字或者同一句里有改变文字方向的字的判无法核对。',
schema: (s) => `文件格式 ${s}`,
}
@@ -876,10 +883,12 @@
* 每一项记下正文里显示出来的字(onPage)。
* - 正文里根本不显示它(分隔线里、表格里不在各行各列上的格):伪造的字可以写在别处,读的人看不到核对过的那个数。
* 服务端导出时不会出现(分隔线不带句子,表格的格都在行列上),判不通过;
* - 紧挨着的字里还有数字或负号(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置的字、显示时藏起来的排版
* 标记也算紧挨着),紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的
* 这几位,判无法核对——真报告里也可能有(放行的数字、日期紧贴着引用),不能说成被改过。两个数之间的连字符是区间,
* 不算负号。
* - 紧挨着的字里写着数字、负号或小数点(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置、显示成什么说不准
* 的字,显示时藏起来的排版标记也算紧挨着):读者看到的是另一个数,判不通过。伪造者不动核对过的那段字、只改旁边的
* 正文,重算全部哈希就做得到;以前判无法核对,总览照样写「可核对的项全部通过」。服务端组装的文档不这样排:两个数
* 之间补空格(evidence._glue),原样导出照样通过。两个数之间的连字符是区间,不算负号;
* - 紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的这几位,判无法核对——
* 真报告里也可能有,不能说成被改过。这两样不会把上一条的不通过降成无法核对。
* 代码块按原文显示、不标数字,照旧
*/
function checkLayout(doc, items) {
@@ -959,9 +968,13 @@
// 数字头尾紧挨着(或隔着一个逗号、句点)的是说不准的字:它本身就可能显示成数字、负号
const near = (a, b) => a.blurred || (a.kind === 'sep' && b.blurred)
const blurred = (lead && near(l1, l2)) || (tail && near(r1, r2))
if (turned || before || sign || after || blurred) {
const why = turned ? T.shownTurned : before || sign || after ? T.shownGlued : T.shownBlurred
Object.assign(item, { result: NA, reason: T.numShownUnknown(why) })
if (before || sign || after) {
// 紧挨着写着数字、负号、小数点:读者看到的是另一个数(伪造者不动核对过的字、只改旁边的正文,重算全部哈希就
// 做得到),判不通过。服务端组装的文档不这样排:两个数之间补了空格(evidence._glue)。排在说不准的字、改变
// 方向的字前面判:伪造者在同一句里再放一个这种字,不能把不通过降成无法核对
Object.assign(item, { result: FAIL, reason: T.numGlued })
} else if (turned || blurred) {
Object.assign(item, { result: NA, reason: T.numShownUnknown(turned ? T.shownTurned : T.shownBlurred) })
}
}
}
@@ -1095,18 +1108,22 @@
}
}
} else if (a.masked) {
// 查询快照:这条查询用到或可能用到设了遮罩的列;口径卡:卡上有输入取自遮罩列(卡上记着输入的值和代入式)。
// 两种都只给 id,报告文档不会。
// 查询快照:这条查询用到或可能用到设了遮罩的列;口径卡:卡上有输入取自遮罩列(卡上记着输入的值和代入式);
// 报告文档:证据目录里写着值取自遮罩列的指标的原值(升级前的报告撰写节点生成的,或者生成之后数据源才设的遮罩;
// 现在的报告撰写节点写成「已遮罩」)。
// 三种都只给 id,检索快照这类别的工件不会。
// 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来
// (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了
if (a.kind !== 'query' && a.kind !== 'metric_set') {
// (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了。报告文档
// 只给哈希时页面上没有正文可核,总览不是绿色的通过(renderOverall 按报告文档没核对上算)
if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc') {
item.result = FAIL
item.reason = T.maskedOdd
} else if (a.mismatch) {
item.result = FAIL
item.reason = T.maskedMismatch
} else {
item.reason = a.unparsable ? T.maskedUnparsable : a.kind === 'query' ? T.maskedQuery : T.masked
item.reason = a.unparsable ? T.maskedUnparsable : a.kind === 'query' ? T.maskedQuery
: a.kind === 'report_doc' ? T.maskedDoc : T.masked
}
} else if (a.missing) {
item.reason = T.artifactMissing
@@ -1143,7 +1160,8 @@
else if (!art) link.reason = T.chainDocNotListed
else if (art.result === FAIL) Object.assign(link, { result: NA, reason: T.chainDocUnverified })
else if (art.result !== PASS) {
Object.assign(link, { result: NA, reason: art.a.missing ? T.chainDocMissing : T.chainDocUnparsable })
Object.assign(link, { result: NA,
reason: art.a.masked ? T.chainDocMasked : art.a.missing ? T.chainDocMissing : T.chainDocUnparsable })
} else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) {
link.reason = T.chainDocElsewhere
} else link.result = PASS
@@ -1695,10 +1713,12 @@
]))
}
/** 报告文档没核对上的原因:{ key, text }。key 进 data-why,服务端已没有文件、读不懂的用中性色,不是红的 */
/** 报告文档没核对上的原因:{ key, text }。key 进 data-why,服务端已没有文件、读不懂、因遮罩不随文件附带的用中性色,
* 不是红的 */
function docWhy(item) {
if (!item.art) return { key: 'absent', text: T.docAbsent }
if (item.art.result === FAIL || (item.link && item.link.result === FAIL)) return { key: 'untrusted', text: T.docUntrusted }
if (item.art.a.masked) return { key: 'masked', text: T.docMasked }
if (item.art.a.missing) return { key: 'missing', text: T.docMissing }
return { key: 'unreadable', text: T.docUnreadable }
}
+74 -11
View File
@@ -48,7 +48,7 @@ import math
import re
import unicodedata
from collections import Counter
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Context, Decimal
from typing import Any, Literal, TypedDict
@@ -974,6 +974,26 @@ def build_catalog(
return catalog
def withhold_metrics(catalog: dict[str, Any], aliases: Iterable[str], word: str) -> dict[str, Any]:
"""目录里值不给的指标(aliases,指标的别名):值、渲染、标签里的值都换成 word(「已遮罩」),另记 masked。交回一份新的
目录,原来那份不动;没有要换的原样交回。
哪几个不给由报告撰写节点按证据面板指标步骤的那一套认(api/evidence.py 的 withheld_metrics,只按出处认:cell() 取的
遮罩格、取自改了名字的遮罩列、按位置取自遮罩列的一格都算,值碰巧等于遮罩值的普通指标不算)。写作目录、存进报告文档的
证据目录都从这份目录来:目录收着上游口径卡的全部指标,
没被引用的也在里面,原值写着就随文档、可验证报告出去了。引用它的照样解析、正文写 word(resolve_marker),报告自查另记
一条违规(masked_metrics)。出口契约复核按报告节点产出里记下的同一份别名重建(report_catalog),渲染出来对得上。"""
hidden = [a for a in dict.fromkeys(aliases) if (catalog.get(a) or {}).get("kind") == "metric"]
if not hidden:
return catalog
out = dict(catalog)
for alias in hidden:
entry = catalog[alias]
name = entry.get("name") or (entry.get("locator") or {}).get("metric") or alias
out[alias] = {**entry, "value": word, "rendered": word, "label": f"{name} = {word}", "masked": True}
return out
# --------------------------------------------------------------------------
# 实体:表和字段
# --------------------------------------------------------------------------
@@ -1592,13 +1612,18 @@ def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_all
if alias is None:
return _unresolved(marker, f"m:{ref}", ev_kind, why)
entry = catalog[alias]
if entry.get("value") is None:
if entry.get("masked"):
# 值不给的指标(withhold_metrics):照样解析,正文写目录里的那个字(「已遮罩」),换算不再算——它不是数,
# 换算了只会解析不了、原因说成「不是数值」。引用它本身由报告自查另记一条违规(masked_metrics)
rendered = str(entry.get("rendered") or "")
elif entry.get("value") is None:
return _unresolved(marker, alias, ev_kind, f"指标「{entry.get('name') or ref}」本次没有值(缺少输入),"
"无法写入报告", rendered=MISSING)
try:
rendered = render_metric(entry, conv)
except RenderError as e:
return _unresolved(marker, alias, ev_kind, str(e))
else:
try:
rendered = render_metric(entry, conv)
except RenderError as e:
return _unresolved(marker, alias, ev_kind, str(e))
else:
ev_kind, alias = "input", f"i:{ref}"
entry = catalog.get(alias)
@@ -2728,7 +2753,8 @@ def compose_doc(
[[t:]] / [[c:]] 解析不了,原因是 ENTITIES_OFF_REASON。
absence=True(写作目录有省略):另查缺失断言(absence_claims),和句中的 [[see:]] 一样只在组装时查。
masked({查询快照工件: 取自遮罩列的结果列},报告节点按数据源的遮罩算好交进来):另查直接引用了这些列的格子
(masked_cells),同样只在组装时查。没给的和以前一字不差。
(masked_cells),同样只在组装时查。没给的和以前一字不差。目录里值不给的指标(withhold_metrics 记了 masked 的)被引用
时也在这里记违规(masked_metrics),不靠这个参数:目录本身说了哪几个不给。
stats / violations 由 verify_doc 算出,和出口契约复核用的是同一个函数。
整表标记先展开成每格一个 [[v:]] 的 Markdown 表,之后和模型自己写的表格走同一条路;
@@ -2887,11 +2913,11 @@ def compose_doc(
}
checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps)
# 句中的 [[see:]]、目录有省略时的缺失断言只在组装时查(交回写作者重写):出口复核看的是数字和出处,
# 句子通不通、结论有没有越过证据不影响出具。引用了遮罩列的格子也在这里查:遮罩设在数据源上,出口复核按目录
# 重建、查不了库;仍不改的由报告节点写进出具缺口
# 句子通不通、结论有没有越过证据不影响出具。引用了遮罩列的格子、值不给的指标也在这里查:遮罩设在数据源上,出口复核
# 按目录重建、查不了库;仍不改的由报告节点写进出具缺口
doc["stats"], doc["violations"] = checked["stats"], \
checked["violations"] + inline_see(source) + (absence_claims(doc) if absence else []) \
+ masked_cells(doc, catalog, masked)
+ masked_metrics(doc, catalog) + masked_cells(doc, catalog, masked)
return doc
@@ -3417,6 +3443,40 @@ def masked_cells(doc: dict[str, Any], catalog: dict[str, Any], masked: dict[str,
return found
def _masked_metric_message(ref: str, name: str) -> _Reason:
"""引用了值不给的指标时违规 masked_cell 的说法:点名引用和指标,不写值。给写作者的那句同引用遮罩格:说清值不能以任何
写法写进报告,只说「删掉引用」的话写作者会把值照抄成文字(它在写作目录里本来就看不到原值)。"""
return _Reason(
f"引用了取自遮罩列的指标 [[{ref}]]:指标「{name}」的值取自设置了遮罩的列,值不应写进报告",
f"[[{ref}]] 引用的指标「{name}」的值取自设置了遮罩的列(证据目录里写着「已遮罩」)。设置了遮罩的列(手机号、"
"姓名这类)的值不能以任何形式写进报告,引用、数字、照抄的文字都不行:删掉这处引用和相应的内容",
)
def masked_metrics(doc: dict[str, Any], catalog: dict[str, Any]) -> list[dict[str, Any]]:
"""正文引用了值不给的指标(目录条目记着 masked,withhold_metrics):每处一条违规 masked_cell,和引用遮罩格同一种违规、
同一套处置——交回写作者重写,仍不改的按「违规处置」办(report.py)。依据 [[see:m:…]] 不显示值,不算。
值不给的是值取自遮罩列一格的指标(cell() 取的遮罩格、取自改了名字的遮罩列),按出处认、和证据面板指标步骤的值同一套,
由报告撰写节点认好写进目录;这里只认目录,查不了库。违规带着引用、指标 id、span 和所在片段,不带值(正文里那一处已经写成
「已遮罩」,违规会进 report.checked、重写的日志和出具缺口)。"""
found: list[dict[str, Any]] = []
for _, unit in iter_units(doc):
for seg in unit.get("segments") or []:
cite = seg.get("cite") or {}
entry = catalog.get(cite.get("alias")) or {}
if cite.get("kind") != "metric" or cite.get("status") != "resolved" or entry.get("kind") != "metric" \
or not entry.get("masked"):
continue
metric = str((entry.get("locator") or {}).get("metric") or cite.get("ref") or "")
ref = str(seg.get("ref") or f"m:{cite.get('ref')}")
span = seg.get("span")
found.append(_violation("masked_cell", _masked_metric_message(ref, str(entry.get("name") or metric)),
ref=ref, metric=metric, span=list(span) if isinstance(span, list) else None,
segment=seg.get("id"), unit=unit.get("id")))
return found
#: 引用查询结果的写法。只跟着目录里的查询出现(catalog_prompt):写作规则本身保持一期那份,升级前
#: 发起的运行跑到报告节点时提示一字不差;只有口径卡的目录也不该教写作者去引用不存在的 Q1
CELL_RULES = (
@@ -3605,7 +3665,10 @@ def _metric_prompt(catalog: dict[str, Any]) -> list[str]:
title = " @ ".join(v for v in (caliber, version) if v) or node_id
lines.append(f"\n口径卡「{title}」:")
for e in entries:
if e.get("value") is None:
if e.get("masked"):
# 值取自遮罩列的一格(withhold_metrics):写作者看到的就是「已遮罩」,引用了也只会交回重写
lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}(取自设置了遮罩的列,不要引用)")
elif e.get("value") is None:
lines.append(f"- [[{e['alias']}]] {e.get('name')}:这次没有值,不要引用")
elif e.get("rendered") == MISSING:
# 有值但按口径卡的格式显示不出来(太小、太大、不是有限的数):引用了也是解析不了
+56 -13
View File
@@ -4,7 +4,9 @@
数值回头猜——同值的指标一多就猜错,模型顺手写的数也要到出口才被发现。这里把核对
挪到写的那一刻:
1. 建目录:收齐上游口径卡的指标和运行输入,编成 [[m:gmv]]、[[i:week]] 这样的引用
1. 建目录:收齐上游口径卡的指标和运行输入,编成 [[m:gmv]]、[[i:week]] 这样的引用。值取自遮罩格的指标(cell() 取的
遮罩格、取自改了名字的遮罩列,和证据面板指标步骤的值同一个判断)在目录里写「已遮罩」:写作目录、存进文档的证据目录
都不写原值,引用了的照样交回重写(报告自查记 masked_cell)
2. 流式写作:模型写标记,StreamRenderer 边收边换成真值,流里看到的就是最终的数字
3. 核对:compose_doc 切块、分句、切片段,找出裸数字和解析不了的引用。模型把整篇包在一个 ```markdown 代码块
里的,先去掉外层围栏(_unfenced):不然整篇是一个代码块,一句结论都切不出来
@@ -60,6 +62,7 @@ from app.engine.evidence import (
compose_doc,
describe_violations,
ledger_enabled,
withhold_metrics,
writer_catalog,
)
from app.engine.nodes.llm import _invoke_streaming, _model_spec, _report_call
@@ -126,6 +129,11 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
出口契约复核必须用这一个函数重建:参数(metrics_from、evidence_from、祖先范围)
差一点,alias 集合就不一样——同一个指标 id 在两张卡里时,只收一张写 m:gmv,
全收就得写 m:east.gmv——复核会把一份好好的报告判成引用解析不了。
值取自遮罩格、写成「已遮罩」的指标(evidence.withhold_metrics)按这个报告节点产出里记下的认(masked_metrics):报告
节点写作之前按证据面板的判断认好(api/evidence.py 的 withheld_metrics,要查库、读快照,这里做不了),随产出记下;
出口契约复核拿同一份重建,正文里的「已遮罩」才对得上,不算文档被改过。报告节点写作时交进来的状态里没有它自己的产出
(run_report),拿到的是原样的目录。
"""
ledger = [e for e in state.get("evidence") or [] if isinstance(e, dict)]
sources = _setting(spec, node, "evidence_from", "ancestors")
@@ -146,9 +154,20 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
if report_entities(spec, node) == "off":
# 目录里没有表和字段,写作目录也就不列;compose / verify 另传 entities=False,解析不了的原因照实说
catalog = {alias: e for alias, e in catalog.items() if e.get("kind") not in ENTITY_KINDS}
recorded = (state.get("nodes") or {}).get(node.id)
withheld = recorded.get("masked_metrics") if isinstance(recorded, dict) else None
if isinstance(withheld, list) and withheld:
catalog = withhold_metrics(catalog, [str(a) for a in withheld], _masked_word())
return catalog
def _masked_word() -> str:
"""「已遮罩」:证据面板、推演同一个说法。api.evidence 导入了引擎里的模块,用到时再导入。"""
from app.api.evidence import MASKED
return MASKED
def report_fields(state: GraphState, spec: GraphSpec, node: GraphNode) -> list[dict[str, Any]]:
"""上游 Agent 经按出处核对交来的结构化字段:[{node_id, title, data, evidence}],按画布上的顺序。
@@ -321,7 +340,9 @@ class _RenderedStream:
class _MaskedCells:
"""报告自查:写作者引用了取自遮罩列的格子([[v:Q2.r0.tel]]),记一条违规 masked_cell,和没有出处的数字一样交回写作者
重写(evidence.masked_cells)。写作目录本身不遮(写作者要看得到数据),只拦引用。
重写(evidence.masked_cells)。写作目录里的查询结果本身不遮(写作者要看得到数据),只拦引用;口径卡指标不一样,值取自
遮罩格的在目录里就写成了「已遮罩」(文档的证据目录收着全部指标,原值写着就随文档出去了),引用它的组装文档时就记了
违规(evidence.masked_metrics),不在这里。
哪几列算按 api/evidence.py 的 _cited_masks 认:列名就是遮罩列的,SQL 里给遮罩列起了别名、套了函数、经子查询或 WITH
转一道的(_mask_sets 的 sure,和证据面板同一个函数);合并查询按逐格来历和合并 SQL 认。遮罩按列名在整个数据源上
@@ -374,9 +395,18 @@ class _MaskedCells:
def _masked_said(hits: list[dict[str, Any]]) -> str:
"""报告引用了遮罩列的那几处在缺口、日志里怎么说:「引用了 2 处取自遮罩列的格子(「v:Q2.r0.tel」、…)」。只点名引用,
不写值。"""
return f"引用了 {len(hits)} 处取自遮罩列的格子({_named(hits)})"
"""报告引用了遮罩列的那几处在缺口、日志里怎么说:「引用了 2 处取自遮罩列的格子(「v:Q2.r0.tel」、…)」,引用了值取自
遮罩格的指标的另说「指标」(违规上记着 metric)。只点名引用,不写值。"""
metrics = sum(1 for v in hits if v.get("metric"))
parts = [f"{n} 处取自遮罩列的{what}" for n, what in ((len(hits) - metrics, "格子"), (metrics, "指标")) if n]
return f"引用了 {'、'.join(parts)}({_named(hits)})"
def _masked_targets(hits: list[dict[str, Any]]) -> str:
"""去改写作指令时要去掉的是什么:引用了格子说「这些列」,引用了指标说「这些指标」,都有的两样都说。"""
metrics = any(v.get("metric") for v in hits)
cells = any(not v.get("metric") for v in hits)
return "这些列、指标" if metrics and cells else "这些指标" if metrics else "这些列"
async def _run_class(run_id: str) -> str | None:
@@ -444,11 +474,12 @@ def _fatal(blocking: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _fail_advice(fatal: list[dict[str, Any]]) -> str:
"""判失败时给人看的下一步。引用了遮罩列的格子多半是写作指令点名要的(「联系电话 [[v:Q2.r0.tel]]」),换模型没用;
改成「标注」照常产出,遮罩列的值就随报告出去了——这两条都不建议,只说去改写作指令。别的违规照旧建议换模型、先标注。"""
masked = sum(1 for v in fatal if v.get("code") == "masked_cell")
if masked == len(fatal):
return "数据源设置了遮罩的列不应写进报告,请在报告撰写节点的指令中去掉对这些列的引用"
if masked:
return "引用了遮罩列的那几处,请在报告撰写节点的指令中去掉对这些列的引用;其余几处可换用指令遵循能力更强的模型"
hits = [v for v in fatal if v.get("code") == "masked_cell"]
if hits and len(hits) == len(fatal):
return f"数据源设置了遮罩的列不应写进报告,请在报告撰写节点的指令中去掉对{_masked_targets(hits)}的引用"
if hits:
return (f"引用了遮罩列的那几处,请在报告撰写节点的指令中去掉对{_masked_targets(hits)}的引用;"
"其余几处可换用指令遵循能力更强的模型")
return (f"请换用指令遵循能力更强的模型,或先将「{field_label('on_violation')}」设为"
f"「{option_label('on_violation', 'flag')}」,查看标注后再调整")
@@ -595,8 +626,17 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
window = await model_window(session, _model_spec(ctx), model_id)
spec = ctx.run.spec
catalog = report_catalog(state, spec, ctx.node)
# 报告引用了取自遮罩列的格子:自查记一条违规,和没有出处的数字一样交回写作者重写(_MaskedCells)
# 目录按上游的产出建:这个节点自己上一次的产出(循环里重跑时)不算,它记下的 masked_metrics 这一次重新认
upstream = {**state, "nodes": {k: v for k, v in (state.get("nodes") or {}).items() if k != ctx.node.id}}
catalog = report_catalog(upstream, spec, ctx.node) # type: ignore[arg-type]
# 值取自遮罩格的指标(cell() 取的遮罩格、取自改了名字的遮罩列):写作目录、存进文档的证据目录里都写「已遮罩」,判断
# 和证据面板指标步骤的值同一个(api/evidence.py 的 withheld_metrics)。记进产出,出口契约复核按同一份重建目录
from app.api.evidence import withheld_metrics
withheld = await withheld_metrics(catalog, nodes=state.get("nodes") or {}, ledger=state.get("evidence") or [])
catalog = withhold_metrics(catalog, withheld, _masked_word())
# 报告引用了取自遮罩列的格子:自查记一条违规,和没有出处的数字一样交回写作者重写(_MaskedCells);引用了上面那几个
# 指标的,组装文档时就记了(evidence.masked_metrics)
masked = _MaskedCells(catalog)
allow = report_allowance(spec, ctx.node)
cells = True
@@ -704,7 +744,8 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
gaps.append(f"报告{_masked_said(hits)},{tried}:数据源设置了遮罩的列不应写进报告")
ctx.emit(EventType.LOG, level="warn", code="report_masked_cell", count=len(hits), refs=refs,
columns=list(dict.fromkeys(str(v["column"]) for v in hits if v.get("column"))),
message=f"{gaps[0]}。报告照常产出,这几处已标注,已记为缺口。请在报告撰写节点的指令中去掉对这些列的引用")
message=f"{gaps[0]}。报告照常产出,这几处已标注,已记为缺口。"
f"请在报告撰写节点的指令中去掉对{_masked_targets(hits)}的引用")
doc_artifact = await _store(doc, ctx)
ctx.emit(EventType.REPORT_CHECKED, doc_artifact=doc_artifact, ok=not doc["violations"],
stats=doc["stats"], violations=doc["violations"][:20], repairs=repairs,
@@ -735,6 +776,8 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
**({"judge": judgement} if judgement is not None else {}),
# 出口契约照单收下的缺口(只在有的时候带这个键:别的报告产出和以前一字不差)
**({"issuance_gaps": gaps} if gaps else {}),
# 写成「已遮罩」的指标:出口契约复核按它重建目录(report_catalog)。同上,只在有的时候带
**({"masked_metrics": withheld} if withheld else {}),
}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}, "usage": usage}
if ctx.cfg("emit_message", True):
+41 -13
View File
@@ -17,8 +17,11 @@
- continued:早期运行先失败、经继续运行跑到成功:范围里有那条运行失败,之后运行又继续了,成功结束的那条在范围外
- degraded:两条出具判定,先完整出具、后降档出具,认最后一条
- missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件
- masked-card:口径卡上一个指标的输入取自会员消费的号码列(设了遮罩),卡上记着号码原值:整张卡只给 id,引用它的
数字无法核对
- masked-card:口径卡上两个指标的输入取自会员消费的号码列(设了遮罩):联系电话位数、首位会员手机号(值就是号码),卡上
记着号码原值。整张卡只给 id,引用它的数字无法核对;卡的节点完成事件(预览里逐条写着「指标(名称)= 值」)只给哈希;
报告文档按现在的报告撰写节点写,证据目录里这两个指标写「已遮罩」,照常随文件附带
- masked-old-doc:同一张卡,报告文档是之前的报告撰写节点写的,证据目录里这两个指标写着原值:整份文档只给哈希、不附带它
引用的工件,报告的节点完成事件也只给哈希,页面照实说明原因
- masked-source:会员库设了号码遮罩(查询当时记下了,数据源现在也设着)。报告引用会员按地区汇总的查询(没碰遮罩列:
快照随文件附带,数字核对得过)和选出了号码的那份会员消费(只给哈希)
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
@@ -39,7 +42,7 @@ from typing import Any
from app.bundle.build import build_bundle, render_html
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
from app.engine.evidence import build_catalog, compose_doc
from app.engine.evidence import build_catalog, compose_doc, withhold_metrics
RUN_ID = "5a3c0f4b9d2e4e1f8b7a6c5d4e3f2a1b"
RUN = {"id": RUN_ID, "workflow_id": "wf-sample-weekly", "workflow_name": "门店周报", "version": 3,
@@ -116,14 +119,34 @@ def shop(nan: bool = False) -> dict[str, Any]:
"sql": "SELECT store, amount, ratio FROM orders ORDER BY id", "truncated": False}
#: 报告撰写节点把值取自遮罩格的指标写成的字(同 api/evidence.py 的 MASKED)
MASKED = "已遮罩"
def masked_card(crm_id: str) -> dict[str, Any]:
"""口径卡多一个指标:联系电话位数,输入是会员消费第 0 行的号码(phone 设了遮罩),卡上记着号码原值和代入式。"""
cell = "cell(nodes.members, 0, 'phone')"
"""口径卡多两个指标:联系电话位数、首位会员手机号,输入都是会员消费第 0 行的号码(phone 设了遮罩),卡上记着号码原值
和代入式;首位会员手机号的值就是号码。"""
cell, phone = "cell(nodes.members, 0, 'phone')", CRM["rows"][0][1]
read = {"path": cell, "value": phone, "node_id": "members", "via": "tool_cell", "artifact": crm_id,
"locator": {"row": 0, "column": "phone"}, "status": "verified"}
contact = {"id": "contact", "name": "联系电话位数", "value": 8, "unit": "位", "decimals": 0, "format": "thousands",
"expression": f"len({cell})", "substituted": f"len('{CRM['rows'][0][1]}')",
"inputs": [{"path": cell, "value": CRM["rows"][0][1], "node_id": "members", "via": "tool_cell",
"artifact": crm_id, "locator": {"row": 0, "column": "phone"}, "status": "verified"}]}
return {**CARD, "metrics": [*CARD["metrics"], contact]}
"expression": f"len({cell})", "substituted": f"len('{phone}')", "inputs": [dict(read)]}
first = {"id": "phone_raw", "name": "首位会员手机号", "value": phone, "unit": "", "decimals": None, "format": "plain",
"expression": cell, "substituted": f"'{phone}'", "inputs": [dict(read)]}
return {**CARD, "metrics": [*CARD["metrics"], contact, first]}
#: masked_card 上值不给的指标(和证据面板指标步骤的值同一个判断:输入取自遮罩格)
MASKED_METRICS = ("m:contact", "m:phone_raw")
def card_preview(card: dict[str, Any], artifact: str) -> dict[str, Any]:
"""口径卡节点完成事件的预览,照 metrics 节点产出的样子(compiler._preview):text 里逐条写着「指标(名称)= 值」。"""
lines = [f"- {m['id']}({m['name']})= {m['value']}{m.get('unit') or ''}" for m in card["metrics"]]
return {"kind": "metric_set", "caliber": card["caliber"], "caliber_version": card["caliber_version"],
"metrics": f"[{len(card['metrics'])} 项]",
"text": f"指标清单(口径 {card['caliber']} @ {card['caliber_version']}):\n" + "\n".join(lines),
"artifact": artifact}
class Sample:
@@ -165,8 +188,11 @@ class Sample:
raw = self.files.get(artifact)
return json.loads(raw) if raw is not None else None
def doc(self, text: str, node_id: str, run_id: str = RUN_ID) -> str:
return self.put(compose_doc(text, self.catalog, node_id=node_id, run_id=run_id, loader=self.load))
def doc(self, text: str, node_id: str, run_id: str = RUN_ID, *, masked: tuple[str, ...] = ()) -> str:
"""组装一份报告文档。masked:值不给的指标,照现在的报告撰写节点写成「已遮罩」(withhold_metrics);不给的是之前的
报告撰写节点写的样子(目录里原样写着值)。"""
catalog = withhold_metrics(self.catalog, masked, MASKED)
return self.put(compose_doc(text, catalog, node_id=node_id, run_id=run_id, loader=self.load))
def events(self, reports: dict[str, str], *, ending: str = "finished", tiers: tuple[str, ...] = ("formal",),
nan: bool = False) -> tuple[list[tuple[int, str, str | None, Any]], int]:
@@ -196,7 +222,7 @@ class Sample:
"query_artifact": self.entries["regions"]["artifact"],
"preview": json.dumps(REGIONS["rows"], ensure_ascii=False)})
add("node.finished", "regions", {"duration_ms": 8, "attempt": 1, "evidence": [self.entries["regions"]]})
add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": {"kind": "metric_set"},
add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": card_preview(self.card, self.card_id),
"evidence": [self.entries["card"]]})
for node_id, doc_id in reports.items():
add("llm.end", node_id, {"model": "mock", "output": "(写作草稿)"})
@@ -283,8 +309,10 @@ def bundles() -> dict[str, dict[str, Any]]:
out["nan"] = build(n, events, bound)
m = Sample(masked=True)
events, bound = m.events({"write": m.doc(WEEKLY, "write")})
events, bound = m.events({"write": m.doc(WEEKLY, "write", masked=MASKED_METRICS)})
out["masked-card"] = build(m, events, bound)
events, bound = m.events({"write": m.doc(WEEKLY, "write")})
out["masked-old-doc"] = build(m, events, bound)
r = Sample(regions=True)
events, bound = r.events({"write": r.doc(MEMBERS, "write")})
+12 -1
View File
@@ -268,11 +268,22 @@ async def test_a_mask_set_after_the_run_still_applies(client, monkeypatch):
assert all("b64" in a for a in before["artifacts"])
assert shown(before) == {"fetch": True, "members": True, "out": True, "finished": True}
await set_masks("bundle_shop", ["amount"])
# 门店名事后设了遮罩:报告引用的那份流水快照(选出了门店名)只给 id,查询事件、成果、运行结束事件只给哈希
await set_masks("bundle_shop", ["store"])
after = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
assert next(a for a in after["artifacts"] if a["id"] == shop) == {
"id": shop, "kind": "query", "node_id": "fetch", "masked": True}
assert shown(after) == {"fetch": False, "members": True, "out": False, "finished": False}
assert "b64" in next(a for a in after["artifacts"] if a["kind"] == "report_doc")
# 金额事后设了遮罩:口径卡的销售额就是流水第一行的金额(cell(nodes.fetch, 0, 'amount')),写作时还没遮,报告文档的
# 证据目录里写着原值——和事后设的遮罩同样生效,整份文档只给哈希,正文的节点完成事件也只给哈希
await set_masks("bundle_shop", ["amount"])
late = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
[doc] = [a for a in late["artifacts"] if a["kind"] == "report_doc"]
assert doc == {"id": doc["id"], "kind": "report_doc", "node_id": "write", "masked": True}, doc
write = next(e for e in late["events"] if e["type"] == "node.finished" and e["node_id"] == "write")
assert "canonical" not in write
async def test_report_text_cannot_close_the_data_block(client, monkeypatch):
+21 -2
View File
@@ -464,7 +464,8 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
rendered = pages()
got = {name: json.loads(data_block(page)) for name, page in rendered.items()}
assert set(got) == {"base", "two-reports", "legacy", "failed", "continued", "degraded", "missing-doc",
"missing-card", "nan", "stray", "masked-card", "masked-source", "formats", "formats-unit"}
"missing-card", "nan", "stray", "masked-card", "masked-old-doc", "masked-source", "formats",
"formats-unit"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
@@ -500,9 +501,27 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
issuances = [e["seq"] for e in got["degraded"]["events"] if e["type"] == "issuance"]
assert len(issuances) == 2 and got["degraded"]["meta"]["tier"] == {"value": "degraded", "seq": issuances[-1]}
assert [a["kind"] for a in got["stray"]["artifacts"]] == ["report_doc"]
# 口径卡上有输入取自遮罩列:整张卡只给 id,页面里找不到那个号码
# 口径卡上有输入取自遮罩列:整张卡只给 id,页面里找不到那个号码。卡的节点完成事件(预览里逐条写着指标的值)只给
# 哈希;普通的卡(base)照旧带规范字符串
def card_event(bundle):
return next(e for e in bundle["events"] if e["type"] == "node.finished" and e["node_id"] == "card")
card = next(a for a in got["masked-card"]["artifacts"] if a["kind"] == "metric_set")
assert card.get("masked") is True and "b64" not in card
assert "canonical" not in card_event(got["masked-card"]) and "canonical" in card_event(base)
masked_doc = json.loads(base64.b64decode(next(a["b64"] for a in got["masked-card"]["artifacts"]
if a["kind"] == "report_doc")))
assert {a for a, e in masked_doc["catalog"].items() if e.get("masked")} == {"m:contact", "m:phone_raw"}
assert not any(phone in rendered["masked-card"] for phone in ("000-0001", "000-0002"))
# 同一张卡、之前的报告撰写节点写的文档(证据目录里写着号码):整份只给哈希,不附带它引用的工件,报告的节点完成
# 事件只给哈希,报告自查事件照常带(引用链靠它认文档)
old_doc = got["masked-old-doc"]
assert [{k: v for k, v in a.items() if k != "id"} for a in old_doc["artifacts"]] == \
[{"kind": "report_doc", "node_id": "write", "masked": True}]
shown_types = {(e["type"], e["node_id"]) for e in old_doc["events"] if "canonical" in e}
assert ("report.checked", "write") in shown_types and ("node.finished", "write") not in shown_types
assert ("node.finished", "card") not in shown_types
assert not any(phone in rendered["masked-old-doc"] for phone in ("000-0001", "000-0002"))
# 会员库设了号码遮罩:按地区汇总的那份没碰号码列,随文件附带;选出了号码的那份只给 id,页面里找不到号码
source = got["masked-source"]
shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")}
+9 -4
View File
@@ -30,23 +30,28 @@ CELL = "cell(nodes.members, 0, 'phone')"
def card_with(inputs, substituted="len('000-0001')", expression=f"len({CELL})"):
"""会员号码位数一张卡:报告引用的是它(m:gmv 指过来)。"""
"""会员号码位数一张卡:报告引用的是它(m:gmv 指过来)。卡上另有一个普通指标「会员数」(base,没有输入):报告文档的
证据目录里 m:gmv 指的是它(见 bundle_of)。"""
return {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "metrics": [
{"id": "gmv", "name": "号码位数", "value": 1280.5, "unit": "元", "decimals": 1, "expression": expression,
"substituted": substituted, "inputs": inputs}]}
"substituted": substituted, "inputs": inputs},
{"id": "base", "name": "会员数", "value": 2, "unit": "位", "decimals": 0}]}
def bundle_of(card, *, outputs=None, source_masks=None, tamper=None, rehash=False, files_changed=None):
"""一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs 里的产出)→ 口径卡 → 报告。tamper 给了就把服务端的
卡文件换成 tamper(原字节),id 照旧(文件被改过、哈希对不上);rehash 为真时 id 按换过的字节算(服务端存的就是这一份,
哈希对得上)。files_changed 换掉服务端别的文件({工件 id: 新字节},id 照旧)。"""
哈希对得上)。files_changed 换掉服务端别的文件({工件 id: 新字节},id 照旧)。
报告文档的证据目录里 m:gmv 指的是卡上的普通指标「会员数」(base):这几条只看卡本身给不给内容。目录里收着值不给的
指标的原值的老文档整份只给哈希、也不附带它引用的卡(tests/test_bundle_masked_metrics.py),那样就看不到卡了。"""
card_id, card_raw = stored(card)
if tamper is not None:
card_raw = tamper(card_raw)
if rehash:
card_id = content_hash(card_raw)
doc = doc_of(extra_catalog={"m:gmv": {"alias": "m:gmv", "kind": "metric", "artifact": card_id, "node_id": "card",
"locator": {"metric": "gmv"}, "value": 1280.5}})
"locator": {"metric": "base"}, "value": 2}})
doc_id, doc_raw = stored(doc)
files = {**STORE, card_id: card_raw, doc_id: doc_raw, **(files_changed or {})}
events = [
+365
View File
@@ -0,0 +1,365 @@
"""可验证报告里值取自遮罩格的口径卡指标:口径卡的节点完成事件、带着这类原值的老报告文档都只给哈希。
验收第二轮发现(和报告引不引用无关):
- 口径卡的 node.finished 一律附规范串,它的预览逐条写着「指标(名称)= 值」:没有报告节点的运行(reports、artifacts 都是空的)
照样把 phone_raw 的号码带进了文件
- 报告文档的证据目录收着上游口径卡的全部指标(没被引用的也收)和它们的值、渲染、标签,report_doc 一律原样附带
现在:
- 口径卡的节点完成事件:卡上有值不给的指标、有输入取自遮罩列(卡本身只给 id)的只给哈希;普通的卡照旧带规范串
- 指标的值给不给,和证据面板指标步骤的值同一个判断(_Store.metric_word 对照 api/evidence.py 的 _metric_masks)
- 现在的报告撰写节点把这类指标写成「已遮罩」,文档照常附带;之前写的、证据目录里还带着原值的老文档整份只给哈希,不附带
它引用的工件,报告节点的完成事件也只给哈希;核对页照实说明原因,这份报告算未能核对
- 文档里写成「已遮罩」的指标所在的口径卡一律只给 id
真跑的部分用虚构的 SQLite 会员库(号码是编的),手搭的部分照 tests/test_bundle_build.py 的样子。
"""
from __future__ import annotations
import base64
import json
import re
import pytest
from httpx import ASGITransport, AsyncClient
from app.bundle.build import _Store, build_bundle, render_html
from app.core.artifact_store import read_bytes
from app.engine.runner import run_manager
from app.main import app
from tests.test_bundle_build import GRAPH, MASKED_ID, RUN, SNAP_MASKED, STORE, doc_of, stored
from tests.test_bundle_shown import verdicts
from tests.test_masked_metric_values import members # noqa: F401 夹具:号码设了遮罩的会员库
from tests.test_report_masked_metrics import CITES, CLEAN, METRICS, PHONES, finish, graph, leaks, script, vip # noqa: F401
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client(engine_up):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def data_of(html: str) -> dict:
m = re.search(r'<script type="application/json" id="bundle-data">(.*?)</script>', html, re.S)
assert m, "页面里没有数据块"
return json.loads(m.group(1))
async def export(client, run_id: str) -> tuple[str, dict]:
resp = await client.get(f"/api/runs/{run_id}/bundle.html")
assert resp.status_code == 200, resp.text
return resp.text, data_of(resp.text)
def event(bundle: dict, etype: str, node_id: str | None) -> dict:
return next(e for e in bundle["events"] if e["type"] == etype and e["node_id"] == node_id)
# --------------------------------------------------------------------------
# 真跑、真导出
# --------------------------------------------------------------------------
async def test_a_run_without_a_report_does_not_carry_the_card_values(client, monkeypatch, vip):
"""没有报告节点的运行:没有报告文档、没有工件,口径卡的节点完成事件只给哈希,文件里一个号码都没有。"""
row = await finish(graph(report=False))
assert row.status == "succeeded", row.error
html, bundle = await export(client, row.id)
assert bundle["reports"] == [] and bundle["artifacts"] == []
assert "canonical" not in event(bundle, "node.finished", "card")
assert leaks(html) == [], "可验证报告里不出现号码"
async def test_a_new_doc_is_embedded_and_the_card_event_ships_only_its_hash(client, monkeypatch, vip):
"""现在的报告撰写节点写的文档(写作者坚持引用了 [[m:phone_raw]],标注照常产出):证据目录、正文里都是「已遮罩」,文档照常
附带,报告节点的完成事件照常带规范串;口径卡只给 id、它的节点完成事件只给哈希。文件里没有号码。"""
script(monkeypatch, CITES)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
html, bundle = await export(client, row.id)
[doc_entry] = [a for a in bundle["artifacts"] if a["kind"] == "report_doc"]
doc = json.loads(base64.b64decode(doc_entry["b64"]))
assert doc["catalog"]["m:phone_raw"]["masked"] is True and "手机号 已遮罩" in doc["markdown"]
[card] = [a for a in bundle["artifacts"] if a["kind"] == "metric_set"]
assert card.get("masked") is True and "b64" not in card
assert "canonical" not in event(bundle, "node.finished", "card")
assert "canonical" in event(bundle, "node.finished", "write") and "canonical" in event(bundle, "report.checked", "write")
assert leaks(html) == []
async def test_an_old_doc_carrying_masked_values_ships_only_its_hash(client, monkeypatch, vip):
"""升级前的报告撰写节点写的文档(这里把写作前认遮罩的那一步换成什么都不认):证据目录、正文里写着号码。导出时整份
只给哈希,不附带它引用的口径卡和快照,报告节点的完成事件也只给哈希;报告自查事件照常带(引用链靠它认文档)。文件里
没有号码;核对页把这份报告说成未能核对、原因写明,没有一项不通过。"""
import app.api.evidence as evidence_api
async def nothing(*_args, **_kwargs):
return []
monkeypatch.setattr(evidence_api, "withheld_metrics", nothing)
script(monkeypatch, CITES)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
html, bundle = await export(client, row.id)
assert [{k: v for k, v in a.items() if k != "id"} for a in bundle["artifacts"]] == \
[{"kind": "report_doc", "node_id": "write", "masked": True}]
assert "canonical" not in event(bundle, "node.finished", "write")
assert "canonical" not in event(bundle, "node.finished", "card")
assert "canonical" in event(bundle, "report.checked", "write")
assert leaks(html) == []
got = verdicts(bundle)
[doc] = [a for a in got["why"]["artifacts"] if a["kind"] == "report_doc"]
assert doc["result"] == "na" and "取自遮罩列的指标的值" in doc["reason"] and "未随文件附带" in doc["reason"], doc
[link] = [c for c in got["why"]["chain"] if c["kind"] == "report_doc"]
assert link["result"] == "na" and "取自遮罩列的指标的值" in link["reason"], link
assert got["docs"] == [{"node": "write", "trusted": False}] and got["numbers"] == []
assert "fail" not in got["events"] + got["manifest"] + got["artifacts"] + got["chain"] + got["meta"], got
async def test_an_ordinary_card_keeps_its_event_and_its_numbers(client, monkeypatch, vip):
"""同一个设了号码遮罩的会员库,卡上只有客户编号、客户数(值里没有号码):卡照常附带,节点完成事件照常带规范串,报告里
引用它的数字照常核对通过——普通报告的可核对项不因此变少。"""
script(monkeypatch, CLEAN)
row = await finish(graph(on_violation="flag", metrics=[m for m in METRICS if m["id"] in ("first_id", "n_cust")]))
assert row.status == "succeeded", row.error
html, bundle = await export(client, row.id)
[card] = [a for a in bundle["artifacts"] if a["kind"] == "metric_set"]
assert "b64" in card and "masked" not in card
assert "canonical" in event(bundle, "node.finished", "card")
got = verdicts(bundle)
assert [n["result"] for n in got["numbers"]] == ["pass", "pass"], got["numbers"]
assert leaks(html) == []
async def test_the_bundle_judges_metric_values_like_the_report_writer(client, monkeypatch, members):
"""可验证报告认指标的值给不给,和报告撰写节点的写作目录同一个判断(证据面板指标步骤的那一套,只按出处认:
_metric_masks 的 by_value=False):整份结果转成文字、整行、下标不是字面量的一格、cell() 取的号码都不给,数行数的、
取编号的照给(用例同 tests/test_masked_metric_values.py)。数据整形取出来再赋给变量的号码认不出出处,证据面板按值多遮
一层,这里照给(已知的缺口)。"""
from app.api.evidence import MASKED, _mask_fingerprint, _Masks, _metric_masks, _metric_in, _sealed
from tests.test_masked_metric_values import TEXT, graph as members_graph
script(monkeypatch, TEXT)
row = await finish(members_graph())
assert row.status == "succeeded", row.error
sealed = await _sealed(row.id)
entry = next(e for e in sealed.entries if e.get("kind") == "metric_set")
card = json.loads(read_bytes(entry["artifact"], verify=False))
store = _Store(lambda a: read_bytes(a, verify=False), await _mask_fingerprint(), sealed.events)
writer, panel, bundle = {}, {}, {}
for metric in card["metrics"]:
_, word = await _metric_masks(metric, "card", entry["artifact"], sealed, _Masks(sealed), by_value=False)
writer[metric["id"]] = {MASKED: "masked"}.get(word or "", word)
_, word = await _metric_masks(metric, "card", entry["artifact"], sealed, _Masks(sealed))
panel[metric["id"]] = {MASKED: "masked"}.get(word or "", word)
bundle[metric["id"]] = store.metric_word(entry["artifact"], _metric_in(card, metric["id"]), sealed.events)
assert writer == bundle, (writer, bundle)
assert bundle == {"n_cust": None, "dump": "masked", "row0": "masked", "last": "masked", "phone_var": None,
"phone_raw": "masked", "id0": None}
assert {k: v for k, v in panel.items() if k != "phone_var"} == {k: v for k, v in bundle.items() if k != "phone_var"}
assert panel["phone_var"] == "masked"
# --------------------------------------------------------------------------
# 手搭:认法的几条分支
# --------------------------------------------------------------------------
PHONE = SNAP_MASKED["rows"][0][1]
CELL = "cell(nodes.members, 0, 'phone')"
READ = {"path": CELL, "value": PHONE, "node_id": "members", "via": "tool_cell", "artifact": MASKED_ID,
"locator": {"row": 0, "column": "phone"}, "status": "verified"}
PHONE_METRIC = {"id": "phone_raw", "name": "首位会员手机号", "value": PHONE, "format": "plain", "expression": CELL,
"substituted": f"'{PHONE}'", "inputs": [READ]}
COUNT_METRIC = {"id": "base", "name": "会员数", "value": 2, "unit": "位", "decimals": 0}
#: 报告文档的证据目录里,现在的报告撰写节点写的 phone_raw(「已遮罩」)。挂在 m:gmv 上:手搭的文档正文引用的是 m:gmv
MASKED_ENTRY = {"m:gmv": {"locator": {"metric": "phone_raw"}, "value": "已遮罩", "rendered": "已遮罩",
"label": "首位会员手机号 = 已遮罩", "masked": True}}
def card_of(*metrics) -> dict:
return {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "metrics": list(metrics)}
def text_of(card: dict) -> str:
"""metrics 节点产出的 text:逐条「指标(名称)= 值」。"""
return "指标清单:\n" + "\n".join(f"- {m['id']}({m['name']})= {m['value']}{m.get('unit') or ''}"
for m in card["metrics"])
def built(card: dict, *, entries: dict | None = None, finished: dict | None = None, drop: tuple[str, ...] = (),
extra_files: dict | None = None, outputs: dict | None = None, source_masks: dict | None = None):
"""一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs:{节点: 产出工件})→ 口径卡 → 报告。entries:报告文档
证据目录里另加的指标条目({别名: 条目},值由调用方给,artifact 指向这张卡);finished 换掉口径卡节点完成事件的内容;
drop 是导出时服务端已没有文件的工件;source_masks 是数据源现在设的遮罩。"""
card_id, card_raw = stored(card)
doc = doc_of(extra_catalog={alias: {"alias": alias, "kind": "metric", "node_id": "card", "artifact": card_id,
**entry} for alias, entry in (entries or {}).items()})
doc_id, doc_raw = stored(doc)
files = {**STORE, card_id: card_raw, doc_id: doc_raw, **(extra_files or {})}
files = {k: v for k, v in files.items() if k not in drop}
# 数据整形节点的完成事件排在会员查询之后、口径卡之前(seq 3.x),口径卡、报告的序号照旧是 5、6、7
shaped = [(3, "node.finished", node_id, {"artifact": artifact}) for node_id, artifact in (outputs or {}).items()]
events = [
(1, "run.started", None, {"nodes": 7}),
(2, "tool.end", "members", {"tool": "db_query__crm", "query_artifact": MASKED_ID}),
(3, "node.finished", "members", {"evidence": [{"kind": "query", "node_id": "members", "artifact": MASKED_ID}]}),
*shaped,
(4, "node.started", "card", {}),
(5, "node.finished", "card", finished if finished is not None else {
"preview": {"kind": "metric_set", "text": text_of(card), "artifact": card_id},
"evidence": [{"kind": "metric_set", "node_id": "card", "artifact": card_id}]}),
(6, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}),
(7, "node.finished", "write", {"preview": {"text": "…", "doc_artifact": doc_id}}),
(8, "run.finished", None, {"output": {}}),
]
bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash="0" * 64, graph=GRAPH,
source_masks=source_masks or {}, read=files.get, exported_at="2026-10-12T08:00:00+00:00")
return bundle, card_id, doc_id
def shown(bundle: dict, seq: int) -> bool:
return "canonical" in next(e for e in bundle["events"] if e["seq"] == seq)
def entry_of(bundle: dict, artifact: str) -> dict | None:
return next((a for a in bundle["artifacts"] if a["id"] == artifact), None)
def test_a_card_with_a_metric_from_a_masked_cell_ships_its_event_as_a_hash():
"""卡上一个指标就是号码那一格:卡的节点完成事件只给哈希;同样的事件换成只有普通指标的卡,照常带规范串。"""
bundle, *_ = built(card_of(COUNT_METRIC, PHONE_METRIC), entries={"m:base": {"locator": {"metric": "base"},
"value": 2}})
assert not shown(bundle, 5) and PHONE not in render_html(bundle)
plain, *_ = built(card_of(COUNT_METRIC), entries={"m:base": {"locator": {"metric": "base"}, "value": 2}})
assert shown(plain, 5)
@pytest.mark.parametrize("pointer", ["preview", "output"])
def test_a_card_event_without_a_ledger_is_judged_by_what_it_points_to(pointer):
"""升级前的运行:口径卡的节点完成事件没有台账,按预览里记的 artifact、再按节点产出工件(内容就是这张卡加上 text)认。"""
card = card_of(COUNT_METRIC, PHONE_METRIC)
card_id, _ = stored(card)
output = {**card, "text": text_of(card), "artifact": card_id}
output_id, output_raw = stored(output)
finished = {"preview": {"kind": "metric_set", "text": text_of(card), "artifact": card_id}} if pointer == "preview" \
else {"preview": {"kind": "metric_set", "text": text_of(card)}, "artifact": output_id}
bundle, *_ = built(card, finished=finished, extra_files={output_id: output_raw})
assert not shown(bundle, 5)
@pytest.mark.parametrize("masked_run, expected", [(True, False), (False, True)])
def test_an_unreadable_card_event_follows_the_run(masked_run, expected):
"""卡的文件读不出来:不知道卡上有没有遮罩,运行里有带遮罩的快照时只给哈希(同成果节点),没有的照常带。"""
card = card_of(COUNT_METRIC, PHONE_METRIC)
card_id, _ = stored(card)
store = _Store({}.get, {}, [])
from app.bundle.build import _card_shown
data = {"preview": {"text": text_of(card)}, "evidence": [{"kind": "metric_set", "artifact": card_id}]}
assert _card_shown(data, store, masked_run, []) is expected
def test_an_old_doc_carrying_a_masked_value_ships_only_its_hash():
"""证据目录里写着 phone_raw 的号码(没被引用也一样):整份只给哈希,不附带它引用的工件,报告节点的完成事件只给哈希。
同一张卡,证据目录里这个指标写成「已遮罩」的照常附带。"""
raw = {"m:phone_raw": {"locator": {"metric": "phone_raw"}, "value": PHONE, "rendered": PHONE,
"label": f"首位会员手机号 = {PHONE}"}}
bundle, _, doc_id = built(card_of(COUNT_METRIC, PHONE_METRIC), entries=raw)
assert bundle["artifacts"] == [{"id": doc_id, "kind": "report_doc", "node_id": "write", "masked": True}]
assert not shown(bundle, 7) and shown(bundle, 6)
assert PHONE not in render_html(bundle)
bundle, card_id, doc_id = built(card_of(COUNT_METRIC, PHONE_METRIC), entries=MASKED_ENTRY)
assert "b64" in entry_of(bundle, doc_id) and shown(bundle, 7)
assert entry_of(bundle, card_id) == {"id": card_id, "kind": "metric_set", "node_id": "card", "masked": True}
assert PHONE not in render_html(bundle)
def test_an_old_doc_with_only_ordinary_values_is_embedded():
"""证据目录里只有普通指标的值(同一张卡上有号码那个指标,目录里没收):照常附带。"""
bundle, _, doc_id = built(card_of(COUNT_METRIC, PHONE_METRIC),
entries={"m:base": {"locator": {"metric": "base"}, "value": 2}})
assert "b64" in entry_of(bundle, doc_id) and shown(bundle, 7)
@pytest.mark.parametrize("value, withheld", [(PHONE, True), (2, False)])
def test_an_old_doc_whose_card_is_missing_is_judged_by_value(value, withheld):
"""目录里的口径卡导出时读不出来:认不出这个指标取自哪里,按值认——值就是这次运行查到过的遮罩值才只给哈希。"""
card = card_of(COUNT_METRIC, PHONE_METRIC)
card_id, _ = stored(card)
bundle, _, doc_id = built(card, entries={"m:x": {"locator": {"metric": "phone_raw"}, "value": value}},
drop=(card_id,))
assert (entry_of(bundle, doc_id).get("masked") is True) is withheld, entry_of(bundle, doc_id)
def test_a_doc_masking_a_metric_forces_its_card_to_ship_only_its_id():
"""文档的证据目录里写成「已遮罩」的指标,导出时这里认不出它取自遮罩列(这里是卡上没记输入):卡上记着的原值照样不随
文件附带,整张卡只给 id。"""
plain = {k: v for k, v in PHONE_METRIC.items() if k != "inputs"}
card = card_of(COUNT_METRIC, plain)
assert _Store({}.get, {}, []).metric_word("x", plain, []) is None
bundle, card_id, _ = built(card, entries=MASKED_ENTRY)
assert entry_of(bundle, card_id) == {"id": card_id, "kind": "metric_set", "node_id": "card", "masked": True}
assert PHONE not in render_html(bundle)
# 对照:目录里同一个指标照写原值(老文档、真没遮)的,这张卡照常附带
bundle, card_id, _ = built(card, entries={"m:gmv": {"locator": {"metric": "base"}, "value": 2}})
assert "b64" in entry_of(bundle, card_id)
@pytest.mark.parametrize("path, value, parsed", [
# 整行转成文字:只认字面量下标一格的那一套认不出,按位置认得出整行的列
("vars.res.rows[0]", ["林一", PHONE, 320.0], {"columns": SNAP_MASKED["columns"], "rows": SNAP_MASKED["rows"],
"artifact": MASKED_ID, "source": "crm"}),
# 整份结果转成文字:结果里带着号码列,值里就是号码
("vars.res.rows", [["林一", PHONE, 320.0]], {"columns": SNAP_MASKED["columns"], "rows": SNAP_MASKED["rows"],
"artifact": MASKED_ID, "source": "crm"}),
])
def test_a_card_whose_value_carries_a_masked_row_ships_only_its_id(path, value, parsed):
"""卡上的指标是整行、整份结果转成的文字:卡只给 id、节点完成事件只给哈希,页面里没有号码(以前只认字面量下标的一格,
卡原样嵌了进去)。"""
metric = {"id": "who", "name": "首位会员", "value": str(value), "format": "plain", "expression": f"str({path})",
"substituted": "…", "inputs": [{"path": path, "value": value, "node_id": "parse", "via": "transform",
"status": "ok"}]}
card = card_of(COUNT_METRIC, metric)
parse_id, parse_raw = stored(parsed)
finished = {"preview": {"kind": "metric_set", "text": text_of(card)},
"evidence": [{"kind": "metric_set", "node_id": "card", "artifact": stored(card)[0]}]}
bundle, card_id, _ = built(card, finished=finished, extra_files={parse_id: parse_raw},
entries={"m:gmv": {"locator": {"metric": "base"}, "value": 2}},
outputs={"parse": parse_id})
assert entry_of(bundle, card_id) == {"id": card_id, "kind": "metric_set", "node_id": "card", "masked": True}
assert not shown(bundle, 5) and PHONE not in render_html(bundle)
def test_an_old_doc_is_judged_by_the_source_when_the_snapshot_is_gone():
"""号码那一格所在的快照导出时读不出来(这次运行查到过的遮罩值也就收不到):按这份快照的数据源现在设的遮罩认,phone_raw
照样算取自遮罩列,带着号码的老文档只给哈希。"""
raw = {"m:phone_raw": {"locator": {"metric": "phone_raw"}, "value": PHONE}}
bundle, _, doc_id = built(card_of(COUNT_METRIC, PHONE_METRIC), entries=raw, drop=(MASKED_ID,),
source_masks={"crm": ["phone"]})
assert entry_of(bundle, doc_id).get("masked") is True, entry_of(bundle, doc_id)
def test_an_ordinary_metric_that_merely_equals_a_masked_value_stays_checkable():
"""写死在数据整形里的热线号码碰巧和遮罩列里的号码一样(认不出出处,只是值一样,按值认得出):不算取自遮罩列。卡照常
附带、节点完成事件照常带规范串,证据目录里写着这个值的文档照常附带——普通报告的可核对项不因此变少(真实的情形见
tests/test_inquiry_api.py 里「单笔目标 20000」那一条:目标碰巧等于遮罩金额列里的一笔)。证据面板为了展示按值多遮一层,
这里不跟。"""
metric = {"id": "hotline", "name": "服务热线", "value": PHONE, "format": "plain", "expression": "vars.kpi.hotline",
"substituted": f"'{PHONE}'", "inputs": [{"path": "vars.kpi.hotline", "value": PHONE, "node_id": "calc",
"via": "transform", "status": "ok"}]}
card = card_of(COUNT_METRIC, metric)
kpi_id, kpi_raw = stored({"hotline": PHONE})
bundle, card_id, doc_id = built(card, extra_files={kpi_id: kpi_raw}, outputs={"calc": kpi_id},
entries={"m:gmv": {"locator": {"metric": "hotline"}, "value": PHONE}})
assert "b64" in entry_of(bundle, card_id) and shown(bundle, 5)
assert "b64" in entry_of(bundle, doc_id) and shown(bundle, 7)
+30 -8
View File
@@ -13,9 +13,10 @@
- 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示
文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写;
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、
负号(长得像负号的横线也算,隔着不占位置的字也算),紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里
有改变文字方向的字(方向控制字符、希伯来文和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的
写法照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数。
负号、小数点(长得像负号的横线也算,隔着不占位置的字也算)判不通过:读者看到的是另一个数,以前只判无法核对,总览
照样是绿的;紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里有改变文字方向的字(方向控制字符、希伯来文
和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的写法照常通过;列表缩进有上限、表格只排有格
的列,伪造的缩进、列号挤不走核对过的数。
数据全是虚构的。
"""
@@ -78,6 +79,10 @@ async function main() {
return {
events: results(out.events), manifest: results(out.manifest), artifacts: results(out.artifacts),
chain: results(out.chain), meta: results(out.meta),
// 工件、引用链每一项的种类、结果和原因;每份报告的文档核对上了没有
why: { artifacts: out.artifacts.map((i) => ({ kind: i.a.kind, result: i.result, reason: i.reason || '' })),
chain: out.chain.map((c) => ({ kind: c.kind, result: c.result, reason: c.reason || '' })) },
docs: out.docs.map((d) => ({ node: d.report.node_id, trusted: d.trusted })),
// shownOk:显示文字核对过、对上了;truthText:出处面板「快照里的值」写的字(数字文本照原文);onPage:正文里这处
// 可点的数字上读的人看到的字(没有显示成可点的数字是 null)
numbers: out.numbers.map((n) => ({ key: n.key, ref: n.seg.ref ?? null, text: String(n.seg.text ?? ''),
@@ -546,7 +551,7 @@ def test_inline_markup_cannot_hide_a_checked_value():
assert hit["result"] == "pass" and hit["onPage"] == text, hit
GLUED = "无法核对显示文字:紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值"
GLUED = "正文里紧挨着这个数的字改变了读者看到的数:前后紧挨着的数字、负号或小数点会和它连成另一个数"
BLURRED = "无法核对显示文字:紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值"
TURNED = "无法核对显示文字:这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样"
#: 经营月报第一段里的数字(第二段是 Q1 的取值)
@@ -611,17 +616,34 @@ def _split_pair(high: str, low: str):
("m:refund", _glue_before("s9", "-")), # 负数前面再加负号:--3,120.50元
("v:Q1.r0.paid_at", _glue_after("s45", "5")), # 取值以数字结尾:2026-09-07 08:21:395
])
def test_digits_glued_to_a_checked_value_cannot_pass(ref, change):
"""伪造者在相邻的文字片段里补一位数、一个负号(隔着不占位置、显示成什么说不准的字,藏起来的粗体标记也算紧挨着;
长得像负号的横线也算负号):显示出来的数不止核对过的这几位,这一项判无法核对、写明原因;别的照常通过。"""
def test_digits_glued_to_a_checked_value_fail(ref, change):
"""伪造者不动核对过的那段字,只在相邻的文字片段里补一位数、一个负号、一段小数(隔着不占位置、显示成什么说不准的字,
藏起来的粗体标记也算紧挨着;长得像负号的横线也算负号),重算全部哈希:读者看到的是另一个数。这一项判不通过、点名
正文里紧挨着的字改变了读者看到的数——以前只判无法核对,总览照样写「可核对的项全部通过」。显示文字本身照旧核对过
(shownOk),别的照常通过。服务端组装的文档不会这样排(两个数之间补空格),原样导出照样通过(见下面几条)。"""
from tests.bundle_samples import formats_bundle
got = verdicts(rewritten(formats_bundle(), change))
hit = item(got, ref)
assert hit["result"] == "na" and hit["reason"] == GLUED and hit["shownOk"], hit
assert hit["result"] == "fail" and hit["reason"] == GLUED and hit["shownOk"], hit
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
@pytest.mark.parametrize("ref, change", [
# 改变文字方向的字不能把不通过降成无法核对:伪造者紧挨着补一位数,再在同一句里放一个方向控制字符、一个希伯来字母
("m:gmv", lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + "1"),
by_id(doc, "s5").update(text=chr(0x202E) + by_id(doc, "s5")["text"]))),
("m:gmv", lambda doc: (by_id(doc, "s3").update(text=chr(0x05D0) + by_id(doc, "s3")["text"] + "1"))),
# 说不准的字同理:紧挨着的是一位数,中间隔着叠在上面的组合符号
("m:gmv", _glue_before("s3", "1" + chr(0x0336))),
])
def test_other_doubts_do_not_soften_a_glued_digit(ref, change):
from tests.bundle_samples import formats_bundle
hit = item(verdicts(rewritten(formats_bundle(), change)), ref)
assert hit["result"] == "fail" and hit["reason"] == GLUED, hit
@pytest.mark.parametrize("ref, change", [
("m:gmv", _glue_before("s3", chr(0x0336))), # 叠在空格上的长横线:页面上像一个负号
("m:gmv", _glue_before("s3", chr(0x82))), # 控制字符,Chrome 里画成一道短横
@@ -180,13 +180,14 @@ async def test_the_metric_chain_masks_the_renamed_column_too(client, monkeypatch
async def test_an_input_taken_from_a_renamed_masked_column_is_withheld(client, monkeypatch, mall):
"""cell() 取 tel 那一格:输入不给值、写明取自哪一列,代入式里那一处写「已遮罩」,指标本身的值也不给。"""
"""cell() 取 tel 那一格:输入不给值、写明取自哪一列,代入式里那一处写「已遮罩」,指标本身的值也不给。报告撰写节点写作时
按同一个判断把它在目录里换成了「已遮罩」,报告上的字也是(tests/test_report_masked_metrics.py),按引用找片段。"""
await set_source(mall, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
body = await segment(client, row.id, text="13位")
body = await segment(client, row.id, ref="m:tel_len")
metric = body["chain"][0]
assert metric["step"] == "metric" and metric["substituted"] == "len(已遮罩)"
assert metric["value"] == "已遮罩" and metric["rendered"] == "13位"
assert metric["value"] == "已遮罩" and metric["rendered"] == "已遮罩" and metric["render_ok"] is True
[cell] = steps(body, "input")
assert cell["value"] == "已遮罩" and cell["masked"] == ["tel"] and cell["path"] == TEL_CELL
assert body["redacted"]["columns"] == ["tel"] and leaks(body) == []
@@ -196,7 +197,7 @@ async def test_a_scalar_taken_by_position_from_a_renamed_masked_column_is_withhe
"""vars.res.rows[0][1]:没有定位,顺着取值链认出取自查询结果的 tel 列,tel 又是 phone 改的名字。"""
await set_source(mall, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
body = await segment(client, row.id, text="26位")
body = await segment(client, row.id, ref="m:tel_pos2")
assert body["chain"][0]["substituted"] == "len(已遮罩) * 2" and body["chain"][0]["value"] == "已遮罩"
[pos] = steps(body, "input")
assert pos["path"] == TEL_POS and pos["value"] == "已遮罩" and pos["masked"] == ["tel"]
+10 -8
View File
@@ -121,11 +121,13 @@ async def run_graph(monkeypatch) -> Run:
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
async def segment(client, run_id: str, text: str) -> dict:
async def segment(client, run_id: str, text: str | None = None, *, ref: str | None = None) -> dict:
"""点开报告里的一处:按正文的字找,或者按引用找(ref)。运行之前就设了遮罩的,用到遮罩列的指标在报告里写「已遮罩」
(报告撰写节点按指标步骤同一个判断换掉,见 tests/test_report_masked_metrics.py),只能按引用找。"""
graph_body = (await client.get(f"/api/runs/{run_id}/evidence")).json()
[report] = graph_body["reports"]
doc = artifact_store.load(report["doc_artifact"])
seg = next(s for s in iter_segments(doc) if s["text"] == text)
seg = next(s for s in iter_segments(doc) if (s.get("ref") == ref if ref else s["text"] == text))
res = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg['id']}")
assert res.status_code == 200, res.text
return res.json()
@@ -146,7 +148,7 @@ def leaks(body: dict) -> list[str]:
async def test_a_masked_cell_is_hidden_from_the_input_label_and_the_substituted_expression(client, monkeypatch, shop):
await set_source(shop, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
body = await segment(client, row.id, "13位")
body = await segment(client, row.id, ref="m:plen")
metric = body["chain"][0]
assert metric["step"] == "metric" and metric["expression"] == f"len({PHONE_CELL})"
assert metric["substituted"] == "len(已遮罩)"
@@ -163,7 +165,7 @@ async def test_a_scalar_taken_by_position_from_a_masked_column_is_hidden(client,
"""vars.res.rows[0][2]:没有 cell() 的定位,顺着取值链认出取自查询结果的 phone 列。"""
await set_source(shop, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
body = await segment(client, row.id, "26位")
body = await segment(client, row.id, ref="m:plen2")
metric = body["chain"][0]
assert metric["substituted"] == "len(已遮罩) * 2"
[phone] = inputs_of(body).values()
@@ -175,7 +177,7 @@ async def test_a_scalar_taken_by_position_from_a_masked_column_is_hidden(client,
async def test_only_the_masked_input_is_hidden_when_inputs_are_mixed(client, monkeypatch, shop):
await set_source(shop, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
body = await segment(client, row.id, "93.0分")
body = await segment(client, row.id, ref="m:mix")
assert body["chain"][0]["substituted"] == "round(80.0 + len(已遮罩), 1)"
inputs = inputs_of(body)
amount = inputs["cell(nodes.fetch, 1, 'amount')"]
@@ -193,9 +195,9 @@ async def test_masks_recorded_at_query_time_stay_after_the_source_drops_them(cli
await set_source(shop, {"mask_columns": ["phone"]})
row = await run_graph(monkeypatch)
await set_source(shop, {})
for text in ("13位", "26位", "93.0分"):
body = await segment(client, row.id, text)
assert "已遮罩" in body["chain"][0]["substituted"] and leaks(body) == [], text
for ref in ("m:plen", "m:plen2", "m:mix"):
body = await segment(client, row.id, ref=ref)
assert "已遮罩" in body["chain"][0]["substituted"] and leaks(body) == [], ref
async def test_masks_added_after_the_run_apply_too(client, monkeypatch, shop):
+374
View File
@@ -0,0 +1,374 @@
"""值取自遮罩格的口径卡指标:交给写作者的目录、存进报告文档的证据目录里写「已遮罩」,引用它的报告自查记 masked_cell。
验收第二轮发现:口径卡 phone_raw = cell(nodes.q_cust, 0, 'phone')(phone 设了遮罩),写作要求点名 [[m:phone_raw]],报告自查
判 ok、violations 为空,正文写出号码;证据面板的指标步骤写着「已遮罩」,同一步的 rendered、片段的字、引用的值却都是号码。
报告自查以前只查 kind 为 cell 的引用;写作目录、文档的证据目录收着上游口径卡的全部指标(没被引用的也收),值和渲染原样写着。
现在:
- 指标的值给不给,和证据面板指标步骤的值、推演的原值同一个判断(api/evidence.py 的 _metric_masks → recompute.withheld_value),
报告撰写节点写作之前按运行状态认好(withheld_metrics)。判「已遮罩」的那几个指标,目录条目的值、渲染、标签都写「已遮罩」
(withhold_metrics),写作目录里另说不要引用;数值型的普通指标照旧
- 引用这类指标照样解析、正文写「已遮罩」,报告自查记一条 masked_cell(masked_metrics),和引用遮罩格同一套处置:交回重写,
仍不改按「违规处置」办;出口契约复核按节点产出里记下的同一份重建目录,「已遮罩」对得上,不算文档被改过
- 证据面板点开这一处:值、渲染都是「已遮罩」,渲染一致
真跑的部分用虚构的 SQLite 会员库(号码是编的),写作者是 MockChatModel 照剧本写。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.evidence import build_catalog, compose_doc, iter_segments, verify_doc, withhold_metrics
from app.engine.runner import run_manager
from app.main import app
CUSTOMERS = [(1, "客户甲", "19900000801"), (2, "客户乙", "19900000802"), (3, "客户丙", "19900000803")]
PHONES = [c[2] for c in CUSTOMERS]
METRICS = [
{"id": "phone_raw", "name": "首位客户手机号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'phone')"},
# 改了名字的遮罩列:SELECT id, phone AS tel
{"id": "tel_raw", "name": "首位客户联系电话", "format": "plain", "expression": "cell(nodes.q_tel, 0, 'tel')"},
{"id": "first_id", "name": "首位客户编号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'id')"},
# 整份结果当输入、只数行数:值里没有号码,照给(和证据面板、推演一样)
{"id": "n_cust", "name": "客户数", "unit": "位", "format": "thousands", "expression": "len(vars.cust.rows)"},
]
CLEAN = "共有 [[m:n_cust]] 客户,首位客户编号 [[m:first_id]]。"
CITES = "首位客户编号 [[m:first_id]],手机号 [[m:phone_raw]]。"
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client(engine_up):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def set_source(path, options=None) -> str:
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "vip"))).scalar_one_or_none()
if row is None:
row = DataSource(name="vip", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options or {})
await session.commit()
return row.id
@pytest.fixture
async def vip(tmp_path, engine_up):
path = tmp_path / "vip.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", CUSTOMERS)
db.commit()
db.close()
source_id = await set_source(path, {"mask_columns": ["phone"]})
await engines.invalidate(source_id)
yield path
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(*, contract: dict | None = None, report: bool = True, metrics: list | None = None, **write) -> dict:
"""会员查询(选出号码)、改了名字的号码查询 → 数据整形 → 口径卡(metrics,默认 METRICS)→ 报告(report 为假时没有)→ 成果。"""
out = {"fields": [{"name": "报告", "value": "{{ nodes.write.text }}"}] if report else [{"name": "说明", "value": "完成"}]}
if contract is not None:
out["contract"] = contract
nodes = [
node("start", "input"),
node("q_cust", "tool", tool="db_query__vip", args={"sql": "SELECT id, name, phone FROM customers ORDER BY id"}),
node("q_tel", "tool", tool="db_query__vip", args={"sql": "SELECT id, phone AS tel FROM customers ORDER BY id"}),
node("parse", "transform", mode="json", template="{{ nodes.q_cust }}", assign_to="cust"),
node("card", "metrics", caliber="会员口径", caliber_version="v1", metrics=metrics or METRICS),
*([node("write", "report", instructions="写一句,手机号写 [[m:phone_raw]]", **write)] if report else []),
node("out", "output", **out),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def script(monkeypatch, *replies) -> list[list]:
"""写作者依次回这几段,用完了一直回最后一段。返回每次调用收到的消息。"""
from app.providers import mock_model
seen: list[list] = []
def _decide(self, messages):
seen.append(list(messages))
return AIMessage(content=replies[min(len(seen), len(replies)) - 1])
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return seen
async def finish(spec: dict) -> Run:
run_id = (await run_manager.start(graph=spec, input_payload={})).id
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"运行 {run_id} 没跑完:{row.status}")
async def events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
return list((await session.execute(q)).scalars())
def leaks(value) -> list[str]:
"""写着的号码,按千分位写的也算。"""
raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return [p for p in PHONES if p in raw or p in raw.replace(",", "")]
async def doc_of(run_id: str) -> dict:
[checked] = [e.data for e in await events(run_id) if e.type == "report.checked"][-1:]
return artifact_store.load(checked["doc_artifact"])
def prompt_of(messages: list) -> str:
return "\n".join(str(m.content) for m in messages)
def metric_lines(messages: list) -> str:
"""写作目录里口径卡指标的那几行。查询结果的行照旧原样列出(写作者要看得到数据,引用遮罩格由自查拦下),不在这里看。"""
return "\n".join(line for line in prompt_of(messages).splitlines() if line.startswith("- [[m:"))
# --------------------------------------------------------------------------
# 目录:组装文档的纯函数
# --------------------------------------------------------------------------
def card_catalog() -> dict:
card = {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "artifact": "c" * 64, "metrics": [
{"id": "phone_raw", "name": "首位客户手机号", "value": PHONES[0], "format": "plain"},
{"id": "first_id", "name": "首位客户编号", "value": 1, "format": "plain"},
{"id": "gmv", "name": "销售额", "value": 74613.65, "unit": "元", "decimals": 2, "format": "thousands"}]}
return build_catalog(nodes={"card": card}, ledger=[])
def test_a_withheld_metric_shows_masked_everywhere_in_the_catalog():
"""值、渲染、标签都写「已遮罩」,另记 masked;别的指标一字不差。没被引用的也一样(目录收着全部指标)。"""
raw = card_catalog()
catalog = withhold_metrics(raw, ["m:phone_raw"], "已遮罩")
entry = catalog["m:phone_raw"]
assert (entry["value"], entry["rendered"], entry["label"], entry["masked"]) == \
("已遮罩", "已遮罩", "首位客户手机号 = 已遮罩", True)
assert catalog["m:first_id"] == raw["m:first_id"] and catalog["m:gmv"] == raw["m:gmv"]
assert raw["m:phone_raw"]["value"] == PHONES[0], "原来的目录不跟着改"
doc = compose_doc(CLEAN.replace("[[m:n_cust]]", "[[m:gmv]]"), catalog, node_id="write", run_id="r1")
assert doc["catalog"]["m:phone_raw"]["masked"] is True and leaks(doc) == []
@pytest.mark.parametrize("ref", ["m:phone_raw", "m:phone_raw|万", "m:phone_raw|int"])
def test_citing_a_withheld_metric_is_a_masked_cell_violation_without_its_value(ref):
"""引用它照样解析(换算不再算:「已遮罩」不是数),正文写「已遮罩」,自查记 masked_cell,说法里不写值;违规点名引用、
指标,写作者知道要去掉哪一处。出口契约复核拿同一份目录重建,渲染对得上,不算文档被改过。"""
catalog = withhold_metrics(card_catalog(), ["m:phone_raw"], "已遮罩")
doc = compose_doc(f"首位客户编号 [[m:first_id]],手机号 [[{ref}]]。", catalog, node_id="write", run_id="r1")
seg = next(s for s in iter_segments(doc) if s.get("ref") == ref)
assert seg["cite"]["status"] == "resolved" and seg["text"] == "已遮罩" and seg["cite"]["value"] == "已遮罩", seg
[v] = [v for v in doc["violations"] if v["code"] == "masked_cell"]
assert (v["ref"], v["metric"], v["segment"]) == (ref, "phone_raw", seg["id"]) and v["unit"] and v["span"], v
assert "首位客户手机号" in v["message"] and "遮罩" in v["message"] and "删掉" in v["for_model"], v
assert "text" not in v and "context" not in v and leaks(doc) == []
assert not [x for x in doc["violations"] if x["code"] != "masked_cell"], doc["violations"]
again = verify_doc(doc, catalog)
assert again["ok"] is True, again["violations"]
def test_a_catalog_without_withheld_metrics_is_unchanged():
raw = card_catalog()
assert withhold_metrics(raw, [], "已遮罩") == raw
doc = compose_doc("手机号 [[m:phone_raw]]。", raw, node_id="write", run_id="r1")
assert not [v for v in doc["violations"] if v["code"] == "masked_cell"]
# --------------------------------------------------------------------------
# 真跑:写作目录、文档的证据目录
# --------------------------------------------------------------------------
async def test_the_writer_and_the_doc_see_masked_for_metrics_from_masked_cells(monkeypatch, vip):
"""写作目录里 phone_raw、tel_raw(改了名字的遮罩列)写「已遮罩」并说不要引用,号码一个都不出现;客户数、编号照给。
文档的证据目录里没被引用的 phone_raw、tel_raw 同样写「已遮罩」,整份文档没有号码。"""
seen = script(monkeypatch, CLEAN)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
lines = metric_lines(seen[0])
assert "首位客户手机号 = 已遮罩(取自设置了遮罩的列,不要引用)" in lines, lines
assert "首位客户联系电话 = 已遮罩(取自设置了遮罩的列,不要引用)" in lines and leaks(lines) == [], lines
assert "客户数 = 3位" in lines and "首位客户编号 = 1" in lines, lines
doc = await doc_of(row.id)
for alias, name in (("m:phone_raw", "首位客户手机号"), ("m:tel_raw", "首位客户联系电话")):
entry = doc["catalog"][alias]
assert (entry["value"], entry["rendered"], entry["label"], entry.get("masked")) == \
("已遮罩", "已遮罩", f"{name} = 已遮罩", True), entry
assert doc["catalog"]["m:first_id"]["value"] == 1 and doc["catalog"]["m:n_cust"]["value"] == 3
assert "masked" not in doc["catalog"]["m:first_id"] and "masked" not in doc["catalog"]["m:n_cust"]
assert leaks(doc) == []
finished = next(e.data for e in await events(row.id) if e.type == "node.finished" and e.node_id == "write")
assert artifact_store.load(finished["artifact"])["masked_metrics"] == ["m:phone_raw", "m:tel_raw"]
async def test_without_masks_metrics_are_given_as_before(monkeypatch, vip):
await set_source(vip, {})
seen = script(monkeypatch, CITES)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
assert f"首位客户手机号 = {PHONES[0]}" in metric_lines(seen[0])
doc = await doc_of(row.id)
entry = doc["catalog"]["m:phone_raw"]
assert str(entry["value"]) == PHONES[0] and "masked" not in entry, entry
finished = next(e.data for e in await events(row.id) if e.type == "node.finished" and e.node_id == "write")
assert "masked_metrics" not in artifact_store.load(finished["artifact"])
[checked] = [e.data for e in await events(row.id) if e.type == "report.checked"]
assert checked["ok"] is True and checked["repairs"] == 0
# --------------------------------------------------------------------------
# 真跑:报告自查交回重写、按违规处置办
# --------------------------------------------------------------------------
async def test_citing_the_metric_is_sent_back_and_the_rewrite_passes(monkeypatch, vip):
"""第一稿引用了 [[m:phone_raw]]:自查记 masked_cell、交回重写,重写稿不引用就通过。重写请求、日志里不写号码。"""
seen = script(monkeypatch, CITES, CLEAN)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
rows = await events(row.id)
[checked] = [e.data for e in rows if e.type == "report.checked"]
assert checked["ok"] is True and checked["repairs"] == 1, checked
repair = str(seen[1][-1].content)
assert "m:phone_raw" in repair and "遮罩" in repair and leaks(repair) == [], repair
[log] = [e.data for e in rows if e.type == "log" and e.data.get("code") == "report_repair"]
assert "m:phone_raw" in log["message"] and leaks(log) == []
async def test_a_writer_that_insists_is_flagged_without_the_value(monkeypatch, vip):
"""重写后还引用:「违规处置」为标注时照常产出,正文写「已遮罩」;自查结果、时间线、出具缺口都说清,都不写号码;出口
契约复核不说文档被改过(按节点产出里记下的同一份重建目录)。"""
script(monkeypatch, CITES)
row = await finish(graph(on_violation="flag", contract={"report_from": "write"}))
assert row.status == "succeeded", row.error
rows = await events(row.id)
[checked] = [e.data for e in rows if e.type == "report.checked"]
assert checked["ok"] is False and checked["repairs"] == 1 and not checked["failed"]
[v] = [v for v in checked["violations"] if v["code"] == "masked_cell"]
assert v["ref"] == "m:phone_raw" and v["metric"] == "phone_raw" and leaks(v) == []
doc = await doc_of(row.id)
assert "手机号 已遮罩" in doc["markdown"] and leaks(doc) == [], doc["markdown"]
[log] = [e.data for e in rows if e.type == "log" and e.data.get("code") == "report_masked_cell"]
assert log["refs"] == ["m:phone_raw"] and "指标" in log["message"] and leaks(log) == [], log
finished = next(e.data for e in rows if e.type == "node.finished" and e.node_id == "write")
[gap] = artifact_store.load(finished["artifact"])["issuance_gaps"]
assert "m:phone_raw" in gap and "指标" in gap and leaks(gap) == [], gap
issuance = row.output["_issuance"]
assert issuance["tier"] == "degraded", issuance
assert not [g for g in issuance["gaps"] if "未通过复核" in g], issuance["gaps"]
assert any("遮罩" in g for g in issuance["gaps"]) and leaks(issuance) == []
assert leaks(row.output) == []
async def test_a_writer_that_insists_fails_the_node_when_violations_fail(monkeypatch, vip):
script(monkeypatch, CITES)
row = await finish(graph(on_violation="fail"))
assert row.status == "failed" and "遮罩" in (row.error or "") and "m:phone_raw" in row.error, row.error
assert "指令中去掉" in row.error and "指令遵循能力" not in row.error and leaks(row.error) == []
async def test_the_evidence_panel_says_masked_and_renders_consistently(client, monkeypatch, vip):
"""证据面板点开引用 [[m:phone_raw]] 的那一处:值、渲染都是「已遮罩」,渲染一致(不报「渲染不一致」);整份答复没有号码。"""
script(monkeypatch, CITES)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
doc = await doc_of(row.id)
seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:phone_raw")
res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")
assert res.status_code == 200, res.text
body = res.json()
step = body["chain"][0]
assert (step["value"], step["rendered"], step["render_ok"]) == ("已遮罩", "已遮罩", True), step
assert leaks(body) == []
graph_body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
labels = {e["alias"]: e["label"] for e in graph_body["evidence"] if e["kind"] == "metric"}
assert labels["m:phone_raw"] == "首位客户手机号 = 已遮罩" and leaks(graph_body) == []
async def test_the_panel_follows_the_doc_when_it_says_a_metric_is_withheld(client, monkeypatch, vip):
"""文档的证据目录里记着这个指标的值不给(写作时认出来的),面板这时却认不出(数据源事后撤了遮罩这类,这里拿客户编号
装作是):照文档不给,值写「已遮罩」;渲染按目录里那个字比。没记的照旧给值。"""
from app.api.evidence import _Masks, _metric_chain, _sealed
script(monkeypatch, CLEAN)
row = await finish(graph(on_violation="flag"))
assert row.status == "succeeded", row.error
doc = await doc_of(row.id)
sealed = await _sealed(row.id)
seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:first_id")
entry = doc["catalog"]["m:first_id"]
[step, *_] = await _metric_chain(seg, seg["cite"], entry, doc, sealed, _Masks(sealed))
assert step["value"] == 1 and step["render_ok"] is True, step
masked = {**entry, "value": "已遮罩", "rendered": "已遮罩", "masked": True}
[step, *_] = await _metric_chain({**seg, "text": "已遮罩"}, seg["cite"], masked, doc, sealed, _Masks(sealed))
assert step["value"] == "已遮罩" and step["render_ok"] is True, step
async def test_a_report_in_a_loop_judges_its_masks_afresh_each_time(monkeypatch, vip):
"""报告撰写节点在循环里跑两次:第一次认出 phone_raw 不给、记进产出,第二次(这里假装数据源撤了遮罩)认不出。第二次建目录
不能沿用第一次产出里记下的那一份:沿用了,正文写「已遮罩」、产出里却没记,出口契约复核按产出重建目录,对不上就说文档
被改过。"""
import app.api.evidence as evidence_api
calls: list[list[str]] = []
real = evidence_api.withheld_metrics
async def first_only(catalog, **kwargs):
got = await real(catalog, **kwargs)
calls.append(got)
return got if len(calls) == 1 else []
monkeypatch.setattr(evidence_api, "withheld_metrics", first_only)
script(monkeypatch, CITES)
base = graph(on_violation="flag", contract={"report_from": "write"})
nodes = {n["id"]: n for n in base["nodes"]}
lp = node("lp", "loop", mode="foreach", items="[1, 2]")
spec = {"nodes": [*base["nodes"][:5], lp, nodes["write"], nodes["out"]],
"edges": [*[{"source": a["id"], "target": b["id"]} for a, b in zip(base["nodes"][:5], base["nodes"][1:5])],
{"source": "card", "target": "lp"},
{"source": "lp", "target": "write", "sourceHandle": "body"},
{"source": "write", "target": "lp"},
{"source": "lp", "target": "out", "sourceHandle": "done"}]}
row = await finish(spec)
assert row.status == "succeeded", row.error
assert len(calls) == 2 and calls[0] == ["m:phone_raw", "m:tel_raw"], calls
checks = [e.data for e in await events(row.id) if e.type == "report.checked"]
assert len(checks) == 2, checks
doc = await doc_of(row.id)
assert "已遮罩" not in doc["markdown"] and not any(e.get("masked") for e in doc["catalog"].values())
gaps = row.output["_issuance"]["gaps"]
assert not [g for g in gaps if "未通过复核" in g], gaps
+10 -4
View File
@@ -8,7 +8,8 @@
(api/evidence.py 的 _counted),证据面板、推演照可验证报告的认法多遮(展示上多遮无妨)
- 第 2 轮另按值认过正文里写着的遮罩值(照抄的号码、姓名,经指标带出来的值),第 3 轮去掉了:两个字的姓名会撞上「销售
高峰」这类常用词,同名列的值会撞上门店名,正式运行因此判失败,代价远大于收益;照抄的号码本来就会被「数字没有出处」
拦下(test_report_masked_owners)。自查只查 [[v:]] 引用,这里留着不按值认的回归
拦下(test_report_masked_owners)。自查查 [[v:]] 引用,和值取自遮罩格的指标的 [[m:]] 引用(按出处认,和证据面板指标
步骤的值同一个判断),这里留着不按值认的回归
真跑的部分用虚构的 SQLite 商城库(号码、姓名是编的),写作者是 MockChatModel 照剧本写。
"""
@@ -240,6 +241,8 @@ async def test_no_snapshot_is_read_without_a_cited_cell(engine_up, mall, monkeyp
METRICS = [
{"id": "phone_raw", "name": "首位客户手机号", "format": "plain", "expression": "cell(nodes.top, 0, 'tel')"},
{"id": "id3", "name": "第三位客户编号", "format": "plain", "expression": "cell(nodes.top, 2, 'id')"},
# 写死在口径卡上的热线号码,碰巧和第三位客户的号码一样:不取自查询结果
{"id": "hotline", "name": "服务热线", "format": "plain", "expression": f"'{PHONES[2]}'"},
]
@@ -252,10 +255,13 @@ def card_graph(**report) -> dict:
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
async def test_a_metric_reference_is_not_checked_by_value(monkeypatch, mall):
"""自查只查 [[v:]] 引用:[[m:phone_raw]] 的值就是号码,自查不按值认(指标的值在证据面板、推演里另有判断,写「已遮罩」,
见 test_masked_metric_values)。违规即失败的运行照常成功,时间线上没有「引用了遮罩列」那一行。"""
async def test_a_metric_reference_is_judged_by_where_its_value_comes_from(monkeypatch, mall):
"""[[m:phone_raw]] 的值取自改了名字的遮罩列(phone AS tel):和证据面板指标步骤的值同一个判断,写作目录、证据目录里写
「已遮罩」,引用了记 masked_cell,违规即失败的运行判失败(tests/test_report_masked_metrics.py)。这是按出处认,不是按值:
写死在卡上的热线号码碰巧和客户的号码一样,不取自查询结果,照常引用,运行照常成功、时间线上没有「引用了遮罩列」那一行。"""
row = await finish(monkeypatch, card_graph(on_violation="fail"), "第三位客户编号 [[m:id3]],首位客户电话 [[m:phone_raw]]。")
assert row.status == "failed" and "m:phone_raw" in (row.error or ""), row.error
row = await finish(monkeypatch, card_graph(on_violation="fail"), "第三位客户编号 [[m:id3]],服务热线 [[m:hotline]]。")
assert row.status == "succeeded", row.error
rows = await events(row.id)
checked = next(e.data for e in rows if e.type == "report.checked")
+7 -5
View File
@@ -256,14 +256,16 @@ async def test_the_whatif_answer_carries_no_masked_value(client, monkeypatch, me
async def test_the_evidence_panel_withholds_the_metric_value_by_the_same_rule(client, monkeypatch, members):
"""证据面板的指标步骤:用到遮罩列的指标 value 写「已遮罩」,报告上的字照旧;没用到的照给。"""
"""证据面板的指标步骤:用到遮罩列的指标 value 写「已遮罩」;报告撰写节点写作时按同一个判断把它在目录里换成了「已遮罩」,
报告上的字也是「已遮罩」(tests/test_report_masked_metrics.py),渲染一致。没用到的照给。"""
row = await run_graph(monkeypatch)
[report] = (await client.get(f"/api/runs/{row.id}/evidence")).json()["reports"]
doc = artifact_store.load(report["doc_artifact"])
by_text = {s["text"]: s["id"] for s in iter_segments(doc) if s.get("ref")}
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_text['11位']}")).json()
by_ref = {s["ref"]: s["id"] for s in iter_segments(doc) if s.get("ref")}
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_ref['m:phone_pos_len']}")).json()
metric = body["chain"][0]
assert (metric["step"], metric["value"], metric["rendered"]) == ("metric", "已遮罩", "11位")
assert (metric["step"], metric["value"], metric["rendered"], metric["render_ok"]) == \
("metric", "已遮罩", "已遮罩", True), metric
assert leaks(json.dumps(body, ensure_ascii=False)) == []
plain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_text['1']}")).json()
plain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{by_ref['m:id0']}")).json()
assert plain["chain"][0]["value"] == 1
+88 -24
View File
@@ -18,14 +18,17 @@
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
// 经继续运行跑到成功的早期运行、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列(卡的节点完成事件只给哈希)、同一张卡配上证据
// 目录里写着号码的老文档(文档只给哈希、页面写明原因)、设了遮罩的数据源上按地区汇总的查询、每种显示
// 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、
// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、前面
// 补一位数、隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数
// 挪到数字前面、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、
// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、整段
// 改成分隔线、文字片段冒用数字的 id;数字前面补一位数、加负号、后面接「0 万」,隔着显示时不占位置的控制字符补一位数、
// 长得像负号的连字符,这几种变红并点名「正文里紧挨着这个数的字改变了读者看到的数」;叠在空格上的横线、从右往左书写的
// 字母把一位数挪到数字前面判无法核对),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、
// 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、
// 报告文档标成遮罩(总览不是绿的)、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡
// 就这么标)、
// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、
// 只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写
// 「尚未核对」;
@@ -580,7 +583,7 @@ await section('样本页:各种形状', async () => {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 14, Object.keys(samples).join('、'))
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 15, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
@@ -703,9 +706,32 @@ await section('样本页:各种形状', async () => {
&& metricNumber.text.includes('含遮罩列'), metricNumber?.text)
check('masked-card:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' && s.text === '可核对的项全部通过'
&& !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`)
// 卡的节点完成事件预览里逐条写着「指标(名称)= 值」,号码那个指标也在里面:只给哈希,① 里不列它
const data = dataOf(samples['masked-card'])
const cardEvent = data.events.find((e) => e.type === 'node.finished' && e.node_id === 'card')
check('masked-card:口径卡的节点完成事件只给哈希,① 里不列它', !!cardEvent && !('canonical' in cardEvent)
&& !s.items.some((i) => i.check === 'events' && i.text.includes(`第 ${cardEvent.seq} 条`)), cardEvent?.seq)
// 报告文档是现在的报告撰写节点写的:证据目录里号码那个指标写「已遮罩」,文档照常随文件附带、正文照常显示
check('masked-card:报告文档照常随文件附带,正文照常显示', s.reports[0]?.why === '' && s.reports[0]?.parts.includes('文档 通过'),
JSON.stringify(s.reports[0]))
const html = await page.content()
check('masked-card:页面里找不到卡上那个遮罩列的号码', !html.includes('000-0001') && !html.includes('000-0002'))
})
// 同一张卡,报告文档是之前的报告撰写节点写的(证据目录里写着号码):整份只给哈希,页面写明原因;这份报告算未能核对,
// 总览不是绿的,但没有一项不通过(这是导出时就这样,不是被改过)
await sample('masked-old-doc', async (s, page) => {
const why = '证据目录里写着取自遮罩列的指标的值'
check('masked-old-doc:总览不是绿色的通过,写明有报告未能核对,没有一项不通过', s.overall === 'incomplete'
&& s.text.includes('份报告未能核对') && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`)
check('masked-old-doc:报告处写明证据目录里写着取自遮罩列的指标的值、未随文件附带(中性色)', s.reports[0]?.why === 'masked'
&& s.reports[0].warn.includes(why) && s.reports[0].warn.includes('未随文件附带'), s.reports[0]?.warn)
check('masked-old-doc:③ 报告文档无法核对、④ 无法确认它记下的运行和节点,原因写明', hasItem(s, 'artifacts', 'na', why)
&& hasItem(s, 'chain', 'na', why))
check('masked-old-doc:报告文档不随文件附带,它引用的口径卡、快照也不附带', dataOf(samples['masked-old-doc']).artifacts
.every((a) => a.kind === 'report_doc' && a.masked === true && !('b64' in a)))
const html = await page.content()
check('masked-old-doc:页面里找不到号码', !html.includes('000-0001') && !html.includes('000-0002'))
})
// 会员库设了号码遮罩(查询当时记下了,数据源现在也设着):按地区汇总的查询没碰号码列,快照随文件附带、数字核对得过;
// 选出了号码的那份只给哈希,原因写明这条查询用到或可能用到设了遮罩的列
await sample('masked-source', async (s, page) => {
@@ -836,13 +862,38 @@ await section('样本页:篡改', async () => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
// 口径卡有输入取自遮罩列时服务端照样只给 id(见样本页 masked-card),报告文档从来不会:把它标成遮罩只能是改过
// 证据目录里写着取自遮罩列的指标的值的老文档,服务端只给哈希、不附带它引用的工件(见样本页 masked-old-doc):伪造者把
// 一份好好的文档也标成遮罩,页面照服务端的说法写原因、正文不显示,可它引用的那几份工件还列着,已核对的事件和文档里却
// 没人引用它们,判不通过——藏不起一份报告还显示通过
await tamper(base, '报告文档标成遮罩', (data) => {
const doc = byKind(data, 'report_doc')
delete doc.b64
doc.masked = true
return '报告文档去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照和口径卡会因遮罩不随文件附带')]])
}, (s) => [
['总览不通过', s.overall === 'fail', `${s.overall}:${s.text}`],
['④ 它引用的工件没人引用,判不通过', hasItem(s, 'chain', 'fail', '已核对的事件和报告文档里都没有引用这份工件')],
['报告处写明未随文件附带,正文不显示', s.reports[0]?.why === 'masked' && s.numbers === 0, JSON.stringify(s.reports[0])],
])
// 只留报告文档、拿掉它引用的工件(和服务端只给哈希时一模一样):没有正文可核,这份报告算未能核对,总览不是绿的
await tamper(base, '报告文档标成遮罩并拿掉它引用的工件', (data) => {
const doc = byKind(data, 'report_doc')
delete doc.b64
doc.masked = true
data.artifacts = [doc]
return '报告文档去掉内容、标遮罩,只留它一份'
}, (s) => [
['总览不是绿色的通过,写明有报告未能核对', s.overall === 'incomplete' && s.text.includes('份报告未能核对'),
`${s.overall}:${s.text}`],
['报告处写明未随文件附带,正文不显示', s.reports[0]?.why === 'masked' && s.numbers === 0, JSON.stringify(s.reports[0])],
])
// 检索快照这类别的工件服务端从来不会因遮罩不随文件附带:标成遮罩只能是改过
await tamper(base, '查询快照以外的工件标成遮罩', (data) => {
const card = byKind(data, 'metric_set')
data.artifacts.push({ id: card.id.replace(/^./, (c) => (c === '0' ? '1' : '0')), kind: 'retrieval', masked: true })
return '多一份标着遮罩的检索快照'
}, (s) => [...turnsRed('artifacts')(s),
['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照、口径卡和报告文档会因遮罩不随文件附带')]])
// 遮罩卡在服务端被改过时,服务端照样只给 id、另标 mismatch(tests/test_bundle_masked_cards.py 守着服务端这一边):
// 不能因为不嵌内容就和正常的遮罩卡一样「可核对的项全部通过」
await tamper(samples['masked-card'], '遮罩卡标成服务端的文件与 id 不一致', (data) => {
@@ -986,19 +1037,33 @@ await section('样本页:篡改', async () => {
['这一句按原文显示,伪造的链接写法露在外面', sentence.includes('[84,613.65元](x 74,613.65元)'), sentence.slice(0, 80)],
]
}, { seal: true })
// 前面补一位数:读起来是 174,613.65元,绿色下划线只在 74,613.65元 下面
await tamper(samples.formats, '核对通过的数字前面补一位数', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += '1'
return { what: '本月销售额 174,613.65元', seg: segById(doc, 's4') }
}), (s, forged) => {
// 核对过的那段字不动,只改紧挨着它的普通正文,重算全部哈希(验收第二轮的绕过):前面补一位数读作 174,613.65元,前面加
// 负号读作 -2.35%,后面接「0 万」读作 45,678.50 万。读者看到的是另一个数:那个数变红、点名原因,总览不通过。以前只判
// 无法核对,总览照样是绿的「可核对的项全部通过」
const GLUED = '正文里紧挨着这个数的字改变了读者看到的数'
const failsBecause = (why) => (s, forged) => {
const btn = buttonOf(s, forged)
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes('紧挨着的正文里还有数字或负号'))
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'fail' && !i.folded && i.text.includes(why))
return [
['销售额判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
['⑤ 写明紧挨着的正文里还有数字或负号', !!item, item?.text ?? '没有这一项'],
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
['那个数字变红,不再标通过', btn?.result === 'fail', JSON.stringify(btn)],
[`⑤ 点名「${why}」`, !!item, item?.text ?? '没有这一项'],
['⑤ 里只有这一项不通过', s.items.filter((i) => i.check === 'numbers' && i.result === 'fail' && !i.folded).length === 1],
['总览不通过', s.overall === 'fail' && s.checks.numbers === 'fail', `${s.overall}:${s.text}`],
['①–④ 照样通过(伪造重算了全部哈希)', ['events', 'manifest', 'artifacts', 'chain'].every((k) => noFail(s.checks[k])),
describe(s)],
]
}, { seal: true })
}
const glued = [
['核对通过的数字前面补一位数', 's3', (t) => `${t}1`, 's4', '本月销售额 174,613.65元'],
['核对通过的数字前面加负号', 's12', (t) => `${t}-`, 's13', '转化率 -2.35%'],
['核对通过的数字后面接「0 万」', 's34', (t) => `0 万${t}`, 's33', '流水 45,678.50 万'],
]
for (const [label, at, change, target, what] of glued) {
await tamper(samples.formats, label, (data) => rewriteDoc(data, (doc) => {
segById(doc, at).text = change(segById(doc, at).text)
return { what, seg: segById(doc, target) }
}), failsBecause(GLUED), { seal: true })
}
// 一段改成分隔线:分隔线不显示句子,这一段里核对通过的数字读的人看不到,导出时不会出现
await tamper(samples.formats, '把一段改成分隔线', (data) => rewriteDoc(data, (doc) => {
doc.blocks[2].type = 'hr'
@@ -1019,9 +1084,9 @@ await section('样本页:篡改', async () => {
&& !s.buttons.some((b) => b.text.includes('84,613.65')), JSON.stringify(same)],
]
}, { seal: true })
// 紧挨着的字(经营月报,重算全部哈希):隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、
// 从右往左书写的字母把一位数挪到数字前面——读起来都不是核对过的那个数,判无法核对并写明原因。字一律按码点写,脚本
// 里不出现看不见、改变方向的字
// 紧挨着的字(经营月报,重算全部哈希):隔着显示时不占位置的控制字符补一位数、长得像负号的连字符,读者看到的是另一个
// 数,判不通过;叠在空格上的横线、从右往左书写的字母把一位数挪到数字前面,读起来可能不是核对过的那个数,判无法核对。
// 原因各自写明。字一律按码点写,脚本里不出现看不见、改变方向的字
const cp = (...points) => String.fromCodePoint(...points)
const naBecause = (why) => (s, forged) => {
const btn = buttonOf(s, forged)
@@ -1032,15 +1097,14 @@ await section('样本页:篡改', async () => {
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
]
}
const GLUED = '紧挨着的正文里还有数字或负号'
await tamper(samples.formats, '隔着显示时不占位置的控制字符补一位数', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += `1${cp(0x7f)}`
return { what: '销售额前面补「1」和一个 DEL', seg: segById(doc, 's4') }
}), naBecause(GLUED), { seal: true })
}), failsBecause(GLUED), { seal: true })
await tamper(samples.formats, '前面加一个长得像负号的连字符', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's12').text += cp(0x2010)
return { what: '转化率前面加 U+2010', seg: segById(doc, 's13') }
}), naBecause(GLUED), { seal: true })
}), failsBecause(GLUED), { seal: true })
await tamper(samples.formats, '叠在空格上的横线', (data) => rewriteDoc(data, (doc) => {
segById(doc, 's3').text += cp(0x336)
return { what: '销售额前面的空格上叠一道长横线(U+0336)', seg: segById(doc, 's4') }