From abb2707904c9b4ca96fd4c49f866bf38cc177834 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 12 Oct 2026 17:23:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=9F=205=20=E9=AA=8C=E6=94=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20N1=EF=BC=9A=E5=8F=96=E8=87=AA=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=E6=A0=BC=E7=9A=84=E5=8F=A3=E5=BE=84=E5=8D=A1=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=E6=8A=A5=E5=91=8A=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A=EF=BC=9B=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=96=87=E5=AD=97=E6=A0=B8=E5=AF=B9=E5=A0=B5=E4=BD=8F?= =?UTF-8?q?=E7=B4=A7=E6=8C=A8=E7=9D=80=E6=94=B9=E5=AD=97=E7=9A=84=E7=BB=95?= =?UTF-8?q?=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收第二轮发现:口径卡 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),断言照新的正文写 --- backend/app/api/evidence.py | 97 ++++- backend/app/bundle/build.py | 222 +++++++++-- backend/app/bundle/template.html | 2 +- backend/app/bundle/verifier.js | 62 ++- backend/app/engine/evidence.py | 85 +++- backend/app/engine/nodes/report.py | 69 +++- backend/tests/bundle_samples.py | 54 ++- backend/tests/test_bundle_api.py | 13 +- backend/tests/test_bundle_build.py | 23 +- backend/tests/test_bundle_masked_cards.py | 13 +- backend/tests/test_bundle_masked_metrics.py | 365 +++++++++++++++++ backend/tests/test_bundle_shown.py | 38 +- backend/tests/test_evidence_masked_aliases.py | 9 +- backend/tests/test_evidence_metric_masks.py | 18 +- backend/tests/test_report_masked_metrics.py | 374 ++++++++++++++++++ backend/tests/test_report_masked_values.py | 14 +- backend/tests/test_whatif_masked_values.py | 12 +- scripts/check-bundle.mjs | 112 ++++-- 18 files changed, 1428 insertions(+), 154 deletions(-) create mode 100644 backend/tests/test_bundle_masked_metrics.py create mode 100644 backend/tests/test_report_masked_metrics.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index ba70c763..ef249f0e 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -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。""" diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index a04386d5..01cf43c6 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -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) diff --git a/backend/app/bundle/template.html b/backend/app/bundle/template.html index e8200d6e..f63b1beb 100644 --- a/backend/app/bundle/template.html +++ b/backend/app/bundle/template.html @@ -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; } diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 37a10191..fffdf58d 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -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 } } diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index b7fc13d7..40dd8db5 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -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: # 有值但按口径卡的格式显示不出来(太小、太大、不是有限的数):引用了也是解析不了 diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 3f3891f7..4d3a0279 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -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): diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index e8cae88b..8a792c9a 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -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")}) diff --git a/backend/tests/test_bundle_api.py b/backend/tests/test_bundle_api.py index 90395059..949071cc 100644 --- a/backend/tests/test_bundle_api.py +++ b/backend/tests/test_bundle_api.py @@ -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): diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index eaefd5b9..6edaec99 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -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")} diff --git a/backend/tests/test_bundle_masked_cards.py b/backend/tests/test_bundle_masked_cards.py index 78d104dd..4028abb8 100644 --- a/backend/tests/test_bundle_masked_cards.py +++ b/backend/tests/test_bundle_masked_cards.py @@ -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 = [ diff --git a/backend/tests/test_bundle_masked_metrics.py b/backend/tests/test_bundle_masked_metrics.py new file mode 100644 index 00000000..91e20045 --- /dev/null +++ b/backend/tests/test_bundle_masked_metrics.py @@ -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'', 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) diff --git a/backend/tests/test_bundle_shown.py b/backend/tests/test_bundle_shown.py index 4989ac6a..995feb94 100644 --- a/backend/tests/test_bundle_shown.py +++ b/backend/tests/test_bundle_shown.py @@ -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 里画成一道短横 diff --git a/backend/tests/test_evidence_masked_aliases.py b/backend/tests/test_evidence_masked_aliases.py index 0ccb5f11..1ae9083f 100644 --- a/backend/tests/test_evidence_masked_aliases.py +++ b/backend/tests/test_evidence_masked_aliases.py @@ -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"] diff --git a/backend/tests/test_evidence_metric_masks.py b/backend/tests/test_evidence_metric_masks.py index ce01d00d..9b557e9b 100644 --- a/backend/tests/test_evidence_metric_masks.py +++ b/backend/tests/test_evidence_metric_masks.py @@ -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): diff --git a/backend/tests/test_report_masked_metrics.py b/backend/tests/test_report_masked_metrics.py new file mode 100644 index 00000000..d23a1e72 --- /dev/null +++ b/backend/tests/test_report_masked_metrics.py @@ -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 diff --git a/backend/tests/test_report_masked_values.py b/backend/tests/test_report_masked_values.py index bb9fc26b..967d1d7d 100644 --- a/backend/tests/test_report_masked_values.py +++ b/backend/tests/test_report_masked_values.py @@ -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") diff --git a/backend/tests/test_whatif_masked_values.py b/backend/tests/test_whatif_masked_values.py index 36c55682..0e5050f3 100644 --- a/backend/tests/test_whatif_masked_values.py +++ b/backend/tests/test_whatif_masked_values.py @@ -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 diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 150cddda..95a9b2ea 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -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') }