mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
期 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:
+85
-12
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
# 有值但按口径卡的格式显示不出来(太小、太大、不是有限的数):引用了也是解析不了
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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') }
|
||||
|
||||
Reference in New Issue
Block a user