diff --git a/backend/app/api/bundle.py b/backend/app/api/bundle.py index 8a2cfe25..00e973b0 100644 --- a/backend/app/api/bundle.py +++ b/backend/app/api/bundle.py @@ -21,7 +21,7 @@ from fastapi.responses import Response from sqlalchemy import select from app.api.coded import CodedHTTPException -from app.api.evidence import RUN_NOT_FOUND, _mask_fingerprint, _sealed +from app.api.evidence import RUN_NOT_FOUND, _mask_fingerprint, _Masks, _sealed, certain_masks from app.bundle.build import attachment_name, build_bundle, content_disposition, render_html from app.core.artifact_store import read_bytes from app.db.base import SessionLocal @@ -60,11 +60,15 @@ async def export_bundle(run_id: str) -> Response: .where(RunEvent.run_id == run_id, RunEvent.seq > bound).order_by(RunEvent.seq))] # 数据源现在设的遮罩:事后加的遮罩同样生效,和证据面板一个道理 masks = await _mask_fingerprint() + # 每份查询快照里确定取自遮罩列的列、这次运行查到过的遮罩值:认老文档时指标的值给不给,和报告撰写节点的写作目录 + # 同一个口径(SQL 认不准时不因此算遮罩列)。要查库、按 SQL 血缘认,在这里认好交进去 + sure, secrets = await certain_masks(sealed, _Masks(sealed)) exported_at = datetime.now(timezone.utc).isoformat(timespec="seconds") # 读工件、算哈希、base64 是同步的活,快照大的时候不该占着事件循环 page = await asyncio.to_thread(lambda: render_html(build_bundle( info, [*sealed.events, *later], manifest_seq=bound, manifest_hash=str(sealed.manifest_hash or ""), legacy=bool(sealed.seal.get("legacy")), graph=sealed.graph, source_masks=masks, + sure_columns=sure, sure_secrets=secrets, read=lambda artifact: read_bytes(artifact, verify=False), exported_at=exported_at))) return Response(page, media_type="text/html; charset=utf-8", headers={ "Content-Disposition": content_disposition(attachment_name(info), run_id=run_id), diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index ef249f0e..611d0f30 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -21,7 +21,8 @@ artifacts 表可以事后插行,run.output 是可以改写的一列,封存 options.mask_columns 里的列换成「已遮罩」,SQL 里给它们起了别名、套了函数得来的结果列也换 (_mask_sets,和可验证报告同一个函数,合并查询另按合并 SQL 认);用到这些列的指标,指标步骤的值、 证据图的标签也不给(_metric_masks,和推演同一个判断;报告撰写节点写作之前按同一个判断把这类指标在目录里写成「已遮罩」, -withheld_metrics);SQL、合并警告里写着的遮罩值(这次运行里确定取自遮罩列的值) +withheld_metrics,只是遮罩列只认确定的那一套,和单元格引用的自查同一个口径;可验证报告认老文档也按这个口径, +certain_masks);SQL、合并警告里写着的遮罩值(这次运行里确定取自遮罩列的值) 展示时换掉。一份快照的遮罩分析、收到的值按快照工件记在进程里(_MEMO),之后的请求不再读盘、不再分析 ——在有身份体系之前,这只减少暴露,不是安全边界:知道工件 id 的人照样能取到整份快照。 @@ -979,8 +980,8 @@ def _held_word(held: _Held) -> str: async def _metric_masks(metric: dict[str, Any], card_node: str, artifact: str, sealed: _Sealed, - masks: _Masks, *, output: Callable[[Any], Any] | None = None, by_value: bool = True - ) -> tuple[dict[str, _Held], str | None]: + masks: _Masks, *, output: Callable[[Any], Any] | None = None, by_value: bool = True, + sure: bool = False, depth: int = 0) -> tuple[dict[str, _Held], str | None]: """一个指标的输入里哪些不给值(路径 → _Held),指标本身的值给不给(None 照给,否则是写在值那里的字:「已遮罩」 「无法核对」)。证据面板的指标步骤、证据图的标签、报告撰写节点的写作目录(withheld_metrics)都按这一个函数认,和推演 同一套分类、同一个判断(recompute.withheld_value): @@ -993,8 +994,12 @@ async def _metric_masks(metric: dict[str, Any], card_node: str, artifact: str, s output(节点 id → 那个节点的产出)给了的,按位置取的一格顺着它走取值链,不去封存的事件里找口径卡算这一次之前的产出: 报告撰写节点写作时运行还没封存,产出就在运行状态里。by_value 为假(报告撰写节点的写作目录):认不出出处的输入不按值认, - 也不因此按值认指标的值——只按出处认,见 withheld_metrics。""" - hide = _InputMasks(sealed, masks, card_node, artifact, output=output) + 也不因此按值认指标的值——只按出处认,见 withheld_metrics。sure 为真(写作目录、可验证报告认老文档):遮罩列只按确定的 + 那一套认(_InputMasks 的 sure),不跟着证据面板宁可多遮。 + + 转抄上游口径卡的输入(nodes.<卡>.metrics[0].value、nodes.<卡>.text,recompute.card_input)按上游那几个指标认,同一个 + 函数往上追(depth 记追了几张卡,最多 recompute.CARD_DEPTH)。""" + hide = _InputMasks(sealed, masks, card_node, artifact, output=output, by_value=by_value, sure=sure, depth=depth) held: dict[str, _Held] = {} uncertain = False for item in metric.get("inputs") or []: @@ -1037,7 +1042,12 @@ async def withheld_metrics(catalog: dict[str, Any], *, nodes: dict[str, Any], le 产出走取值链(口径卡算的时候读到的就是它们) - 查询快照的数据源按证据台账认(ledger 的 query 条目),这次运行查到过的遮罩值从台账里的查询快照收 遮没遮核对不了的(「无法核对」)不算:写作时快照和产出刚落下,读不出来的只能是坏了的文件,宁可照给、不让写作者因此 - 改不掉;可验证报告那边另有兜底(bundle/build.py 只给哈希)。""" + 改不掉;可验证报告那边另有兜底(bundle/build.py 只给哈希)。 + + 遮罩列只按确定的那一套认(sure,和单元格引用的自查同一个口径:_cited_masks):SQL 认不准、证据面板整份遮的快照里, + 没确定取自遮罩列的普通金额、计数、只装着分档标签的列照给。以前跟着证据面板宁可多遮,WITH 写了列名表、VALUES 当表的 + 销售额也成了「已遮罩」,正式运行引用它就判失败;同一格按 [[v:…]] 引用却不报违规。转抄上游口径卡的指标 + (nodes.<卡>.metrics[0].value、nodes.<卡>.text)按上游那个指标认(_InputMasks._copied)。""" 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")} @@ -1055,8 +1065,12 @@ async def withheld_metrics(catalog: dict[str, Any], *, nodes: dict[str, Any], le 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 [] + # cell()、Agent 字段落在的快照先在线程里读好(读盘、复验哈希):在运行里认,不占着事件循环。转抄上游口径卡的指标要顺着 + # 上游卡的输入认,运行状态里每张口径卡的输入一并读好 + cards = [card for card in nodes.values() if isinstance(card, dict) and card.get("kind") == "metric_set"] + await masks.prefetch(str(item["artifact"]) for metric in [*(m for *_, m in found), + *(m for card in cards for m in card.get("metrics") or [])] + if isinstance(metric, dict) 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: @@ -1064,14 +1078,35 @@ async def withheld_metrics(catalog: dict[str, Any], *, nodes: dict[str, Any], le 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) + _, word = await _metric_masks(metric, card_node, artifact, view, masks, output=output, by_value=False, + sure=True) if word == MASKED: out.append(alias) return out +async def certain_masks(sealed: _Sealed, masks: _Masks) -> tuple[dict[str, frozenset[str]], Any]: + """可验证报告认老文档时用的遮罩(bundle/build.py 的 _Store.metric_word,sure):(封存范围内每份读得出来的查询快照 → + 确定取自遮罩列的结果列(小写,_cited_masks 的键,和写作目录同一个口径), 这次运行查到过的遮罩值(run_secrets))。 + + 可验证报告在线程里构建、不查库,这几样按证据面板的那一套先在这里认好交进去:老文档整份只给哈希的判断和写作目录 + 逐个指标一样(SQL 认不准时不因此算遮罩列),不另写一套。读不出来、和哈希对不上的快照不在里面:可验证报告按它的数据源 + 现在设的遮罩认,同 _InputMasks。""" + await masks.prefetch(sealed.queries) + out: dict[str, frozenset[str]] = {} + for artifact, info in sealed.queries.items(): + snap, ok = masks.fetch(artifact) + if ok is True and isinstance(snap, dict): + out[artifact] = frozenset(await masks.cited(artifact, (info or {}).get("source"), snap)) + return out, await masks.run_secrets() + + #: tool_cell、Agent 字段落在的查询快照读不出来:只知道数据源现在设的遮罩,查询当时记下的在快照里,遮没遮核对不了 SNAPSHOT_UNVERIFIED = "查询快照无法读取,核对不了这一格是否取自设置了遮罩的列" +#: 转抄的上游口径卡指标遮没遮核对不了(它的输入落在读不出来的快照上这类) +CARD_UNVERIFIED = "转抄的上游口径卡指标核对不了是否取自设置了遮罩的列" +#: 口径卡一张抄一张,追了 recompute.CARD_DEPTH 张还没追到头(或者循环里抄自己上一轮的产出):宁可多遮 +CARD_TOO_DEEP = f"转抄上游口径卡超过 {recompute.CARD_DEPTH} 层,追不到这个值最初取自哪里,按取自设置了遮罩的列处理" class _InputMasks: @@ -1088,15 +1123,24 @@ class _InputMasks: 一次请求里同一件工件只读一次、同一份快照的遮罩只认一次(记在 _Masks 上):按位置取了十几格的指标,不必把写变量的 节点那几 MB 的产出重读十几遍;证据图给每个指标认一遍输入,也不必把同一份快照反复读盘。 + + sure(写作目录、可验证报告认老文档):遮罩列只按确定的那一套认——读得出来的快照按单元格引用自查的 _cited_masks(_mask_sets + 的 sure,再按血缘排除值确实不取自遮罩列的),读不出来的按数据源现在设的列名。证据面板、推演照旧按 hidden 宁可多遮。 + + 转抄上游口径卡的输入(recompute.card_input:nodes.<卡>.metrics[0].value、vars.<卡的 assign_to>.metrics[0].value、 + nodes.<卡>.text)按上游那几个指标认(_copied,同一个 _metric_masks 往上追):上游的指标取自遮罩格,转抄它的这个输入 + 同样不给值。整张卡、清单文字这种不一定把哪个指标的值带进来的,同整份查询结果(table),指标的值另按值认。 """ def __init__(self, sealed: _Sealed, masks: _Masks, card_node: str, artifact: str, *, - output: Callable[[Any], Any] | None = None) -> None: + output: Callable[[Any], Any] | None = None, by_value: bool = True, sure: bool = False, + depth: int = 0) -> 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 + self._by_value, self._sure, self._depth = by_value, sure, depth def _fetch(self, artifact: str) -> tuple[Any, bool | None]: return self._masks.fetch(artifact) @@ -1106,7 +1150,8 @@ class _InputMasks: 为假,值按值认(_metric_masks)。""" origin = recompute.input_origin(item, self._output, wide=True) if origin is None: - return None, False + copied = recompute.card_input(item, self._output) + return (await self._copied(copied), True) if copied is not None else (None, False) if origin.unverified is not None: return _Held("unverified", list(origin.columns), origin.unverified), True used = list(dict.fromkeys(origin.columns)) @@ -1134,7 +1179,37 @@ class _InputMasks: async def _hidden(self, artifact: str | None, source: str | None, snap: dict[str, Any] | None) -> set[str]: info = self._sealed.queries.get(artifact) if artifact else None - return (await self._masks.sets(artifact, (info or {}).get("source") or source, snap)).hidden + name = (info or {}).get("source") or source + if self._sure and snap is not None: + return await self._masks.cited(artifact, name, snap) + sets = await self._masks.sets(artifact, name, snap) + return sets.sure if self._sure else sets.hidden + + async def _copied(self, copied: recompute.CardInput) -> _Held | None: + """转抄上游口径卡的输入不给值的原因:上游带着值的那几个指标里,有不给的(「已遮罩」)就不给,有遮没遮核对不了的 + 就核对不了;整张卡、清单文字(whole)同整份查询结果,记 table、指标的值另按值认。追得太深(卡抄卡超过 + recompute.CARD_DEPTH 张,循环里一张卡抄自己上一轮的产出)的追不到最初取自哪里,宁可多遮,按取自遮罩格算: + 核对不了的不给写作目录拦,号码就从这里出去了。""" + if self._depth >= recompute.CARD_DEPTH: + return _Held("masked", [], CARD_TOO_DEEP) + artifact = str(copied.card.get("artifact") or "") + words: list[str] = [] + columns: list[str] = [] + for index in copied.picked: + metric = copied.card["metrics"][index] + if not isinstance(metric, dict) or not metric.get("inputs"): + continue + held, word = await _metric_masks(metric, copied.node_id, artifact, self._sealed, self._masks, + output=self._given, by_value=self._by_value, sure=self._sure, + depth=self._depth + 1) + if word is not None: + words.append(word) + columns.extend(c for h in held.values() if h.reason is None for c in h.columns) + if MASKED in words: + return _Held("table" if copied.whole else "masked", list(dict.fromkeys(columns))) + if UNVERIFIED_VALUE in words: + return _Held("table" if copied.whole else "unverified", [], CARD_UNVERIFIED) + return None def _card_outputs(sealed: _Sealed, card_node: str, artifact: str) -> dict[str, str]: @@ -1449,6 +1524,7 @@ class _Masks: self._collected = False self._read: dict[str, tuple[Any, bool | None]] = {} self._sets: dict[tuple[str | None, str], _MaskSets] = {} + self._cited: dict[tuple[str, str], set[str]] = {} def fetch(self, artifact: str) -> tuple[Any, bool | None]: """取回一件工件(_fetch):一次请求里同一件只读一次。证据图要给每个指标认一遍输入,同一份快照、同一个节点的 @@ -1475,6 +1551,17 @@ class _Masks: self._sets[key] = found return self._sets[key] + async def cited(self, artifact: str | None, source: Any, snap: dict[str, Any]) -> set[str]: + """一份读得出来的查询快照里确定取自遮罩列的结果列(小写,_cited_masks 的键:单元格引用的自查按它判违规),一次请求里 + 同一份只认一次(_owned_columns 要按 SQL 血缘认,指标一个个认输入时不必反复算)。调用方只读不改。""" + key = (str(artifact or ""), str(source or "")) + if not artifact or key not in self._cited: + found = set(await _cited_masks(snap, source, self, artifact)) + if not artifact: + return found + self._cited[key] = found + return self._cited[key] + async def of(self, source: Any) -> tuple[set[str], bool]: name = str(source or "") if not name: diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 01cf43c6..b0a7d169 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -13,9 +13,11 @@ 嵌入,别的不嵌内容、只给 id。口径卡上记着每个输入的值和代入式,有输入取自遮罩列(cell() 的格、Agent 字段、按位置 取的 vars.res.rows[0][2],SQL 里改了名字的遮罩列也算,SQL 认不出结果里哪一列取自哪一列的整份快照的列都算)的整张卡 同样只给 id,认法和证据面板、推演同一个(recompute.input_origin);有指标的值不给的(metric_word:值取自遮罩列的 - 一格、一行,整行、下标不是字面量的也算,和报告撰写节点的写作目录同一个判断)也一样;服务端文件被改过的卡照样按卡上 - 记的输入认,解析不出一张卡的整张只给 id。报告文档的证据目录收着上游口径卡的全部指标,现在的报告撰写节点把值不给的 - 写成「已遮罩」;还写着原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给 id、不附带它引用的工件, + 一格、一行,整行、下标不是字面量的、转抄上游口径卡里这类指标的也算,遮罩列按这份文件宁可多遮的认法)也一样;服务端 + 文件被改过的卡照样按卡上记的输入认,解析不出一张卡的整张只给 id。报告文档的证据目录收着上游口径卡的全部指标,现在的 + 报告撰写节点把值不给的写成「已遮罩」;还写着原值的老文档(升级前生成的,或者生成之后数据源才设的遮罩)整份只给 id、 + 不附带它引用的工件——哪些指标的值不给和写作目录同一个判断(metric_word 的 sure:只按确定取自遮罩列的认,由调用方按 + 证据面板的那一套认好交进来,api/evidence.py 的 certain_masks),SQL 认不准时没确定取自遮罩列的普通金额照常附带。 文档里写成「已遮罩」的指标所在的口径卡一律只给 id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析 不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被报告引用的工件不嵌。 - 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。 @@ -23,7 +25,8 @@ 它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次 运行的封存核对(GET /api/runs/{id}/verify)对照,页面上醒目地写着这一点。 -主体是纯函数:不碰数据库,工件经 read 取原字节,数据源现在设的遮罩由调用方查好传进来(source_masks)。 +主体是纯函数:不碰数据库,工件经 read 取原字节,数据源现在设的遮罩由调用方查好传进来(source_masks),每份查询快照 +里确定取自遮罩列的列、这次运行查到过的遮罩值也一样(sure_columns、sure_secrets)。 """ from __future__ import annotations @@ -45,7 +48,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 carries_masked, input_origin, withheld_value +from app.engine.recompute import CARD_DEPTH, CardInput, card_input, carries_masked, input_origin, withheld_value from app.engine.schema import NodeType from app.engine.toolcalls import QUERY_PREFIX @@ -81,19 +84,26 @@ Reader = Callable[[str], "bytes | None"] class _Store: """这一次构建里读过的工件:原字节、解析出来的内容、查询快照带不带遮罩。每份只读一次盘。events 是封存范围内的 - 事件(认这次运行里查到过哪些遮罩值,seen)。""" + 事件(认这次运行里查到过哪些遮罩值,seen)。 + + sure_columns、sure_secrets 由调用方按证据面板的那一套认好交进来(api/evidence.py 的 certain_masks):每份查询快照里 + 确定取自遮罩列的结果列,和这次运行查到过的遮罩值(只收确定的那几列)。认老文档时指标的值给不给按它们认(metric_word + 的 sure),和报告撰写节点的写作目录同一个口径;没交的(直接调用 build_bundle 的地方)按这份文件自己宁可多遮的认法。""" def __init__(self, read: Reader, source_masks: Mapping[str, Any], - events: Sequence[tuple[int, str, str | None, Any]] = ()) -> None: + events: Sequence[tuple[int, str, str | None, Any]] = (), *, + sure_columns: Mapping[str, Iterable[str]] | None = None, sure_secrets: Any = None) -> None: self._read = read self._source_masks = source_masks self._events = events + self._sure_columns = sure_columns + self._sure_secrets = sure_secrets self._raw: dict[str, bytes | None] = {} self._json: dict[str, Any] = {} self._masked: dict[str, bool] = {} self._hidden: dict[str, set[str]] = {} self._seen: Any = None - self._words: dict[tuple[str, int], str | None] = {} + self._words: dict[tuple[str, int, bool], str | None] = {} self._docs: dict[str, bool] = {} self._sources: dict[str, str] | None = None @@ -140,6 +150,17 @@ class _Store: self._hidden[artifact] = _derived_columns(content, base) if base else set() return self._hidden[artifact] + def sure(self, artifact: str) -> set[str]: + """一份读得出来的查询快照里确定取自遮罩列的列(小写),调用方交进来的(sure_columns,和报告撰写节点的写作目录同一个 + 口径)。没交、或者这份不在里面的,按 hidden 宁可多遮:认不准就当它取自遮罩列。""" + found = self._sure_columns.get(artifact) if self._sure_columns is not None else None + return {str(c).lower() for c in found} if found is not None else self.hidden(artifact) + + def sure_seen(self) -> Any: + """这次运行查到过的遮罩值,只收确定取自遮罩列的那几列(调用方交进来的 sure_secrets,同证据面板的 run_secrets)。 + 没交 sure_columns 的按 seen。""" + return self._sure_secrets if self._sure_columns is not None else self.seen() + def seen(self) -> Any: """这次运行里查到过的遮罩值(inquiry._Secrets,只用按值认得出的写法:够长的数、两个字以上的文字):封存范围内 每份查询快照里值取自遮罩列的那几列(hidden)的格子。模型拿在遮罩查询里看到的号码去查别的表时,SQL 里没写遮罩列, @@ -185,21 +206,27 @@ class _Store: 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,和报告撰写节点的写作目录同一个判断)——按位置取的整行、下标不是字面量 - # 的一格,整份结果里带着遮罩列、值里就是号码的,上面只认字面量下标一格的那一套认不出,卡上记着的值照样是号码 + # 另按指标认:值不给的指标(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: + def metric_word(self, card: str, metric: dict[str, Any], events: Sequence[tuple[int, str, str | None, Any]], *, + sure: bool = False, depth: int = 0) -> 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)) + 值里就有这次运行查到过的遮罩值);"unverified" 遮没遮核对不了。和证据面板指标步骤的值同一个判断(api/evidence.py + 的 _metric_masks,by_value=False:输入的出处按 recompute.input_origin 的 wide 认,转抄上游口径卡的按上游那个指标认, + 最后交给 recompute.withheld_value)。只按出处认:认不出出处的输入(写死的目标、数据整形算出来的数)值碰巧等于遮罩列 + 里的某一格,不因此算——不然普通报告里的这种指标在文件里也核对不了,老文档也整份只给哈希。 + + 遮罩列两种认法: + - 默认(认口径卡能不能随文件附带,card_masked):按这份文件自己的认法宁可多遮(hidden:SQL 认不准时整份的列都算; + 快照读不出来、和 id 对不上的,按它的数据源现在设的遮罩),遮罩值用 seen。卡上记着输入的值,附带了就带出去了 + - sure(认老文档,doc_withheld):只按确定取自遮罩列的认(sure,调用方按写作目录的口径认好交进来),遮罩值用 + sure_seen。和报告撰写节点写作时认的一样:现在的报告撰写节点照给的普通指标,老文档里写着它也照常附带""" + key = (card, id(metric), sure) if key not in self._words: outputs = _card_outputs(events, card) @@ -214,24 +241,27 @@ class _Store: if not isinstance(item, dict): continue path = str(item.get("path")) - kind, _ = self._input_kind(item, output) + kind, _ = self._input_kind(item, output, events, sure=sure, depth=depth) 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) + uncertain=uncertain, secrets=self.sure_seen if sure else self.seen) return self._words[key] - def _input_kind(self, item: dict[str, Any], output: Callable[[Any], Any]) -> tuple[str | None, bool]: + def _input_kind(self, item: dict[str, Any], output: Callable[[Any], Any], + events: Sequence[tuple[int, str, str | None, Any]] = (), *, sure: bool = False, + depth: int = 0) -> tuple[str | None, bool]: """(一个输入不给值的原因, 认不认得出出处),同 api/evidence.py 的 _InputMasks.check:取自遮罩列的一格、一行是 "masked",整份结果是 "table"(它不一定把哪一格带进指标,指标的值另按值认),遮没遮核对不了的是 "unverified" (取值链上的产出读不出来、快照读不出来、结果没存快照只带着数据源名),给值的是 None。认不出出处的(常量、运行输入、 - 数据整形算出来的一格)第二项为假。""" + 数据整形算出来的一格)第二项为假。转抄上游口径卡的(recompute.card_input)按上游那几个指标认(_copied_kind)。""" origin = input_origin(item, output, wide=True) if origin is None: - return None, False + copied = card_input(item, output) + return (self._copied_kind(copied, events, sure, depth), True) if copied is not None else (None, False) if origin.unverified is not None: return "unverified", True used = list(dict.fromkeys(origin.columns)) @@ -240,7 +270,7 @@ class _Store: 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)) + hidden = self.sure(str(origin.artifact)) if sure else self.hidden(str(origin.artifact)) else: # 读不出来、和 id 对不上的快照:查询当时记下的遮罩在快照里,只能按它的数据源现在设的认 source = origin.source or (self.query_source(origin.artifact) if origin.artifact else None) @@ -252,6 +282,23 @@ class _Store: return ("table" if origin.table else "unverified"), True return None, True + def _copied_kind(self, copied: CardInput, events: Sequence[tuple[int, str, str | None, Any]], sure: bool, + depth: int) -> str | None: + """转抄上游口径卡的输入(nodes.<卡>.metrics[0].value、nodes.<卡>.text)不给值的原因,同 api/evidence.py 的 + _InputMasks._copied:上游带着值的那几个指标有不给的就不给,有遮没遮核对不了的就核对不了;整张卡、清单文字同整份结果 + ("table")。追得太深(recompute.CARD_DEPTH)的追不到最初取自哪里,宁可多遮,按取自遮罩格算。""" + if depth >= CARD_DEPTH: + return "masked" + artifact = str(copied.card.get("artifact") or "") + words = [self.metric_word(artifact, metric, events, sure=sure, depth=depth + 1) + for metric in (copied.card["metrics"][i] for i in copied.picked) + if isinstance(metric, dict) and metric.get("inputs")] + if "masked" in words: + return "table" if copied.whole else "masked" + if "unverified" in words: + return "table" if copied.whole else "unverified" + return None + def query_source(self, artifact: str) -> str | None: """一份查询快照的数据源名,按封存范围内的事件认(_query_sources):快照读不出来时认它的数据源现在设的遮罩。""" if self._sources is None: @@ -264,8 +311,9 @@ class _Store: 哈希、不嵌入,它的正文也不随节点完成事件带出去。现在的报告撰写节点把这些指标写成「已遮罩」、记 masked (evidence.withhold_metrics),不算。 - 哪些指标的值不给按 metric_word 认,只算「不给」("masked")的,和写作时报告撰写节点认的一样;遮没遮核对不了的、 - 目录里的口径卡读不出来或找不到这个指标的,按值认:值就是这次运行查到过的遮罩值才算(seen)。按解析出来的内容认, + 哪些指标的值不给按 metric_word 的 sure 认,只算「不给」("masked")的,和写作时报告撰写节点认的一样:SQL 认不准、 + 整份遮的快照里没确定取自遮罩列的普通金额,现在的报告撰写节点照给,老文档里写着它也照常附带。遮没遮核对不了的、 + 目录里的口径卡读不出来或找不到这个指标的,按值认:值就是这次运行查到过的遮罩值才算(sure_seen)。按解析出来的内容认, 不看哈希(同 card_masked):服务端的文件被改过,嵌进页面的照样是这份原字节。""" if doc_id not in self._docs: doc = self.content(doc_id) @@ -282,8 +330,8 @@ class _Store: 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())) + word = self.metric_word(str(artifact), metric, self._events, sure=True) if metric is not None else "unverified" + return word == "masked" or (word == "unverified" and carries_masked(entry.get("value"), self.sure_seen())) def _input_masked(self, item: dict[str, Any], output: Callable[[Any], Any]) -> bool: origin = input_origin(item, output) @@ -1031,6 +1079,8 @@ def build_bundle( legacy: bool = False, graph: Mapping[str, Any] | None = None, source_masks: Mapping[str, Any] | None = None, + sure_columns: Mapping[str, Iterable[str]] | None = None, + sure_secrets: Any = None, ) -> dict[str, Any]: """一次已封存运行 → 页面里的数据(bundle-data)。 @@ -1041,6 +1091,8 @@ def build_bundle( - manifest_seq / manifest_hash:封存范围(老运行是按结束事件推算的那个,legacy 为真)和封存哈希 - read:工件 id → 原字节(不复验哈希),没有就是 None - source_masks:数据源名 → 它现在设的遮罩列(只列设了的) + - sure_columns、sure_secrets:每份查询快照里确定取自遮罩列的结果列、这次运行查到过的遮罩值(只收确定的那几列), + 调用方按写作目录的口径认好交进来(api/evidence.py 的 certain_masks):认老文档时按它们认,见 _Store.doc_withheld """ graph = graph if isinstance(graph, Mapping) else {} node_types: dict[str, str] = {} @@ -1054,7 +1106,7 @@ def build_bundle( sealed = [e for e in events if e[0] <= manifest_seq] later = [e for e in events if e[0] > manifest_seq] - store = _Store(read, source_masks or {}, sealed) + store = _Store(read, source_masks or {}, sealed, sure_columns=sure_columns, sure_secrets=sure_secrets) artifacts = _Artifacts(store, sealed) reports = _report_events(sealed) diff --git a/backend/app/bundle/template.html b/backend/app/bundle/template.html index f63b1beb..65166965 100644 --- a/backend/app/bundle/template.html +++ b/backend/app/bundle/template.html @@ -37,9 +37,12 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } border: 1px solid var(--border); background: var(--panel); font-size: 18px; font-weight: 650; } .overall[data-overall="pass"] { color: var(--ok); background: var(--ok-soft); border-color: transparent; } .overall[data-overall="fail"] { color: var(--bad); background: var(--bad-soft); border-color: transparent; } -.overall[data-overall="incomplete"] { color: var(--na); background: var(--na-soft); border-color: var(--na); } +.overall[data-overall="incomplete"], .overall[data-overall="partial"] { color: var(--na); background: var(--na-soft); border-color: var(--na); } .overall-mark { font-size: 20px; line-height: 1; } .overall-sub { margin: 4px 2px 0; font-size: 13px; color: var(--dim); } +/* 总览不是绿色的通过时:真伪以系统里的封存核对为准,写在总览正下方,比封存说明醒目 */ +.overall-hint { margin: 8px 0 0; padding: 8px 10px; border-left: 3px solid var(--accent); border-radius: 6px; background: var(--panel); + color: var(--fg); font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } .seal { margin: 18px 0; padding: 14px 16px; border: 2px solid var(--accent); border-radius: 10px; } .seal h2 { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--dim); } .hash { display: block; font-size: 15px; font-weight: 600; line-height: 1.5; word-break: break-all; color: var(--fg); } @@ -154,6 +157,7 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font 正在核对…尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本

+

封存清单哈希

__BUNDLE_SEAL__ diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index fffdf58d..37c02b12 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -13,14 +13,17 @@ * render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字, * 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字 * 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过, - * 紧挨着写着数字、负号、小数点(读者看到的是另一个数)的判不通过,紧挨着显示成什么说不准的字,或者同一句里有改变 - * 文字方向的字,读起来可能不是这个值的判无法核对;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; + * 紧挨着写着数字、小数点、负号(读者看到的是另一个数)的判不通过;画着删除线、后面接着量级单位、紧挨着像负号的短横、 + * 长得像数字的字母、显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对;列表 + * 缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、检索快照这类工件标着 * 遮罩)一律算不通过:只可能是文件被改过。报告文档的证据目录里写着取自遮罩列的指标的值时,服务端只给它的哈希 - * (升级前生成的文档,或者生成之后数据源才设的遮罩),页面照实说明,这份报告算未能核对。这些只证明内部一致,真伪要拿 - * 封存哈希去系统里对照,页面上写明。 + * (升级前生成的文档,或者生成之后数据源才设的遮罩),页面照实说明,这份报告算未能核对。总览只有一项无法核对都没有、 + * 每份报告都核对上了才是绿色的通过(overallOf):有无法核对的项写「可核对的项全部通过,另有 N 项无法核对」,用中性色, + * 无法核对的项里可能正藏着读者看到的另一个数。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明;总览 + * 不是绿色的通过时,总览下面另醒目地写一遍(运行 ID、/verify 接口、定时任务通知里的清单哈希前 12 位)。 * * SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现, * 两者结果一致。纯 JS 实现启动时先拿标准测试向量自检,自检不过就不下任何结论。 @@ -62,9 +65,13 @@ } const T = { allPass: '全部通过', - passWithNa: '可核对的项全部通过', + passWithNa: (n) => `可核对的项全部通过,另有 ${n} 项无法核对`, failed: (n) => `有 ${n} 项不通过`, incomplete: (n) => `有 ${n} 份报告未能核对`, + /** 总览不是绿色的通过时醒目地写在下面:这个文件核对不了真伪,真伪以系统里的封存核对为准 */ + verifyHint: (run, prefix) => `这个文件只能核对内部是否一致,判断不了真伪:请在系统中打开运行 ${run},确认清单哈希与上方` + + `一致并点「核对」(接口 GET /api/runs/${run}/verify),或与定时任务通知里的清单哈希前 12 位「${prefix}」对照,` + + '一致才说明这些记录出自系统、未被改过。', tally: (p, f, n) => `通过 ${p} 项 · 不通过 ${f} 项 · 无法核对 ${n} 项`, broken: '页面数据无法解析,无法核对', selfTestFailed: '本页的 SHA-256 实现在这个浏览器中自检未通过,不给出核对结论', @@ -184,6 +191,10 @@ numGlued: '正文里紧挨着这个数的字改变了读者看到的数:前后紧挨着的数字、负号或小数点会和它连成另一个数', shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值', shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样', + shownStruck: '这个数画着删除线,或者紧挨着画了删除线的文字,读的人可能以为它已作废、以旁边的数为准', + shownScaled: '后面接着量级单位(万、亿、千、百、%、‰、k、M、B 这类)。系统显示这个数时不带它,读起来的数可能差了量级', + shownDash: '前面紧挨着一道像负号的短横,读起来可能是负数', + shownLookalike: '紧挨着长得像数字的字母或符号(l、I、O、上标数字、圈码这类),读起来可能多出一位', numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过', numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`, statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`, @@ -215,7 +226,8 @@ shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致', renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)' + '把口径卡或快照里的值(运行输入按引用的值)重新渲染一遍,与显示的文字逐字比对;核对通过的字在正文里原样显示,' - + '紧挨着它写着数字、负号或小数点的判不通过,紧挨着看不见的字或者同一句里有改变文字方向的字的判无法核对。', + + '紧挨着它写着数字、负号或小数点的判不通过;画着删除线、后面接着量级单位、紧挨着像负号的短横、长得像数字的字母' + + '或看不见的字,或者同一句里有改变文字方向的字,判无法核对。有一项无法核对,总览就不是绿色的通过。', schema: (s) => `文件格式 ${s}`, } @@ -828,31 +840,45 @@ // 紧挨着核对通过的数字、取值的字,按码点分几类(kindOf): // - digit:数字。Python 的 \d 认的全都算(全角的、数学字母区的、阿拉伯文的也是); - // - minus:负号。横线类标点(连字符、各种破折号,全角的、小号的也算;波浪形的不算),减号类符号(数学减号、上下标 - // 负号、修饰字母负号、粗减号),以及显示出来同样是一道横线的字:Unicode 易混字符表里映射到「-」「ー」的(汉字「一」、 - // 假名长音、制表符的横线这类),和在 Chrome 的常用字体里逐个画出来看是一道横线、高度和负号相当的字; + // - minus:负号。只认真当负号用的三个:半角的连字符减号、数学减号「−」、全角的「-」; + // - dash:长得像负号、平时却不当负号用的短横:连字符、不断行连字符、数字宽的横线、小号的连字符减号、上下标负号、修饰 + // 字母负号、粗减号、别的文字里的连字符。不判不通过:紧挨着数字开头、前面又不是字母数字时,读起来可能是负数,判无法 + // 核对。破折号(——、—、–、―,小号的、竖排的)、波浪线、汉字「一」和显示出来同样是一道横线的字(假名长音、制表符 + // 的横线、注音和谚文里的横这类)不在这里,是正文里的普通写法(「运费统一10元」「合计——7,036.50元」),当 other; // - sep:半角逗号、句点和长得一样的窄标点(低位单引号、单点前导符、小号的逗号句点这类,取自易混字符表):「1,」后面 // 接着数字也算紧挨着。全角的「,」「.」不算:字宽,中文里两个数之间本来就常用「,」隔开; + // - unit:量级单位(万、亿、千、百、兆,繁体的萬、億,%、‰、‱,k、K、M、B,全角写法也算):接在数字后面,读者看到 + // 的数差了量级。系统显示一个数时单位、万 / 亿换算都在核对过的那段字里,不会另接在后面,判无法核对; + // - look:长得像数字、却不是数字的字:拉丁字母 l、I、O(全角的也算),上下标数字、圈码、罗马数字、汉字「〇」这类数字 + // 符号。紧挨着数字,读起来可能多出一位(「l74,613.65元」),判无法核对; // - skip:显示时不占位置的字(零宽空格、连接符、变体选择符、韩文填充符这类格式字符和默认不显示的字),隔着它们照样 // 算紧挨着; // - blur:显示成什么说不准的字:控制字符(制表、换行这几个空白除外)、未分配和私用区的字、孤立的代理项、组合附加符号 // (叠在前一个字上:叠在空格上能在数字前面画出一道横线)、可选连字符(在它那里换行时显示成连字符)、发丝空格、对象 // 替换符,以及在 Chrome 的常用字体里量出来几乎不占宽度的几个字。各家浏览器、字体把它们显示成数字、横线还是什么都 // 不显示说不准:隔着它们照样往外找数字、负号;找不到的也判无法核对,原因另写; - // - other:别的字(汉字、字母、空格、别的标点)把数字隔开。 + // - other:别的字(汉字、别的字母、空格、别的标点)把数字隔开。 // 改变文字方向的控制字符(BIDI)和从右往左书写的文字(RTL:希伯来文、阿拉伯文这类)另算:它们能把一位数挪到数字的 // 另一边显示(逻辑上在数字后面的「5」显示在数字前面),按字的先后看不出来,同一句里出现就判无法核对 const DIGIT = unicode('^\\p{Nd}$') || /^[0-9\uff10-\uff19]$/ - const DASH_LIKE = '\\u02d7\\u0c7c\\u1173\\u172d\\u174d\\u180a\\u1b78\\u1bc7\\u1bf3\\u1c7c\\u2043\\u207b\\u208b\\u2212' - + '\\u23af\\u23ba-\\u23bd\\u23e4\\u2500\\u2501\\u2504\\u2505\\u2508\\u2509\\u254c\\u254d\\u2550\\u2574\\u2576\\u2578' - + '\\u257a\\u257c\\u257e\\u2796\\u29ff\\u2cba\\u2cbb\\u2d67\\u2f00\\u30fc\\u3127\\u3161\\u3192\\u31d0\\u4e00\\ua7f7' - + '\\uaa7d\\uff70\\uffda' - const DASH_LIKE_ASTRAL = '\\u{10110}\\u{10191}\\u{11049}\\u{1104b}\\u{11052}\\u{110be}\\u{13404}\\u{1bc03}\\u{1bc08}' - + '\\u{1bc0d}\\u{1bc86}\\u{1d369}' - const MINUS = unicode(`^(?![\\u301c\\u3030])[\\p{Pd}${DASH_LIKE}${DASH_LIKE_ASTRAL}]$`) - || new RegExp(`^[-\\u2010-\\u2015\\u2e3a\\u2e3b\\ufe58\\ufe63\\uff0d${DASH_LIKE}]$`) + const MINUS = /^[-\u2212\uff0d]$/ + // \p{Pd} 里去掉负号和正文里的破折号、波浪线(这几个另算),再加上码点不在 \p{Pd} 里的减号类符号 + const DASH = unicode('^(?![-\\u2013-\\u2015\\u2e3a\\u2e3b\\u301c\\u3030\\ufe31\\ufe32\\ufe58\\uff0d])' + + '[\\p{Pd}\\u02d7\\u207b\\u208b\\u2043\\u2796]$') + || /^[\u058a\u1400\u1806\u2010-\u2012\u2e17\u2e1a\u2e40\u30a0\ufe63\u02d7\u207b\u208b\u2043\u2796]$/ const SEP = unicode('^[.,\\u00b8\\u201a\\u2024\\u2e12\\ua4f8\\ua4f9\\ua60e\\uabec\\ufe50\\ufe52\\u{16f53}\\u{1d16d}]$') || /^[.,\u00b8\u201a\u2024\u2e12\ua4f8\ua4f9\ua60e\uabec\ufe50\ufe52]$/ + const UNIT = /^[万亿千百兆萬億%\uff05\u2030\u2031kKMB\uff4b\uff2b\uff2d\uff22]$/ + // 量级单位里本身就能接在数后面的那几个汉字:核对过的字以它们结尾(换算成万的「4.57万」),再接一个也差了量级(「4.57万亿」) + const UNIT_HAN = /^[万亿千百兆萬億]$/ + const LOOK = unicode('^[lIO\\uff4c\\uff29\\uff2f\\p{No}\\p{Nl}]$') + || new RegExp('^[lIO\\uff4c\\uff29\\uff2f\\u00b2\\u00b3\\u00b9\\u00bc-\\u00be\\u2070-\\u2079\\u2080-\\u2089' + + '\\u2150-\\u2189\\u2460-\\u249b\\u24ea-\\u24ff\\u2776-\\u2793\\u3007\\u3021-\\u3029\\u3038-\\u303a]$') + // 连字符减号前面的字母数字(拉丁、希腊、西里尔字母,全角的也算):「SKU-12」「A-3」是编号的一部分,不是负号 + const WORDY = unicode('^[\\p{Script=Latin}\\p{Script=Greek}\\p{Script=Cyrillic}]$') + || /^[A-Za-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff\uff21-\uff3a\uff41-\uff5a]$/ + // 量级单位前面可以隔着的空白:「45,678.5 万」照样读作四亿多 + const BLANK = /^[ \u00a0\u3000]$/ // 变体选择符只换前一个字的字形(文字样式还是表情样式),算 skip:表情后面常带一个,不能因此把数字判成核对不了 const VARIATION = unicode('^[\\u180b-\\u180d\\u180f\\ufe00-\\ufe0f\\u{e0100}-\\u{e01ef}]$') || /^[\u180b-\u180d\u180f\ufe00-\ufe0f]$/ @@ -871,7 +897,10 @@ if (VARIATION.test(c)) return 'skip' if (DIGIT.test(c)) return 'digit' if (MINUS.test(c)) return 'minus' + if (DASH.test(c)) return 'dash' if (SEP.test(c)) return 'sep' + if (UNIT.test(c)) return 'unit' + if (LOOK.test(c)) return 'look' if (BLUR.test(c)) return 'blur' return SKIP.test(c) ? 'skip' : 'other' } @@ -883,12 +912,15 @@ * 每一项记下正文里显示出来的字(onPage)。 * - 正文里根本不显示它(分隔线里、表格里不在各行各列上的格):伪造的字可以写在别处,读的人看不到核对过的那个数。 * 服务端导出时不会出现(分隔线不带句子,表格的格都在行列上),判不通过; - * - 紧挨着的字里写着数字、负号或小数点(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置、显示成什么说不准 - * 的字,显示时藏起来的排版标记也算紧挨着):读者看到的是另一个数,判不通过。伪造者不动核对过的那段字、只改旁边的 - * 正文,重算全部哈希就做得到;以前判无法核对,总览照样写「可核对的项全部通过」。服务端组装的文档不这样排:两个数 - * 之间补空格(evidence._glue),原样导出照样通过。两个数之间的连字符是区间,不算负号; - * - 紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的这几位,判无法核对—— - * 真报告里也可能有,不能说成被改过。这两样不会把上一条的不通过降成无法核对。 + * - 紧挨着的字里写着数字、小数点(前面补一位、后面接一位或一段小数;隔着不占位置、显示成什么说不准的字,显示时藏起来 + * 的排版标记也算紧挨着),或者紧挨着数字开头的是负号、负号前面又不是字母数字(「SKU-12」的连字符是编号的一部分): + * 读者看到的是另一个数,判不通过。伪造者不动核对过的那段字、只改旁边的正文,重算全部哈希就做得到。服务端组装的文档 + * 不这样排:两个数之间补空格(evidence._glue),写作者自己写在引用前面的字(「统一」「——」「SKU-」)都不在这几样 + * 里,原样导出照样通过。两个数之间的连字符是区间,不算负号; + * - 这一句里有改变文字方向的字;核对过的字画着删除线,或者紧挨着画了删除线的字;后面接着量级单位(中间隔着空格也算); + * 前面紧挨着像负号的短横;紧挨着长得像数字的字母、数字符号;紧挨着显示成什么说不准的字:读起来可能不是核对过的那个 + * 数,判无法核对,原因各自写明——真报告里也可能有,不能说成被改过。总览里有一项无法核对就不是绿色的通过 + * (overallOf)。这几样不会把上一条的不通过降成无法核对。 * 代码块按原文显示、不标数字,照旧 */ function checkLayout(doc, items) { @@ -902,7 +934,7 @@ continue } for (const flow of flowsOf(block)) { - // 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段 + // 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段、带着什么行内样式(删除线要看) const shownUnits = [] for (const unit of flow) { const { segs, text, styles, at } = unitPlan(unit) @@ -911,7 +943,7 @@ for (let k = at[i][0]; k < at[i][1]; k++) { if (styles.hide[k]) continue shown += text[k] - shownUnits.push([text[k], seg]) + shownUnits.push([text[k], seg, styles.style[k]]) } if (items.has(seg)) items.get(seg).onPage = shown }) @@ -921,11 +953,11 @@ // 字不和外面的拼 const chars = [] for (let k = 0; k < shownUnits.length; k++) { - const [c, seg] = shownUnits[k] + const [c, seg, style] = shownUnits[k] const next = shownUnits[k + 1] const paired = next && c >= '\ud800' && c < '\udc00' && next[0] >= '\udc00' && next[0] < '\ue000' && (next[1] === seg || (!items.has(seg) && !items.has(next[1]))) - chars.push(paired ? [c + shownUnits[++k][0], seg] : [c, seg]) + chars.push(paired ? [c + shownUnits[++k][0], seg, style] : [c, seg, style]) } // 可点的数字自成一块,它里面的字改变不了外面的方向:只看别的片段 const turned = chars.some(([c, seg]) => !items.has(seg) && (BIDI.test(c) || RTL.test(c))) @@ -948,6 +980,9 @@ } return { ...NOWHERE, blurred } } + const struck = (j) => j >= 0 && (chars[j][2] & BIT.strike) !== 0 + // 前一个字算不算字母数字:负号前面是它的,是「SKU-12」这类编号的一部分 + const wordy = (at) => at >= 0 && (kinds[at] === 'digit' || kinds[at] === 'look' || WORDY.test(chars[at][0])) for (const [seg, item] of items) { if (item.result !== PASS || !spans.has(seg)) continue const [s0, s1] = spans.get(seg) @@ -963,18 +998,28 @@ const r1 = look(s1, 1) const r2 = r1.at >= 0 ? look(r1.at, 1) : NOWHERE const before = kinds[first] === 'digit' && (l1.kind === 'digit' || (l1.kind === 'sep' && l2.kind === 'digit')) - const sign = lead && l1.kind === 'minus' && l2.kind !== 'digit' && !(l2.at >= 0 && numeric(chars[l2.at][1])) + // 负号、像负号的短横紧挨着数字开头,前面不是字母数字、也不是另一个数(两个数之间是区间) + const signed = lead && (l1.kind === 'minus' || l1.kind === 'dash') && !wordy(l2.at) + && !(l2.at >= 0 && numeric(chars[l2.at][1])) const after = tail && (r1.kind === 'digit' || (r1.kind === 'sep' && r2.kind === 'digit')) + // 后面的量级单位:中间隔着空格也算(「45,678.5 万」) + let ru = r1 + while (ru.at >= 0 && BLANK.test(chars[ru.at][0])) ru = look(ru.at, 1) + const scaled = (tail || UNIT_HAN.test(chars[last][0])) && ru.kind === 'unit' + const crossed = chars.slice(s0, s1 + 1).some((_, j) => struck(s0 + j)) || struck(l1.at) || struck(r1.at) + const lookalike = (kinds[first] === 'digit' && l1.kind === 'look') || (tail && r1.kind === 'look') // 数字头尾紧挨着(或隔着一个逗号、句点)的是说不准的字:它本身就可能显示成数字、负号 const near = (a, b) => a.blurred || (a.kind === 'sep' && b.blurred) const blurred = (lead && near(l1, l2)) || (tail && near(r1, r2)) - if (before || sign || after) { + if (before || (signed && l1.kind === 'minus') || 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) }) + } else { + const why = turned ? T.shownTurned : crossed ? T.shownStruck : scaled ? T.shownScaled + : signed ? T.shownDash : lookalike ? T.shownLookalike : blurred ? T.shownBlurred : null + if (why) Object.assign(item, { result: NA, reason: T.numShownUnknown(why) }) } } } @@ -984,6 +1029,23 @@ } } + /** + * 只看正文的排法(checkLayout):文档里每个解析成功的数字、取值都当作核对通过,交回 [{ id, ref, result, reason }]。 + * node 里用(tests/test_bundle_shown.py 拿各种写法逐条核;也拿开发数据里的报告文档扫一遍,看真报告会不会被误判) + */ + function layoutOf(doc) { + const items = new Map() + for (const [seg] of segmentsOf(doc)) { + const cite = isObj(seg.cite) ? seg.cite : {} + if ((seg.kind === 'number' || seg.kind === 'value') && cite.status === 'resolved') { + items.set(seg, { result: PASS, reason: '' }) + } + } + checkLayout(doc, items) + return [...items].map(([seg, item]) => ({ id: seg.id ?? null, ref: seg.ref ?? null, text: String(seg.text ?? ''), + result: item.result, reason: item.reason || '', onPage: item.onPage ?? null })) + } + // ------------------------------------------------------------------ 核对 function push(map, key, value) { @@ -1114,7 +1176,7 @@ // 三种都只给 id,检索快照这类别的工件不会。 // 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来 // (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了。报告文档 - // 只给哈希时页面上没有正文可核,总览不是绿色的通过(renderOverall 按报告文档没核对上算) + // 只给哈希时页面上没有正文可核,总览不是绿色的通过(overallOf 按报告文档没核对上算) if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc') { item.result = FAIL item.reason = T.maskedOdd @@ -1818,18 +1880,34 @@ h('span', { class: 'what' }, `${T.chainByEvent(ev.seq, eventWhat(ev))}${ev.summary ? `:${ev.summary}` : ''}`))))) } - function renderOverall(out) { + /** + * 总览:{ result, text, pass, fail, na }。result 是 pass(绿)、fail(红)、incomplete(有报告文档核对不了)、partial + * (可核对的都通过,另有几项无法核对)。只有一项都没有无法核对、每份报告都核对上了才是绿色的通过:无法核对的项里可能 + * 正藏着读者看到的另一个数(紧挨着改的量级单位、删除线这类),以前照样给绿色的「可核对的项全部通过」。不是绿色的, + * 页面另醒目地写明真伪要去系统里核对封存(renderOverall) + */ + function overallOf(out) { const all = [...out.events, ...out.manifest, ...out.artifacts, ...out.chain, ...out.numbers, ...out.meta] const count = (r) => all.filter((i) => i.result === r).length const [pass, fail, na] = [count(PASS), count(FAIL), count(NA)] - // 报告文档核对不了(导出时服务端已没有文件、读不懂):页面上没有可核的正文,不能写成绿色的「通过」 + // 报告文档核对不了(导出时服务端已没有文件、读不懂、因遮罩不随文件附带):页面上没有可核的正文 const unverified = out.docs.filter((d) => !d.trusted).length - const result = fail ? FAIL : unverified ? 'incomplete' : PASS + const result = fail ? FAIL : unverified ? 'incomplete' : na ? PARTIAL : PASS + const text = fail ? T.failed(fail) : unverified ? T.incomplete(unverified) : na ? T.passWithNa(na) : T.allPass + return { result, text, pass, fail, na } + } + + function renderOverall(out, data) { + const { result, text, pass, fail, na } = overallOf(out) const box = $('overall') box.setAttribute('data-overall', result) - box.querySelector('.overall-mark').textContent = fail ? MARK.fail : unverified ? MARK.na : MARK.pass - $('overall-text').textContent = fail ? T.failed(fail) : unverified ? T.incomplete(unverified) : na ? T.passWithNa : T.allPass + box.querySelector('.overall-mark').textContent = MARK[result] || MARK.na + $('overall-text').textContent = text $('overall-sub').textContent = T.tally(pass, fail, na) + const hint = $('overall-hint') + const run = String(data.meta.run_id ?? '') + hint.textContent = result === PASS ? '' : T.verifyHint(run, String(data.meta.manifest_hash ?? '').slice(0, 12)) + hint.hidden = result === PASS } function renderFoot(data, kind) { @@ -1869,7 +1947,7 @@ renderChecks(view.out) renderAppended(data) renderFoot(data, picked.kind) - renderOverall(view.out) + renderOverall(view.out, data) $('detail-close').addEventListener('click', closeDetail) document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail() }) document.documentElement.setAttribute('data-verified', '') @@ -1878,7 +1956,8 @@ // 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停 if (typeof document === 'undefined') { if (typeof module === 'object' && module) { - module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells } + module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells, + layoutOf, overallOf } } return } diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 40dd8db5..02f3c2da 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -978,10 +978,10 @@ def withhold_metrics(catalog: dict[str, Any], aliases: Iterable[str], word: str) """目录里值不给的指标(aliases,指标的别名):值、渲染、标签里的值都换成 word(「已遮罩」),另记 masked。交回一份新的 目录,原来那份不动;没有要换的原样交回。 - 哪几个不给由报告撰写节点按证据面板指标步骤的那一套认(api/evidence.py 的 withheld_metrics,只按出处认:cell() 取的 - 遮罩格、取自改了名字的遮罩列、按位置取自遮罩列的一格都算,值碰巧等于遮罩值的普通指标不算)。写作目录、存进报告文档的 - 证据目录都从这份目录来:目录收着上游口径卡的全部指标, - 没被引用的也在里面,原值写着就随文档、可验证报告出去了。引用它的照样解析、正文写 word(resolve_marker),报告自查另记 + 哪几个不给由报告撰写节点认(api/evidence.py 的 withheld_metrics,证据面板指标步骤的那一套,只按出处、只按确定取自 + 遮罩列的认,和单元格引用的自查同一个口径:cell() 取的遮罩格、取自改了名字的遮罩列、按位置取自遮罩列的一格、转抄上游 + 口径卡里这类指标的都算;值碰巧等于遮罩值的普通指标、SQL 认不准时没确定取自遮罩列的普通金额不算)。写作目录、存进报告 + 文档的证据目录都从这份目录来:目录收着上游口径卡的全部指标,没被引用的也在里面,原值写着就随文档、可验证报告出去了。引用它的照样解析、正文写 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: @@ -3457,8 +3457,8 @@ def masked_metrics(doc: dict[str, Any], catalog: dict[str, Any]) -> list[dict[st """正文引用了值不给的指标(目录条目记着 masked,withhold_metrics):每处一条违规 masked_cell,和引用遮罩格同一种违规、 同一套处置——交回写作者重写,仍不改的按「违规处置」办(report.py)。依据 [[see:m:…]] 不显示值,不算。 - 值不给的是值取自遮罩列一格的指标(cell() 取的遮罩格、取自改了名字的遮罩列),按出处认、和证据面板指标步骤的值同一套, - 由报告撰写节点认好写进目录;这里只认目录,查不了库。违规带着引用、指标 id、span 和所在片段,不带值(正文里那一处已经写成 + 值不给的是值取自遮罩列一格的指标(cell() 取的遮罩格、取自改了名字的遮罩列、转抄上游口径卡里这类指标的),只按出处、 + 只按确定取自遮罩列的认(和单元格引用的自查同一个口径),由报告撰写节点认好写进目录;这里只认目录,查不了库。违规带着引用、指标 id、span 和所在片段,不带值(正文里那一处已经写成 「已遮罩」,违规会进 report.checked、重写的日志和出具缺口)。""" found: list[dict[str, Any]] = [] for _, unit in iter_units(doc): diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 4d3a0279..5a33171f 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -5,8 +5,8 @@ 挪到写的那一刻: 1. 建目录:收齐上游口径卡的指标和运行输入,编成 [[m:gmv]]、[[i:week]] 这样的引用。值取自遮罩格的指标(cell() 取的 - 遮罩格、取自改了名字的遮罩列,和证据面板指标步骤的值同一个判断)在目录里写「已遮罩」:写作目录、存进文档的证据目录 - 都不写原值,引用了的照样交回重写(报告自查记 masked_cell) + 遮罩格、取自改了名字的遮罩列、转抄上游口径卡里这类指标的;只按确定取自遮罩列的认,和单元格引用的自查同一个口径) + 在目录里写「已遮罩」:写作目录、存进文档的证据目录都不写原值,引用了的照样交回重写(报告自查记 masked_cell) 2. 流式写作:模型写标记,StreamRenderer 边收边换成真值,流里看到的就是最终的数字 3. 核对:compose_doc 切块、分句、切片段,找出裸数字和解析不了的引用。模型把整篇包在一个 ```markdown 代码块 里的,先去掉外层围栏(_unfenced):不然整篇是一个代码块,一句结论都切不出来 @@ -629,8 +629,10 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 目录按上游的产出建:这个节点自己上一次的产出(循环里重跑时)不算,它记下的 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)。记进产出,出口契约复核按同一份重建目录 + # 值取自遮罩格的指标(cell() 取的遮罩格、取自改了名字的遮罩列、转抄上游口径卡里这类指标的):写作目录、存进文档的 + # 证据目录里都写「已遮罩」。只按确定取自遮罩列的认,和单元格引用的自查同一个口径(api/evidence.py 的 withheld_metrics): + # SQL 认不准时没确定取自遮罩列的普通金额照给,不然引用它的正式运行判失败、写作者也改不掉。记进产出,出口契约复核按 + # 同一份重建目录 from app.api.evidence import withheld_metrics withheld = await withheld_metrics(catalog, nodes=state.get("nodes") or {}, ledger=state.get("evidence") or []) diff --git a/backend/app/engine/recompute.py b/backend/app/engine/recompute.py index f28ce514..a7a75d22 100644 --- a/backend/app/engine/recompute.py +++ b/backend/app/engine/recompute.py @@ -575,6 +575,72 @@ _GONE = object() #: 下标不是字面量(rows[vars.i]、rows[len(vars.res.rows) - 1]):wide 时认作「哪一行、哪一列说不准」 _ANY = object() +#: 转抄上游口径卡的取值链最多往上追几张卡(卡 3 抄卡 2、卡 2 抄卡 1……):循环里一张卡抄自己上一轮的产出也就此打住。 +#: 追到头还没追完的宁可多遮,按取自遮罩格算 +CARD_DEPTH = 8 +#: 口径卡产出里不带指标值的字段:取这些的输入不算转抄了指标的值 +_CARD_PLAIN = frozenset({"kind", "caliber", "caliber_version", "artifact", "source"}) +#: 一个指标里不带值的字段(名称、单位、表达式、显示格式、状态这类)。值、渲染、输入、代入式都带着值 +_METRIC_PLAIN = frozenset({"id", "name", "unit", "expression", "decimals", "format", "status", "recompute_ok", + "incomplete", "incomplete_reason", "sql_check_failed", "sql_check_reason"}) + + +@dataclass(frozen=True) +class CardInput: + """卡上的一个输入转抄了上游口径卡的产出(card_input):node_id 是那张卡的节点,card 是它当时的产出(指标集加清单 + 文字),picked 是这个输入带着值的那几个指标(在 card["metrics"] 里的位置)。whole:整张卡、整份指标列表、清单文字、 + 下标不是字面量的一个指标——不一定把哪个指标的值带进来,同整份查询结果(Origin.table)。""" + + node_id: str + card: dict[str, Any] + picked: tuple[int, ...] + whole: bool + + +def card_input(item: dict[str, Any], output: Callable[[Any], Any]) -> CardInput | None: + """卡上的一个输入是不是转抄上游口径卡的产出:nodes.<卡>.metrics[0].value、vars.<卡的 assign_to>.metrics[0].value、 + nodes.<卡>.text(清单文字逐条写着「指标(名称)= 值」)、整张卡。是的话交回那张卡和带着值的那几个指标,值给不给按 + 那几个指标认(证据面板、写作目录、可验证报告同一个判断):上游的指标取自遮罩格,转抄它的这个指标同样取自遮罩格。 + 以前这几种写法认不出出处,转抄来的号码照样进了写作目录、文档的证据目录和可验证报告。 + + output 同 input_origin:节点 id → 那个节点当时的产出;nodes.X 是 X 的产出,vars.X 是写它的节点(输入记着的 node_id) + 的产出——口径卡的 assign_to 交给变量的就是它的产出本身。产出不是指标集的(别的节点)交回 None。只取口径名、版本、指标 + 的名称、单位这类不带值的字段的,也交回 None。""" + try: + tree = ast.parse(str(item.get("path") or ""), mode="eval").body + except SyntaxError: + return None + keys: list[Any] = [] + while isinstance(tree, (ast.Attribute, ast.Subscript)): + if isinstance(tree, ast.Attribute): + keys.append(tree.attr) + else: + key = _literal_key(tree.slice) + keys.append(_ANY if key is _GONE else key) + tree = tree.value + keys.reverse() + if not isinstance(tree, ast.Name) or tree.id not in ("vars", "nodes") or not keys or not isinstance(keys[0], str): + return None + node_id = keys[0] if tree.id == "nodes" else item.get("node_id") + card = output(node_id) if node_id else None + if not (isinstance(card, dict) and card.get("kind") == "metric_set" and isinstance(card.get("metrics"), list)): + return None + metrics, rest = card["metrics"], keys[1:] + every = tuple(range(len(metrics))) + if not rest or rest[0] == "text" or rest[0] is _ANY or (rest[0] == "metrics" and len(rest) == 1): + return CardInput(str(node_id), card, every, True) + if rest[0] != "metrics": + # 口径名、版本、工件 id 这类,或者产出里本来没有的键:不带指标的值 + return None + index = rest[1] + if index is _ANY: + return CardInput(str(node_id), card, every, True) + if not isinstance(index, int) or not -len(metrics) <= index < len(metrics): + return None + if len(rest) > 2 and isinstance(rest[2], str) and rest[2] in _METRIC_PLAIN: + return None + return CardInput(str(node_id), card, (index % len(metrics),), False) + def positional_cell(path: str, node_id: Any, output: Callable[[Any], Any], *, wide: bool = False) -> dict[str, Any] | None: """按位置取自一份查询结果的一格:vars.res.rows[0][2]、nodes.parse.rows[1]['amount'] 这种标量引用——查询结果经数据 diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index 8a792c9a..cbc10cd0 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -32,6 +32,8 @@ 逐个格式核一遍 - formats-unit:同经营月报,销售额的单位带尾随空白(「元 」:单位输入框不去空白,指标节点照存),正文显示「74,613.65元 」, 原样导出照样全部通过 +- prose:同经营月报的口径卡和流水,正文是写作者自己写在引用旁边的普通文字(「运费统一[[m:…]]」「合计——[[m:…]]」 + 「SKU-[[v:…]]」「共[[m:…]]家」这类):原样导出全部通过、总览是绿的,紧挨着核对的规则不误判正常写法 """ from __future__ import annotations @@ -99,6 +101,13 @@ FORMATS = ("## 经营月报\n\n本月销售额 [[m:gmv]](约 [[m:gmv|万]]) "取整是 [[m:avg|int]];线上占比 [[m:share|pct]],出口额 [[m:export]]。\n\n" "[[v:Q1.r0.region]]流水 [[v:Q1.r0.gmv]](约 [[v:Q1.r0.gmv|万]]),[[v:Q1.r1.region]]冲减 [[v:Q1.r1.gmv]]。" "最大一单的订单号 [[v:Q1.r0.order_no]],付款时间 [[v:Q1.r0.paid_at]]。[[see:Q1]]") +#: 经营月报的另一种写法:写作者自己写在引用旁边的普通文字——「统一」后面紧跟数(汉字「一」不是负号)、破折号(——、—、 +#: –、―,小号的)后面紧跟数、编号「SKU-」「A-」后面紧跟数(连字符前面是字母,是编号的一部分)、「共…家」、全角括号、 +#: 「NO.」后面跟数、假名长音和制表符横线、粗体斜体包着的数。原样导出要全部通过、总览是绿的 +PROSE = ("## 正文\n\n全场运费统一[[m:orders]],消费合计——[[m:gmv]],退款—[[m:refund]],转化率–[[m:conv_rate]]," + "环比―[[m:growth]],客单价\ufe58[[m:avg]]。\n\n" + "最大一单 SKU-[[v:Q1.r0.order_no]],编号A-[[m:orders]],共[[m:orders]]家,线上占比([[m:share|pct]])," + "排名NO.[[m:orders]],出口额ー[[m:export]],年度─[[m:annual|亿]],**[[m:gmv]]**,*[[v:Q1.r0.gmv]]*。") #: 会员周报:Q3 是会员按地区汇总(没碰遮罩列),Q2 是选出了号码的会员消费 MEMBERS = ("## 会员周报\n\n华东会员消费 [[v:Q3.r0.spend]],华南 [[v:Q3.r1.spend]]。[[see:Q3]]\n\n" "会员林一消费 [[v:Q2.r0.spend]]。") @@ -136,7 +145,7 @@ def masked_card(crm_id: str) -> dict[str, Any]: return {**CARD, "metrics": [*CARD["metrics"], contact, first]} -#: masked_card 上值不给的指标(和证据面板指标步骤的值同一个判断:输入取自遮罩格) +#: masked_card 上值不给的指标(和报告撰写节点的写作目录同一个判断:输入确定取自遮罩格) MASKED_METRICS = ("m:contact", "m:phone_raw") @@ -320,6 +329,7 @@ def bundles() -> dict[str, dict[str, Any]]: out["formats"] = formats_bundle() out["formats-unit"] = formats_bundle(card=FORMATS_UNIT_CARD) + out["prose"] = formats_bundle(text=PROSE) return out diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index 6edaec99..669aaa18 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -465,7 +465,7 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): 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-old-doc", "masked-source", "formats", - "formats-unit"} + "formats-unit", "prose"} for name, bundle in got.items(): assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name diff --git a/backend/tests/test_bundle_masked_metrics.py b/backend/tests/test_bundle_masked_metrics.py index 91e20045..4b0d7f06 100644 --- a/backend/tests/test_bundle_masked_metrics.py +++ b/backend/tests/test_bundle_masked_metrics.py @@ -140,11 +140,12 @@ async def test_an_ordinary_card_keeps_its_event_and_its_numbers(client, monkeypa 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 + """可验证报告认指标的值给不给,和报告撰写节点的写作目录同一个判断(证据面板指标步骤的那一套,只按出处认、遮罩列只认 + 确定的:_metric_masks 的 by_value=False、sure;可验证报告认老文档用 metric_word 的 sure):整份结果转成文字、整行、 + 下标不是字面量的一格、cell() 取的号码都不给,数行数的、取编号的照给(用例同 tests/test_masked_metric_values.py)。 + 认口径卡能不能附带的默认认法在这份 SQL 认得清的数据上结果一样。数据整形取出来再赋给变量的号码认不出出处,证据面板 + 按值多遮一层,这里照给(已知的缺口)。""" + from app.api.evidence import MASKED, _mask_fingerprint, _Masks, _metric_masks, _metric_in, _sealed, certain_masks from tests.test_masked_metric_values import TEXT, graph as members_graph script(monkeypatch, TEXT) @@ -153,15 +154,20 @@ async def test_the_bundle_judges_metric_values_like_the_report_writer(client, mo 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 = {}, {}, {} + sure, secrets = await certain_masks(sealed, _Masks(sealed)) + store = _Store(lambda a: read_bytes(a, verify=False), await _mask_fingerprint(), sealed.events, + sure_columns=sure, sure_secrets=secrets) + writer, panel, bundle, card_word = {}, {}, {}, {} for metric in card["metrics"]: - _, word = await _metric_masks(metric, "card", entry["artifact"], sealed, _Masks(sealed), by_value=False) + _, word = await _metric_masks(metric, "card", entry["artifact"], sealed, _Masks(sealed), by_value=False, + sure=True) 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) + bundle[metric["id"]] = store.metric_word(entry["artifact"], _metric_in(card, metric["id"]), sealed.events, + sure=True) + card_word[metric["id"]] = store.metric_word(entry["artifact"], _metric_in(card, metric["id"]), sealed.events) + assert writer == bundle == card_word, (writer, bundle, card_word) 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"} diff --git a/backend/tests/test_bundle_prose_export.py b/backend/tests/test_bundle_prose_export.py new file mode 100644 index 00000000..aa29247b --- /dev/null +++ b/backend/tests/test_bundle_prose_export.py @@ -0,0 +1,101 @@ +"""原样导出的可验证报告:写作者自己写在引用旁边的普通文字不能把核对过的数判成不通过、无法核对,总览要是绿的。 + +复核第 1 轮发现(相对基线的回退):显示文字核对把「紧挨着负号」改判不通过,负号却连破折号「——」「—」和汉字「一」都算上了。 +写作者写「全场运费统一[[m:fee]],消费合计——[[m:total]]。」,服务端自查通过、正式运行成功,原样导出断网打开,总览却是 +「有 2 项不通过」。现在只有真当负号用的三个(半角、数学减号、全角)、前面又不是字母数字的才判不通过;破折号、汉字「一」 +当普通的字;总览只有一项无法核对都没有才是绿的。这里真跑一遍报告撰写节点、从接口导出,再在 node 里执行页面上的核对脚本。 + +数据全是虚构的。 +""" +from __future__ import annotations + +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource +from app.engine.runner import run_manager +from app.main import app +from tests.test_bundle_masked_metrics import data_of +from tests.test_bundle_shown import verdicts +from tests.test_report_masked_metrics import events, finish, node, script + +#: 写作者的写法:引用前面紧挨着「统一」「——」「—」「–」「SKU-」「共」,后面紧挨着「家」「元」 +PROSE = ("全场运费统一[[m:fee]],消费合计——[[m:total]],退款—[[m:refund]],共[[m:stores]]家门店," + "热销款 SKU-[[v:Q1.r0.sku]],单价区间 [[v:Q1.r0.price]]–[[v:Q1.r1.price]]。") + + +@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 + + +@pytest.fixture +async def shop(tmp_path, engine_up): + """门店库:没设遮罩。""" + path = tmp_path / "shop.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE items (sku INTEGER, price REAL);" + "CREATE TABLE stores (id INTEGER, amount REAL, refund REAL);") + db.executemany("INSERT INTO items VALUES (?, ?)", [(1207, 19.9), (1311, 39.5)]) + db.executemany("INSERT INTO stores VALUES (?, ?, ?)", [(1, 1234.5, -20.5), (2, 2345.25, 0), (3, 3456.75, -8)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "n1shop"))).scalar_one_or_none() + if row is None: + row = DataSource(name="n1shop", kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield path + await engines.invalidate(source_id) + + +async def test_ordinary_writing_next_to_citations_exports_all_green(client, monkeypatch, shop): + """报告撰写节点照写作者的稿子组装(正式运行,违规处置判失败),自查通过;导出的可验证报告里每个数字都核对通过,总览 + 是绿色的「全部通过」。""" + script(monkeypatch, PROSE) + metric = {"format": "thousands", "decimals": 2} + nodes = [ + node("start", "input"), + node("q", "tool", tool="db_query__n1shop", args={"sql": "SELECT sku, price FROM items ORDER BY sku"}), + node("q2", "tool", tool="db_query__n1shop", args={ + "sql": "SELECT SUM(amount) AS total, SUM(refund) AS refund, COUNT(*) AS stores, 10 AS fee FROM stores"}), + node("card", "metrics", caliber="门店口径", caliber_version="v1", metrics=[ + {"id": "fee", "name": "运费", "unit": "元", "format": "plain", "expression": "cell(nodes.q2, 0, 'fee')"}, + {"id": "total", "name": "消费合计", "unit": "元", **metric, "expression": "cell(nodes.q2, 0, 'total')"}, + {"id": "refund", "name": "退款", "unit": "元", **metric, "expression": "cell(nodes.q2, 0, 'refund')"}, + {"id": "stores", "name": "门店数", "format": "plain", "expression": "cell(nodes.q2, 0, 'stores')"}]), + node("write", "report", instructions="写一句", on_violation="fail"), + node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]), + ] + spec = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + row = await finish(spec) + assert row.status == "succeeded", row.error + [checked] = [e.data for e in await events(row.id) if e.type == "report.checked"] + assert checked["ok"] is True and checked["repairs"] == 0, checked + resp = await client.get(f"/api/runs/{row.id}/bundle.html") + assert resp.status_code == 200, resp.text + got = verdicts(data_of(resp.text)) + shown = {n["ref"]: (n["text"], n["result"]) for n in got["numbers"]} + assert shown == { + "m:fee": ("10元", "pass"), "m:total": ("7,036.50元", "pass"), "m:refund": ("-28.50元", "pass"), + "m:stores": ("3", "pass"), "v:Q1.r0.sku": ("1,207", "pass"), "v:Q1.r0.price": ("19.9", "pass"), + "v:Q1.r1.price": ("39.5", "pass"), + }, shown + assert got["overall"]["result"] == "pass" and got["overall"]["text"] == "全部通过", got["overall"] diff --git a/backend/tests/test_bundle_shown.py b/backend/tests/test_bundle_shown.py index 995feb94..68c89459 100644 --- a/backend/tests/test_bundle_shown.py +++ b/backend/tests/test_bundle_shown.py @@ -13,10 +13,13 @@ - 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示 文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写; - 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、 - 负号、小数点(长得像负号的横线也算,隔着不占位置的字也算)判不通过:读者看到的是另一个数,以前只判无法核对,总览 - 照样是绿的;紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里有改变文字方向的字(方向控制字符、希伯来文 - 和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的写法照常通过;列表缩进有上限、表格只排有格 - 的列,伪造的缩进、列号挤不走核对过的数。 + 小数点、负号(只认真当负号用的半角、数学减号、全角三个,前面是字母数字的是编号的一部分;隔着不占位置的字也算)判不 + 通过:读者看到的是另一个数,以前只判无法核对,总览照样是绿的;后面接着量级单位(万、亿、%、k 这类,隔着空格也算)、 + 画着删除线或紧挨着画了删除线的字、紧挨着像负号的短横(连字符、上标负号这类)、长得像数字的字母和数字符号(l、I、O、 + 上标数字、圈码)、显示成什么说不准的字(控制字符、组合符号这类),同一句里有改变文字方向的字(方向控制字符、希伯来文 + 和阿拉伯文这类从右往左书写的文字),判无法核对;写作者自己写在引用旁边的普通文字(「统一」「——」「SKU-」「共…家」)、 + 区间、日期照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数; +- 总览:一项无法核对都没有才是绿色的通过,有的是中性的「可核对的项全部通过,另有 N 项无法核对」(overallOf)。 数据全是虚构的。 """ @@ -73,6 +76,8 @@ async function main() { return input.blocks.map((b) => (b.type === 'list' ? v.listItems(b.units).map((i) => i.depth) : v.tableCells(b.units).cols)) } + // 只看正文的排法:文档里每个解析成功的数字、取值当作核对通过,交回各自的结果 + if (input.task === 'layoutOf') return input.docs.map((d) => v.layoutOf(d)) const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex') const out = await v.verify(input.data, digest, input.seal) const results = (items) => items.map((i) => i.result) @@ -88,6 +93,8 @@ async function main() { numbers: out.numbers.map((n) => ({ key: n.key, ref: n.seg.ref ?? null, text: String(n.seg.text ?? ''), result: n.result, reason: n.reason || '', expected: n.expected ?? null, shownOk: !!n.shownOk, truthText: n.truthText ?? null, onPage: n.onPage ?? null })), + // 总览:结果(pass 绿 / fail 红 / incomplete、partial 中性)和那一句 + overall: v.overallOf(out), } } main().then((r) => process.stdout.write(JSON.stringify(r)), (e) => { @@ -607,20 +614,20 @@ def _split_pair(high: str, low: str): ("v:Q1.r0.gmv", _glue_after("s34", chr(0x2024) + "5")), # 和句点长得一样的单点前导符 ("m:conv_rate", _glue_before("s12", "-")), # 前面加负号:-2.35% ("m:conv_rate", _glue_before("s12", chr(0xFF0D))), # 全角负号 - # 长得像负号的横线:连字符、不断行连字符、数字宽的横线、短破折号、长破折号、修饰字母负号、粗减号、小号长破折号、 - # 数学减号、上标负号,和显示出来同样是一道横线的汉字「一」、假名长音、制表符横线、罗马盎司符号(码点在两个 - # UTF-16 单元里) - *[("m:conv_rate", _glue_before("s12", chr(cp))) for cp in ( - 0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x02D7, 0x2796, 0xFE58, 0x2212, 0x207B, 0x4E00, 0x30FC, 0x2500, - 0x10191)], + ("m:conv_rate", _glue_before("s12", chr(0x2212))), # 数学减号 + ("m:conv_rate", _glue_before("s12", "-" + chr(0x200B))), # 隔着看不见的零宽空格 + # 负号前面是汉字、括号:不是编号的一部分(「SKU-12」那种前面是字母数字的见下面照常通过的几条) + ("m:conv_rate", lambda doc: by_id(doc, "s12").update(text="转化率-")), + ("m:conv_rate", lambda doc: by_id(doc, "s12").update(text="转化率(-")), ("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_fail(ref, change): """伪造者不动核对过的那段字,只在相邻的文字片段里补一位数、一个负号、一段小数(隔着不占位置、显示成什么说不准的字, - 藏起来的粗体标记也算紧挨着;长得像负号的横线也算负号),重算全部哈希:读者看到的是另一个数。这一项判不通过、点名 - 正文里紧挨着的字改变了读者看到的数——以前只判无法核对,总览照样写「可核对的项全部通过」。显示文字本身照旧核对过 - (shownOk),别的照常通过。服务端组装的文档不会这样排(两个数之间补空格),原样导出照样通过(见下面几条)。""" + 藏起来的粗体标记也算紧挨着),重算全部哈希:读者看到的是另一个数。这一项判不通过、点名正文里紧挨着的字改变了读者 + 看到的数——以前只判无法核对,总览照样写「可核对的项全部通过」。负号只认真当负号用的三个(半角、数学减号、全角), + 前面是字母数字的是编号的一部分(见下面照常通过的几条)。显示文字本身照旧核对过(shownOk),别的照常通过。服务端组装 + 的文档不会这样排(两个数之间补空格),原样导出照样通过(见下面几条)。""" from tests.bundle_samples import formats_bundle got = verdicts(rewritten(formats_bundle(), change)) @@ -716,6 +723,119 @@ def test_ranges_dates_and_signs_written_by_the_system_still_pass(): assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"] +UNSURE = "无法核对显示文字:" +DASHED = UNSURE + "前面紧挨着一道像负号的短横,读起来可能是负数" +SCALED = UNSURE + "后面接着量级单位(万、亿、千、百、%、‰、k、M、B 这类)。系统显示这个数时不带它,读起来的数可能差了量级" +STRUCK = UNSURE + "这个数画着删除线,或者紧挨着画了删除线的文字,读的人可能以为它已作废、以旁边的数为准" +LOOKALIKE = UNSURE + "紧挨着长得像数字的字母或符号(l、I、O、上标数字、圈码这类),读起来可能多出一位" + + +def _only(ref: str, change: Any, result: str, reason: str) -> dict[str, Any]: + """改过的经营月报里只有 ref 这一项是 result(原因 reason),别的照常通过;交回核对结果。""" + from tests.bundle_samples import formats_bundle + + got = verdicts(rewritten(formats_bundle(), change)) + hit = item(got, ref) + assert hit["result"] == result and hit["reason"] == reason and hit["shownOk"], hit + assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref), \ + [n for n in got["numbers"] if n["ref"] != ref and n["result"] != "pass"] + return got + + +@pytest.mark.parametrize("cp", [0x2010, 0x2011, 0x2012, 0x02D7, 0x2796, 0x207B, 0x208B, 0xFE63, 0x058A]) +def test_a_short_dash_that_looks_like_a_minus_cannot_pass(cp): + """连字符、不断行连字符、数字宽的横线、修饰字母负号、粗减号、上下标负号、小号连字符减号、亚美尼亚连字符:平时不当负号 + 用,不判不通过;紧挨着数字开头、前面又不是字母数字时读起来像负数,判无法核对、原因说清。总览不是绿色的通过。""" + got = _only("m:conv_rate", _glue_before("s12", chr(cp)), "na", DASHED) + assert got["overall"]["result"] == "partial", got["overall"] + + +@pytest.mark.parametrize("ref, change", [ + ("v:Q1.r0.gmv", _glue_after("s34", "万")), # 45,678.5万:读作四亿多 + ("v:Q1.r0.gmv", _glue_after("s34", " 万")), # 中间隔着空格照样读作四亿多 + ("v:Q1.r0.gmv", _glue_after("s34", "亿元")), + ("v:Q1.r0.gmv", _glue_after("s34", "%")), + ("v:Q1.r0.gmv", _glue_after("s34", "‰")), + ("v:Q1.r0.gmv", _glue_after("s34", chr(0xFF05))), # 全角百分号 + *[("v:Q1.r0.gmv", _glue_after("s34", c)) for c in ("k", "K", "M", "B", "千", "百", "萬", chr(0xFF2B))], + ("v:Q1.r0.gmv|万", _glue_after("s36", "亿")), # 4.57万亿:核对过的字以「万」结尾,再接一个量级 + ("v:Q1.r0.order_no", _glue_after("s43", "万")), # 订单号后面接「万」 + ("m:orders", _glue_after("s18", "")), # 对照组见下:以「单」结尾的不受影响 +]) +def test_a_magnitude_unit_after_a_checked_value_cannot_pass(ref, change): + """核对过的那段字不动,后面接上量级单位(万、亿、%、k 这类,隔着空格也算):读者看到的数差了量级。系统显示一个数时 + 单位、万 / 亿换算都在核对过的那段字里,不会另接在后面,判无法核对、原因说清;总览不是绿色的通过。""" + if ref == "m:orders": + # 以「单」结尾的数后面接什么都不改变它的量级:照常通过 + from tests.bundle_samples import formats_bundle + + got = verdicts(rewritten(formats_bundle(), lambda doc: by_id(doc, "s18").update(text="万。"))) + assert all(n["result"] == "pass" for n in got["numbers"]), got["numbers"] + return + got = _only(ref, change, "na", SCALED) + assert got["overall"]["result"] == "partial" and "另有 1 项无法核对" in got["overall"]["text"], got["overall"] + + +@pytest.mark.parametrize("change", [ + # 核对过的数整个画上删除线,旁边另写一个数(读的人以为它已作废) + lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + "~~"), + by_id(doc, "s5").update(text="~~ 84,613.65元" + by_id(doc, "s5")["text"])), + # 删除线只画在紧挨着它的字上 + _glue_before("s3", "~~旧~~"), + _glue_after("s5", "~~作废~~"), +]) +def test_a_struck_checked_value_cannot_pass(change): + """删除线画在核对过的数上,或者画在紧挨着它的字上:显示出来的字没变,读的人却可能以为它已作废、以旁边写的数为准, + 判无法核对、原因说清。""" + ref = "m:gmv" + got = _only(ref, change, "na", STRUCK) + assert got["overall"]["result"] == "partial", got["overall"] + + +@pytest.mark.parametrize("ref, change", [ + ("m:gmv", _glue_before("s3", "l")), # 小写 L:读作 174,613.65元 + ("m:gmv", _glue_before("s3", "I")), # 大写 i + ("m:gmv", _glue_before("s3", chr(0xFF29))), # 全角大写 i + ("v:Q1.r0.gmv", _glue_after("s34", "O")), # 大写 o:读作 45,678.50 + ("m:gmv", _glue_before("s3", chr(0xB9))), # 上标 1 + ("m:gmv", _glue_before("s3", chr(0x2160))), # 罗马数字 1 + ("m:gmv", _glue_before("s3", chr(0x2460))), # 圈码 1 + ("v:Q1.r0.gmv", _glue_after("s34", chr(0x3007))), # 汉字〇 +]) +def test_a_lookalike_digit_next_to_a_checked_value_cannot_pass(ref, change): + """紧挨着长得像数字的字母、数字符号(l、I、O、上标数字、罗马数字、圈码、汉字〇):读起来可能多出一位,判无法核对、 + 原因说清。""" + got = _only(ref, change, "na", LOOKALIKE) + assert got["overall"]["result"] == "partial", got["overall"] + + +def test_ordinary_writing_next_to_checked_values_still_passes_as_exported(): + """正文里的普通写法照常通过,原样导出总览全绿:「统一」后面紧跟数(汉字「一」不是负号)、破折号(——、—、–、―, + 小号的)后面紧跟数、编号「SKU-」「A-」后面紧跟数(连字符前面是字母,是编号的一部分)、「共…家」、全角括号、区间、 + 「NO.」后面跟数、数后面跟「元」「单」这类单位以外的字、假名长音和制表符横线、粗体和斜体包着的数。""" + from tests.bundle_samples import PROSE, formats_bundle + + got = verdicts(formats_bundle(text=PROSE)) + assert len(got["numbers"]) == 15, got["numbers"] + assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), \ + [n for n in got["numbers"] if n["result"] != "pass"] + assert got["overall"] == {"result": "pass", "text": "全部通过", "pass": got["overall"]["pass"], "fail": 0, "na": 0} + + +def test_the_overview_is_green_only_without_anything_unverifiable(): + """总览:原样导出、一项无法核对都没有的是绿色的「全部通过」;有一项无法核对就是中性的「可核对的项全部通过,另有 + N 项无法核对」(以前照样是绿的);有不通过的是红的。""" + from tests.bundle_samples import formats_bundle + + assert verdicts(formats_bundle())["overall"]["result"] == "pass" + got = verdicts(rewritten(formats_bundle(), lambda doc: by_id(doc, "s5").update( + text=chr(0x202E) + by_id(doc, "s5")["text"]))) + assert got["overall"]["result"] == "partial" and got["overall"]["fail"] == 0, got["overall"] + assert got["overall"]["text"] == f"可核对的项全部通过,另有 {got['overall']['na']} 项无法核对", got["overall"] + got = verdicts(rewritten(formats_bundle(), _glue_before("s3", "1"))) + assert got["overall"]["result"] == "fail" and got["overall"]["text"] == "有 1 项不通过", got["overall"] + + NOT_SHOWN = "正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过" diff --git a/backend/tests/test_report_masked_card_copies.py b/backend/tests/test_report_masked_card_copies.py new file mode 100644 index 00000000..32ba8350 --- /dev/null +++ b/backend/tests/test_report_masked_card_copies.py @@ -0,0 +1,264 @@ +"""第二张口径卡转抄上游口径卡里取自遮罩格的指标:转抄来的指标同样不给值。 + +复核第 1 轮发现:卡 2 上的指标写 nodes.card.metrics[0].value、vars.kpi.metrics[0].value(卡 1 设了 assign_to)或 +nodes.card.text(清单文字逐条写着「指标(名称)= 值」),取值链认不出出处(recompute.input_origin 只认查询结果),只按出处认 +时直接照给:写作目录写着号码,文档的证据目录里值、渲染、标签都是号码,可验证报告里报告文档和卡 2 的节点完成事件都带着号码。 + +现在转抄上游口径卡的输入按上游那几个指标认(recompute.card_input,api/evidence.py 的 _InputMasks._copied,bundle/build.py +的 _Store._copied_kind):上游的指标取自遮罩格,转抄它的这个指标同样写「已遮罩」;整张卡、清单文字同整份查询结果,值里有 +遮罩值才不给(数一数有几个指标的照给)。写作目录、报告自查、可验证报告同一个判断。 + +真跑的部分用虚构的 SQLite 会员库(号码是编的),写作者是 MockChatModel 照剧本写。 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine.evidence import iter_segments +from app.engine.runner import run_manager +from app.main import app +from tests.test_bundle_masked_metrics import data_of, event +from tests.test_report_masked_metrics import ( # noqa: F401 vip:号码设了遮罩的会员库 + PHONES, + doc_of, + events, + finish, + leaks, + metric_lines, + node, + script, + vip, +) + +#: 卡 1:首位客户手机号(就是号码那一格)、客户数 +CARD = [ + {"id": "phone_raw", "name": "首位客户手机号", "format": "plain", "expression": "cell(nodes.q_cust, 0, 'phone')"}, + {"id": "n_cust", "name": "客户数", "unit": "位", "format": "thousands", "expression": "len(vars.cust.rows)"}, +] +#: 卡 2:转抄卡 1 的几种写法;后两个只转抄客户数、数指标个数,值里没有号码 +COPIES = [ + {"id": "p_nodes", "name": "转抄手机号", "format": "plain", "expression": "nodes.card.metrics[0].value"}, + {"id": "p_vars", "name": "按变量转抄手机号", "format": "plain", "expression": "vars.kpi.metrics[0].value"}, + {"id": "p_text", "name": "转抄清单", "format": "plain", "expression": "nodes.card.text"}, + {"id": "n_copy", "name": "转抄客户数", "unit": "位", "format": "thousands", "expression": "nodes.card.metrics[1].value"}, + {"id": "n_metrics", "name": "卡 1 的指标个数", "unit": "个", "format": "thousands", + "expression": "len(nodes.card.metrics)"}, +] +WITHHELD = ("m:p_nodes", "m:p_vars", "m:p_text") +CLEAN = "共有 [[m:n_copy]] 客户,首张口径卡记了 [[m:n_metrics]] 指标。" + + +@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 graph(copies: list[dict] | None = None, card: list[dict] | None = None, **write) -> dict: + """会员查询(选出号码)→ 数据整形 → 卡 1(assign_to kpi)→ 卡 2(转抄卡 1)→ 报告 → 成果。""" + nodes = [ + node("start", "input"), + node("q_cust", "tool", tool="db_query__vip", args={"sql": "SELECT id, name, phone FROM customers ORDER BY id"}), + node("parse", "transform", mode="json", template="{{ nodes.q_cust }}", assign_to="cust"), + node("card", "metrics", caliber="会员口径", caliber_version="v1", assign_to="kpi", metrics=card or CARD), + node("card2", "metrics", caliber="转抄口径", caliber_version="v1", metrics=copies or COPIES), + node("write", "report", instructions="写一句", **{"on_violation": "flag", **write}), + node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def test_the_writer_and_the_doc_see_masked_for_copied_masked_metrics(monkeypatch, vip): + """写作目录:转抄号码的三种写法都写「已遮罩」并说不要引用,转抄客户数、数指标个数的照给;整段写作目录里没有号码。文档的 + 证据目录同样写「已遮罩」,整份文档没有号码。""" + seen = script(monkeypatch, CLEAN) + row = await finish(graph()) + assert row.status == "succeeded", row.error + lines = metric_lines(seen[0]) + for name in ("转抄手机号", "按变量转抄手机号", "转抄清单"): + assert f"{name} = 已遮罩(取自设置了遮罩的列,不要引用)" in lines, lines + assert "转抄客户数 = 3位" in lines and "卡 1 的指标个数 = 2个" in lines, lines + assert leaks(lines) == [], lines + doc = await doc_of(row.id) + for alias in WITHHELD: + entry = doc["catalog"][alias] + assert (entry["value"], entry["rendered"], entry.get("masked")) == ("已遮罩", "已遮罩", True), entry + assert "masked" not in doc["catalog"]["m:n_copy"] and "masked" not in doc["catalog"]["m:n_metrics"] + assert leaks(doc) == [] + [checked] = [e.data for e in await events(row.id) if e.type == "report.checked"] + assert checked["ok"] is True, checked + + +@pytest.mark.parametrize("alias", WITHHELD) +async def test_citing_a_copied_masked_metric_is_a_masked_cell_violation(monkeypatch, vip, alias): + """引用转抄来的号码:报告自查记 masked_cell,和引用遮罩格同一套处置;违规处置为判失败的正式运行判失败,说法里不写号码。""" + script(monkeypatch, f"首位客户的联系方式 [[{alias}]]。") + row = await finish(graph(on_violation="fail")) + assert row.status == "failed" and alias in (row.error or "") and "遮罩" in row.error, row.error + assert leaks(row.error) == [] + [checked] = [e.data for e in await events(row.id) if e.type == "report.checked"][-1:] + assert [v["ref"] for v in checked["violations"] if v["code"] == "masked_cell"] == [alias], checked["violations"] + + +async def test_the_bundle_carries_no_copied_number(client, monkeypatch, vip): + """可验证报告:卡 2 只给 id、它的节点完成事件只给哈希,报告文档照常附带(目录里写的是「已遮罩」),整个文件没有号码; + 证据图的标签同样写「已遮罩」。""" + script(monkeypatch, CLEAN) + row = await finish(graph()) + assert row.status == "succeeded", row.error + resp = await client.get(f"/api/runs/{row.id}/bundle.html") + assert resp.status_code == 200, resp.text + bundle = data_of(resp.text) + assert leaks(resp.text) == [], "可验证报告里不出现号码" + assert "canonical" not in event(bundle, "node.finished", "card2") + [doc] = [a for a in bundle["artifacts"] if a["kind"] == "report_doc"] + assert "b64" in doc and "masked" not in doc, doc + cards = {a["node_id"]: a for a in bundle["artifacts"] if a["kind"] == "metric_set"} + assert cards["card2"].get("masked") is True and "b64" not in cards["card2"], cards + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + labels = {e["alias"]: e["label"] for e in body["evidence"] if e["kind"] == "metric"} + assert labels["m:n_copy"] == "转抄客户数 = 3位", labels + assert leaks(body) == [], [e for e in body["evidence"] if leaks(e)] + + +async def test_a_run_without_a_report_does_not_carry_the_copied_number(client, monkeypatch, vip): + """没有报告节点的运行:卡 2 的节点完成事件(预览逐条写着「指标(名称)= 值」)只给哈希,文件里没有号码。卡 2 只转抄 + 客户数、卡 1 上也没有号码的,卡 2 的节点完成事件照常带规范串。""" + spec = graph() + spec["nodes"] = [n for n in spec["nodes"] if n["id"] != "write"] + spec["nodes"][-1]["data"]["config"]["fields"] = [{"name": "说明", "value": "完成"}] + spec["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(spec["nodes"], spec["nodes"][1:])] + row = await finish(spec) + assert row.status == "succeeded", row.error + resp = await client.get(f"/api/runs/{row.id}/bundle.html") + assert resp.status_code == 200, resp.text + bundle = data_of(resp.text) + assert "canonical" not in event(bundle, "node.finished", "card2") + assert leaks(resp.text) == [] + + plain = [{**c, "expression": c["expression"].replace("metrics[1]", "metrics[0]")} for c in COPIES + if c["id"] in ("n_copy", "n_metrics")] + spec = graph(copies=plain, card=[CARD[1]]) + spec["nodes"] = [n for n in spec["nodes"] if n["id"] != "write"] + spec["nodes"][-1]["data"]["config"]["fields"] = [{"name": "说明", "value": "完成"}] + spec["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(spec["nodes"], spec["nodes"][1:])] + row = await finish(spec) + assert row.status == "succeeded", row.error + bundle = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text) + assert "canonical" in event(bundle, "node.finished", "card2") + + +async def test_an_old_doc_carrying_a_copied_number_ships_only_its_hash(client, monkeypatch, vip): + """升级前写的文档(写作前不认遮罩,这里把那一步换成什么都不认):证据目录里写着转抄来的号码。导出时整份只给哈希,报告 + 节点的完成事件也只给哈希,文件里没有号码;卡 1 上没有号码、卡 2 只转抄客户数的(目录里没有号码)照常附带。""" + import app.api.evidence as evidence_api + + async def nothing(*_args, **_kwargs): + return [] + + monkeypatch.setattr(evidence_api, "withheld_metrics", nothing) + script(monkeypatch, CLEAN) + row = await finish(graph()) + assert row.status == "succeeded", row.error + doc = await doc_of(row.id) + assert leaks(doc["catalog"]["m:p_nodes"]) == [PHONES[0]], "老文档的证据目录里写着转抄来的号码" + resp = await client.get(f"/api/runs/{row.id}/bundle.html") + assert resp.status_code == 200, resp.text + bundle = data_of(resp.text) + [art] = [a for a in bundle["artifacts"] if a["kind"] == "report_doc"] + assert art.get("masked") is True and "b64" not in art, art + assert "canonical" not in event(bundle, "node.finished", "write") + assert leaks(resp.text) == [] + + script(monkeypatch, CLEAN) + plain = [{**c, "expression": c["expression"].replace("metrics[1]", "metrics[0]")} for c in COPIES + if c["id"] in ("n_copy", "n_metrics")] + row = await finish(graph(copies=plain, card=[CARD[1]])) + assert row.status == "succeeded", row.error + bundle = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text) + [art] = [a for a in bundle["artifacts"] if a["kind"] == "report_doc"] + assert "b64" in art and "masked" not in art, art + + +async def test_the_panel_withholds_the_copied_number(client, monkeypatch, vip): + """证据面板点开引用转抄号码的那一处(写作者坚持引用、标注照常产出):值写「已遮罩」,整份答复没有号码。""" + script(monkeypatch, "首位客户的联系方式 [[m:p_vars]]。") + row = await finish(graph()) + 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:p_vars") + res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}") + assert res.status_code == 200, res.text + body = res.json() + assert body["chain"][0]["value"] == "已遮罩", body["chain"][0] + assert leaks(body) == [], json.dumps(body, ensure_ascii=False)[:600] + + +# -------------------------------------------------------------------------- +# 手搭:转抄追不到头 +# -------------------------------------------------------------------------- + +#: 一张卡转抄自己上一轮的产出(循环里写成这样):顺着取值链永远追不到最初取自哪里 +LOOP_PATH = "nodes.loop_card.metrics[0].value" +LOOPING = {"kind": "metric_set", "caliber": "循环口径", "caliber_version": "v1", "artifact": "c" * 64, "metrics": [ + {"id": "p", "name": "上一轮的号码", "value": PHONES[0], "format": "plain", "expression": LOOP_PATH, + "inputs": [{"path": LOOP_PATH, "value": PHONES[0], "node_id": "loop_card", "via": "metric", "status": "ok"}]}]} + + +def test_recognizing_which_card_metrics_an_input_copies(): + """认转抄:带值的字段(值、渲染、输入、整个指标)算转抄了那个指标;整张卡、清单文字、下标不是字面量的算整份;只取名称、 + 单位、口径名的不算;产出不是指标集的节点不算。""" + from app.engine.recompute import card_input + + def output(node_id): + return LOOPING if node_id == "loop_card" else {"rows": [[1]], "columns": ["a"]} if node_id == "q" else None + + def picked(path, node_id=None): + got = card_input({"path": path, "node_id": node_id}, output) + return None if got is None else (got.picked, got.whole) + + assert picked("nodes.loop_card.metrics[0].value") == ((0,), False) + assert picked("nodes.loop_card.metrics[-1]['rendered']") == ((0,), False) + assert picked("nodes.loop_card.metrics[0].inputs[0].value") == ((0,), False) + assert picked("vars.kpi.metrics[0].value", node_id="loop_card") == ((0,), False) + assert picked("nodes.loop_card.text") == ((0,), True) + assert picked("nodes.loop_card") == ((0,), True) + assert picked("nodes.loop_card.metrics") == ((0,), True) + assert picked("nodes.loop_card.metrics[vars.i].value") == ((0,), True) + assert picked("nodes.loop_card.metrics[0].name") is None + assert picked("nodes.loop_card.metrics[0].unit") is None + assert picked("nodes.loop_card.caliber") is None + assert picked("nodes.loop_card.metrics[3].value") is None + assert picked("nodes.q.rows[0][0]") is None + + +async def test_a_copy_too_deep_to_follow_counts_as_masked(): + """转抄追了 recompute.CARD_DEPTH 张还没追到头(这里是一张卡转抄自己上一轮的产出):宁可多遮,按取自遮罩格算——写作目录 + 和可验证报告都不给值。核对不了就照给的话,号码就从这里出去了。""" + from app.api.evidence import MASKED, _Masks, _metric_masks, _Sealed + from app.bundle.build import _Store + from app.core.artifact_store import canonical_json, content_hash + + view = _Sealed(run_id="", graph={}, events=[], seal={"sealed": False, "ok": None, "manifest_seq": None, + "legacy": False}) + metric = LOOPING["metrics"][0] + _, word = await _metric_masks(metric, "loop_card", LOOPING["artifact"], view, _Masks(view), + output=lambda node_id: LOOPING if node_id == "loop_card" else None, + by_value=False, sure=True) + assert word == MASKED + raw = canonical_json(LOOPING).encode() + out_id = content_hash(raw) + store = _Store({out_id: raw}.get, {}, [(1, "node.finished", "loop_card", {"artifact": out_id})]) + card = store.content(out_id) + assert store.metric_word(LOOPING["artifact"], card["metrics"][0], store._events, sure=True) == "masked" + assert store.metric_word(LOOPING["artifact"], card["metrics"][0], store._events) == "masked" diff --git a/backend/tests/test_report_masked_metric_sure.py b/backend/tests/test_report_masked_metric_sure.py new file mode 100644 index 00000000..2e8297d2 --- /dev/null +++ b/backend/tests/test_report_masked_metric_sure.py @@ -0,0 +1,207 @@ +"""值取自遮罩格的口径卡指标只按「确定取自遮罩列」认:SQL 认不准、整份遮的快照里,没确定取自遮罩列的普通金额、计数、标签列, +指标照常显示、照常引用。 + +复核第 1 轮发现(相对基线的回退):写作目录按证据面板宁可多遮的那一套(_mask_sets 的 hidden)认指标的出处。SQL 认不准时 +(WITH 写了列名表、VALUES 当表、子查询里有没起别名的表达式)整份结果的列都算遮罩列,完全没碰号码列的销售额也被写成 +「已遮罩」,正式运行引用它就判失败,写作者也改不掉;同一格按 [[v:…]] 引用却不报违规(单元格引用的自查只认确定的那一套, +_cited_masks)。可验证报告认老文档时同样多算,证据目录里写着这类普通金额的老文档整份只给哈希。 + +现在写作目录、报告自查、可验证报告(含老文档只给哈希的判断)认指标取不取自遮罩格,都按单元格引用自查的那一套 +(_cited_masks:_mask_sets 的 sure,再按血缘排除值确实不取自遮罩列的)。证据面板、推演照旧宁可多遮。 + +真跑的部分用虚构的 SQLite 收银库(号码是编的),写作者是 MockChatModel 照剧本写。 +""" +from __future__ import annotations + +import base64 +import json +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.core.artifact_store import read_bytes +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource +from app.engine.runner import run_manager +from app.main import app +from tests.test_bundle_shown import verdicts +from tests.test_report_masked_metrics import doc_of, events, finish, metric_lines, node, script + +ROWS = [(1, "顾客甲", "19900001101", 1234.5), (2, "顾客乙", "19900001102", 2345.25), (3, "顾客丙", "19900001103", 3456.75)] +PHONES = [r[2] for r in ROWS] +GMV = {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "cell(nodes.q, 0, 'gmv')"} + +#: 没碰号码列的查询,SQL 却认不准结果里哪一列取自哪一列(证据面板、可验证报告的快照按整份遮):(SQL, 销售额的渲染) +PLAIN = [ + # WITH 写了列名表 + ("WITH s(cid, amt) AS (SELECT id, amount FROM customers) SELECT SUM(amt) AS gmv FROM s", "7,036.50元"), + # VALUES 当参数表 + ("WITH params(lo) AS (VALUES (1000)) SELECT SUM(amount) AS gmv FROM customers, params WHERE amount >= lo", + "7,036.50元"), + ("WITH v(id) AS (VALUES (1), (2)) SELECT SUM(c.amount) AS gmv FROM customers c JOIN v ON v.id = c.id", + "3,579.75元"), + # 子查询里有没起别名的号码表达式,外层只取金额 + ("SELECT id, amount AS gmv FROM (SELECT id, UPPER(phone), amount FROM customers) ORDER BY id", "1,234.50元"), +] + + +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(",", "")] + + +@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 + + +@pytest.fixture +async def pos(tmp_path, engine_up): + """收银库:顾客的号码设了遮罩。""" + path = tmp_path / "pos.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT, amount REAL);") + db.executemany("INSERT INTO customers VALUES (?, ?, ?, ?)", ROWS) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == "n1pos"))).scalar_one_or_none() + if row is None: + row = DataSource(name="n1pos", 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 = {"mask_columns": ["phone"]} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield path + await engines.invalidate(source_id) + + +def flow(sql: str, metrics: list[dict], **write) -> dict: + """收银查询 → 口径卡 → 报告(违规处置默认判失败)→ 成果。""" + nodes = [ + node("start", "input"), + node("q", "tool", tool="db_query__n1pos", args={"sql": sql}), + node("card", "metrics", caliber="收银口径", caliber_version="v1", metrics=metrics), + node("write", "report", instructions="写一句", **{"on_violation": "fail", **write}), + node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]), + ] + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +# -------------------------------------------------------------------------- +# 写作目录、报告自查 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, shown", PLAIN) +async def test_an_amount_from_a_query_whose_sql_cannot_be_traced_is_given_and_citable(monkeypatch, pos, sql, shown): + """没碰号码列的销售额:写作目录照写它的值(以前写「已遮罩」),正式运行引用它照常通过(以前判失败),同一格按 + [[v:…]] 引用同样不报违规——按指标引用和按格引用是同一个判断。""" + seen = script(monkeypatch, "本期销售额 [[m:gmv]],对照 [[v:Q1.r0.gmv]]。") + row = await finish(flow(sql, [GMV])) + assert row.status == "succeeded", row.error + lines = metric_lines(seen[0]) + assert f"销售额 = {shown}" in lines and "已遮罩" not in lines, lines + [checked] = [e.data for e in await events(row.id) if e.type == "report.checked"] + assert checked["ok"] is True and checked["repairs"] == 0, checked + doc = await doc_of(row.id) + assert "masked" not in doc["catalog"]["m:gmv"] and f"本期销售额 {shown}" in doc["markdown"], doc["markdown"] + + +async def test_a_label_column_that_only_bands_the_masked_column_is_given_and_citable(monkeypatch, pos): + """只装着分档标签的列(CASE 里写死的「已留」「未留」):值里没有号码,指标照给、照常引用。""" + seen = script(monkeypatch, "首位顾客 [[m:has]] 号码,对照 [[v:Q1.r0.has_phone]]。") + sql = "SELECT id, CASE WHEN phone IS NULL THEN '未留' ELSE '已留' END AS has_phone FROM customers ORDER BY id" + row = await finish(flow(sql, [{"id": "has", "name": "首位顾客留号", "format": "plain", + "expression": "cell(nodes.q, 0, 'has_phone')"}])) + assert row.status == "succeeded", row.error + assert "首位顾客留号 = 已留" in metric_lines(seen[0]) + doc = await doc_of(row.id) + assert "masked" not in doc["catalog"]["m:has"] and "首位顾客 已留 号码" in doc["markdown"], doc["markdown"] + + +async def test_a_metric_whose_column_is_surely_derived_from_the_masked_one_is_still_withheld(monkeypatch, pos): + """对照:SQL 里认得出取自号码列的结果列(套了函数、改了名字)照旧写「已遮罩」,同一张卡上的金额照给;引用它的正式运行 + 照旧判失败。""" + seen = script(monkeypatch, "首位顾客号码 [[m:p0]],消费 [[m:spend0]]。") + row = await finish(flow("SELECT id, UPPER(phone) AS p, amount FROM customers ORDER BY id", [ + {"id": "p0", "name": "首位顾客号码", "format": "plain", "expression": "cell(nodes.q, 0, 'p')"}, + {"id": "spend0", "name": "首位顾客消费", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "cell(nodes.q, 0, 'amount')"}])) + lines = metric_lines(seen[0]) + assert "首位顾客号码 = 已遮罩(取自设置了遮罩的列,不要引用)" in lines and "首位顾客消费 = 1,234.50元" in lines, lines + assert leaks(lines) == [] + assert row.status == "failed" and "m:p0" in (row.error or "") and leaks(row.error) == [], row.error + + +# -------------------------------------------------------------------------- +# 可验证报告:老文档只给哈希的判断 +# -------------------------------------------------------------------------- + + +async def test_an_old_doc_carrying_an_ordinary_amount_from_an_untraceable_query_is_embedded(client, monkeypatch, pos): + """升级前写的文档(写作前不认遮罩,这里把那一步换成什么都不认):证据目录里写着没碰号码列的销售额。以前可验证报告按 + 宁可多遮的那一套认,整份文档只给哈希、页面说「有 1 份报告未能核对」;现在和写作目录同一个判断,文档照常附带、核对得上。 + 口径卡照旧只给 id(卡上记着输入的值,SQL 认不准的快照按整份遮,同基线),引用它的数字无法核对。""" + import app.api.evidence as evidence_api + + async def nothing(*_args, **_kwargs): + return [] + + monkeypatch.setattr(evidence_api, "withheld_metrics", nothing) + script(monkeypatch, "本期销售额 [[m:gmv]]。") + row = await finish(flow(PLAIN[1][0], [GMV])) + assert row.status == "succeeded", row.error + resp = await client.get(f"/api/runs/{row.id}/bundle.html") + assert resp.status_code == 200, resp.text + from tests.test_bundle_masked_metrics import data_of + + bundle = data_of(resp.text) + [doc] = [a for a in bundle["artifacts"] if a["kind"] == "report_doc"] + assert "b64" in doc and "masked" not in doc, doc + assert json.loads(base64.b64decode(doc["b64"]))["catalog"]["m:gmv"]["value"] == 7036.5 + got = verdicts(bundle) + assert got["docs"] == [{"node": "write", "trusted": True}], got["docs"] + assert leaks(resp.text) == [] + + +@pytest.mark.parametrize("sql, expected", [ + *[(sql, None) for sql, _ in PLAIN], + ("SELECT id, UPPER(phone) AS gmv FROM customers ORDER BY id", "masked"), + ("SELECT id, phone AS gmv FROM customers ORDER BY id", "masked"), + ("SELECT id, CASE WHEN phone IS NULL THEN '未留' ELSE '已留' END AS gmv FROM customers ORDER BY id", None), +]) +async def test_the_bundle_judges_old_docs_like_the_report_writer(monkeypatch, pos, sql, expected): + """可验证报告认老文档里的指标给不给(_Store.metric_word 的 sure),和报告撰写节点的写作目录(_metric_masks 的 + sure、by_value=False)一样;证据面板照旧宁可多遮。""" + from app.api.evidence import MASKED, _mask_fingerprint, _Masks, _metric_in, _metric_masks, _sealed, certain_masks + from app.bundle.build import _Store + + script(monkeypatch, "收银完成。") + row = await finish(flow(sql, [{**GMV, "format": "plain", "unit": "", "decimals": None}], on_violation="flag")) + 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)) + sure, secrets = await certain_masks(sealed, _Masks(sealed)) + store = _Store(lambda a: read_bytes(a, verify=False), await _mask_fingerprint(), sealed.events, + sure_columns=sure, sure_secrets=secrets) + metric = _metric_in(card, "gmv") + _, word = await _metric_masks(metric, "card", entry["artifact"], sealed, _Masks(sealed), by_value=False, sure=True) + writer = {MASKED: "masked"}.get(word or "", word) + bundle = store.metric_word(entry["artifact"], metric, sealed.events, sure=True) + assert writer == bundle == expected, (writer, bundle) diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 95a9b2ea..16ee7d17 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -7,7 +7,8 @@ // 分几段: // - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口), // 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因; -// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处; +// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过(有无法核对的项时是中性的「可核对的项全部 +// 通过,另有 N 项无法核对」,下面醒目地写明真伪要去系统里核对封存)、没有发出任何请求,数字点开看得到出处; // 数字与取值的显示文字都核对得了(没有「无法核对显示文字」:移植的显示规则漏了真实写法时这里先报); // - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现; // - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、 @@ -20,11 +21,14 @@ // 经继续运行跑到成功的早期运行、两条出具 // 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列(卡的节点完成事件只给哈希)、同一张卡配上证据 // 目录里写着号码的老文档(文档只给哈希、页面写明原因)、设了遮罩的数据源上按地区汇总的查询、每种显示 -// 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、 -// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、整段 -// 改成分隔线、文字片段冒用数字的 id;数字前面补一位数、加负号、后面接「0 万」,隔着显示时不占位置的控制字符补一位数、 -// 长得像负号的连字符,这几种变红并点名「正文里紧挨着这个数的字改变了读者看到的数」;叠在空格上的横线、从右往左书写的 -// 字母把一位数挪到数字前面判无法核对),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、 +// 格式各引用一处的经营月报、单位带尾随空白的经营月报、引用旁边是写作者自己写的普通文字的经营月报(「统一」「——」 +// 「SKU-」「共…家」,原样导出全绿)),逐份断言各项结果和原因文案;有一项无法核对的样本页总览是中性的,不是绿的; +// 经营月报上只改文字取值、数字、换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它 +// 旁边的(链接地址藏起数字、整段改成分隔线、文字片段冒用数字的 id;数字前面补一位数、加负号(半角、数学减号、负号 +// 前面是汉字的)、后面接「0 万」,隔着显示时不占位置的控制字符补一位数,这几种变红并点名「正文里紧挨着这个数的字改变了 +// 读者看到的数」;后面接量级单位(万、亿元、%、k,订单号后面接万)、画上删除线另写一个数、长得像数字的字母(l、O)、 +// 长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数挪到数字前面判无法核对、写明原因,总览不再是绿的), +// 读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、 // 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④ // 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把 // 报告文档标成遮罩(总览不是绿的)、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡 @@ -157,6 +161,8 @@ const stateOf = (page) => page.evaluate(() => ({ buttons: [...document.querySelectorAll('button.num')].map((b) => ({ key: b.dataset.num, result: b.dataset.result, text: b.textContent })), sha: document.querySelector('[data-sha]')?.dataset.sha ?? null, + // 总览不是绿色的通过时下面醒目写着的那句(真伪要去系统里核对封存),没显示是空串 + hint: document.getElementById('overall-hint')?.hidden === false ? document.getElementById('overall-hint').textContent : '', seal: document.getElementById('seal-hash')?.textContent ?? '', overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, })) @@ -166,6 +172,14 @@ const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).jo const MASKED_QUERY = '这条查询用到或可能用到设了遮罩的列,快照未随文件附带,只能核对到哈希' /** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */ const noFail = (r) => r === 'pass' || r === 'partial' +/** + * 中性的总览:可核对的项都通过,另有几项无法核对——不是绿色的通过(无法核对的项里可能藏着读者看到的另一个数)。下面醒目地 + * 写明真伪要去系统里核对封存:运行 ID、/verify 接口、定时任务通知里的清单哈希前 12 位 + */ +const neutral = (s) => s.overall === 'partial' && /^可核对的项全部通过,另有 \d+ 项无法核对$/.test(s.text) + && s.hint.includes('/verify') && s.hint.includes('清单哈希前 12 位') +/** 总览通过:一项无法核对都没有的是绿色的「全部通过」,有的是中性的(neutral) */ +const passed = (s) => (s.overall === 'pass' && s.text === '全部通过' && s.hint === '') || neutral(s) const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result && (!text || i.text.includes(text))) @@ -374,7 +388,9 @@ await section('断网打开', async () => { const { ctx, page, requests, errors } = await openFile(writePage('bundle.html', html)) try { const s = await stateOf(page) - check('总览通过', s.overall === 'pass', `${s.overall}:${s.text}`) + check('总览通过(有无法核对的项时是中性的「可核对的项全部通过,另有 N 项无法核对」)', passed(s), `${s.overall}:${s.text}`) + check('总览不是绿色的通过时,醒目地写明去系统里核对封存(运行 ID、清单哈希前 12 位)', s.overall === 'pass' + || (s.hint.includes(tamperable.meta.run_id) && s.hint.includes(tamperable.meta.manifest_hash.slice(0, 12))), s.hint) for (const key of ['events', 'manifest', 'chain']) check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key]) // 遮罩快照、服务端已没有文件的工件是正常的无法核对;没有出处的数字同理 check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts) @@ -423,7 +439,7 @@ await section('去掉 crypto.subtle', async () => { const { ctx, page, requests, errors } = await openFile(join(TMP, 'bundle.html'), { noSubtle: true }) try { const s = await stateOf(page) - check('照常全部通过', s.overall === 'pass' && !Object.values(s.checks).includes('fail'), describe(s)) + check('照常全部通过', passed(s) && !Object.values(s.checks).includes('fail'), describe(s)) check('页脚写明用的是本页内置的实现', s.sha === 'js', String(s.sha)) check('没有发出任何网络请求、没有报错', !requests.length && !errors.length, [...requests, ...errors].slice(0, 2).join(' | ')) } finally { @@ -583,11 +599,22 @@ await section('样本页:各种形状', async () => { check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e)) return } - check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 15, Object.keys(samples).join('、')) + check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 16, Object.keys(samples).join('、')) await sample('base', async (s, page) => { - check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', + check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s), `${s.overall}:${s.text}`) + const meta = dataOf(samples.base).meta + check('base:总览下面醒目地写明真伪要去系统里核对封存,带着运行 ID 和清单哈希前 12 位', + s.hint.includes(meta.run_id) && s.hint.includes(meta.manifest_hash.slice(0, 12)) && s.hint.includes('判断不了真伪') + && await page.locator('#overall-hint').isVisible(), s.hint) + check('base:总览框不是通过的绿底', await page.locator('#overall').evaluate((el) => { + const probe = document.body.appendChild(document.createElement('div')) + probe.style.background = 'var(--ok-soft)' + const ok = getComputedStyle(probe).backgroundColor + probe.remove() + return getComputedStyle(el).backgroundColor !== ok + })) check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass') && s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s)) check(`base:选出了遮罩列的快照在 ③ 无法核对,写明「${MASKED_QUERY}」`, hasItem(s, 'artifacts', 'na', MASKED_QUERY)) @@ -653,7 +680,7 @@ await section('样本页:各种形状', async () => { check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass') }) await sample('legacy', async (s, page) => { - check('legacy:总览通过,范围写明按结束事件推算', s.overall === 'pass' + check('legacy:总览中性(运行结果无法核对),范围写明按结束事件推算', neutral(s) && (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text) check('legacy:运行结果无法核对,写明早期运行的结束事件不在封存范围内', s.meta.status?.result === 'na' && hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status)) @@ -669,8 +696,8 @@ await section('样本页:各种形状', async () => { }) // 早期运行先失败、经继续运行跑到成功:范围里那条运行失败之后运行又继续了,它不是运行的结束;成功结束的那条在范围外 await sample('continued', async (s) => { - check('continued:总览通过,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对', - s.overall === 'pass' && s.meta.status?.result === 'na' && hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), + check('continued:总览中性,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对', + neutral(s) && s.meta.status?.result === 'na' && hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status)) }) await sample('degraded', async (s) => { @@ -694,7 +721,7 @@ await section('样本页:各种形状', async () => { }) await sample('missing-card', async (s) => { check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件')) - check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`) + check('missing-card:服务端缺文件是正常的无法核对,没有不通过,总览中性', neutral(s), `${s.overall}:${s.text}`) }) // 口径卡上有输入取自遮罩列(会员号码):卡上记着号码原值和代入式,整张卡只给 id。这是正常的无法核对,不是被改过 await sample('masked-card', async (s, page) => { @@ -704,7 +731,7 @@ await section('样本页:各种形状', async () => { const metricNumber = s.items.find((i) => i.check === 'numbers' && i.text.includes('销售额')) check('masked-card:引用这张卡的数字无法核对,原因同上', metricNumber?.result === 'na' && metricNumber.text.includes('含遮罩列'), metricNumber?.text) - check('masked-card:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' && s.text === '可核对的项全部通过' + check('masked-card:总览中性(可核对的项全部通过,另有几项无法核对),没有一项不通过', neutral(s) && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`) // 卡的节点完成事件预览里逐条写着「指标(名称)= 值」,号码那个指标也在里面:只给哈希,① 里不列它 const data = dataOf(samples['masked-card']) @@ -746,8 +773,8 @@ await section('样本页:各种形状', async () => { const phoneCited = s.items.find((i) => i.check === 'numbers' && i.text.includes('查询 Q2')) check('masked-source:引用它的数字无法核对,原因同上', phoneCited?.result === 'na' && phoneCited.text.includes(MASKED_QUERY), phoneCited?.text) - check('masked-source:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' - && s.text === '可核对的项全部通过' && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`) + check('masked-source:总览中性(可核对的项全部通过,另有几项无法核对),没有一项不通过', neutral(s) + && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`) // 点开地区汇总的第一个数字:出处面板里有这条查询的 SQL 和被引用的那一行。先看有没有核对通过的数字,没有就照实记一笔、 // 不去点:点空了要等满时限才抛错,同一节后面的样本(连同下面这一项)就都不跑了 const passed = page.locator('button.num[data-result="pass"]') @@ -762,7 +789,7 @@ await section('样本页:各种形状', async () => { check('masked-source:页面里找不到号码', !html.includes('000-0001') && !html.includes('000-0002')) }) await sample('nan', async (s) => { - check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass' + check('nan:内容里有裸 NaN 的事件和工件照常通过', passed(s) && s.checks.events === 'pass' && !s.items.some((i) => i.result === 'fail'), describe(s)) }) await sample('stray', async (s) => { @@ -773,8 +800,9 @@ await section('样本页:各种形状', async () => { // 经营月报:每种显示格式各引用一处(货币单位、万 / 亿、比率、负数、不分组、小数位、pct、DECIMAL 文本、超过 2^53 的 // 订单号、日期时间、文字取值),原样导出全部通过:每个数字和取值的显示文字都按显示规则重新渲染、逐字对上 await sample('formats', async (s, page) => { - check('formats:总览「全部通过」,没有一项无法核对', s.overall === 'pass' && s.text === '全部通过' - && !s.items.some((i) => i.result !== 'pass'), `${s.overall}:${s.text}`) + check('formats:总览「全部通过」,没有一项无法核对,不另写去系统里核对的提示', s.overall === 'pass' && s.text === '全部通过' + && !s.items.some((i) => i.result !== 'pass') && s.hint === '' && await page.locator('#overall-hint').isHidden(), + `${s.overall}:${s.text}`) check('formats:⑤ 数字 16 个、取值 3 个全部通过', s.checks.numbers === 'pass' && s.sums.numbers.includes('数字 16 个 · 取值 3 个') && s.numberStates.pass === 19, s.sums.numbers) const wan = page.locator('button.num').filter({ hasText: /^7\.46万元$/ }) @@ -817,6 +845,14 @@ await section('样本页:各种形状', async () => { check('formats-unit:点开销售额,注明与按显示规则渲染的结果一致,不另列「按显示规则应为」', detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'), detail.replace(/\s+/g, ' ').slice(0, 160)) }) + // 引用旁边是写作者自己写的普通文字(「运费统一10元」「合计——7,036.50元」「SKU-202…」「共12840单家」、破折号、全角括号、 + // 「NO.」、假名长音、制表符横线、粗体斜体包着的数):服务端组装的文档原样导出,全部通过,总览是绿的 + await sample('prose', async (s) => { + check('prose:普通写法紧挨着核对过的数,原样导出「全部通过」,没有一项无法核对', s.overall === 'pass' + && s.text === '全部通过' && !s.items.some((i) => i.result !== 'pass') && s.hint === '', `${s.overall}:${s.text}`) + check('prose:15 个数字和取值都显示成可点的数字、都通过', s.numberStates.pass === 15 && s.numbers === 15, + JSON.stringify(s.numberStates)) + }) // 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」 { @@ -931,7 +967,7 @@ await section('样本页:篡改', async () => { && card.text.includes('导出时服务端的这份文件无法解析,认不出是否含遮罩列'), card?.text], ['引用它的数字原因同上', metricNumber?.result === 'na' && metricNumber.text.includes('认不出是否含遮罩列'), metricNumber?.text], - ['总览照常通过', s.overall === 'pass', `${s.overall}:${s.text}`], + ['总览中性,没有不通过', neutral(s), `${s.overall}:${s.text}`], ] }) await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [ @@ -1056,6 +1092,8 @@ await section('样本页:篡改', async () => { const glued = [ ['核对通过的数字前面补一位数', 's3', (t) => `${t}1`, 's4', '本月销售额 174,613.65元'], ['核对通过的数字前面加负号', 's12', (t) => `${t}-`, 's13', '转化率 -2.35%'], + ['核对通过的数字前面加数学减号', 's12', (t) => `${t}\u2212`, 's13', '转化率 −2.35%'], + ['核对通过的数字前面紧挨着汉字加负号', 's12', () => '转化率-', 's13', '转化率-2.35%'], ['核对通过的数字后面接「0 万」', 's34', (t) => `0 万${t}`, 's33', '流水 45,678.50 万'], ] for (const [label, at, change, target, what] of glued) { @@ -1084,9 +1122,10 @@ 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) @@ -1094,17 +1133,51 @@ await section('样本页:篡改', async () => { return [ ['那个数字判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)], [`⑤ 写明「${why}」`, !!item, item?.text ?? '没有这一项'], - ['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`], + ['总览不是绿色的通过:中性,并写明真伪去系统里核对', neutral(s) && s.checks.numbers === 'partial', `${s.overall}:${s.text}`], + ['①–④ 照样通过(伪造重算了全部哈希)', ['events', 'manifest', 'artifacts', 'chain'].every((k) => noFail(s.checks[k])), + describe(s)], ] } await tamper(samples.formats, '隔着显示时不占位置的控制字符补一位数', (data) => rewriteDoc(data, (doc) => { segById(doc, 's3').text += `1${cp(0x7f)}` return { what: '销售额前面补「1」和一个 DEL', seg: segById(doc, 's4') } }), 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') } - }), failsBecause(GLUED), { seal: true }) + }), naBecause('前面紧挨着一道像负号的短横'), { seal: true }) + // 后面接量级单位:读者看到的数差了量级(45,678.5万 读作四亿多)。系统显示一个数时单位、换算都在核对过的字里,判无法 + // 核对、写明原因,总览不再是绿的。以前连无法核对都不是,总览「全部通过」 + for (const [label, at, change, target] of [ + ['核对通过的数字后面接「万」', 's34', (t) => `万${t}`, 's33'], + ['核对通过的数字后面隔着空格接「万」', 's34', (t) => ` 万${t}`, 's33'], + ['核对通过的数字后面接「亿元」', 's34', (t) => `亿元${t}`, 's33'], + ['核对通过的数字后面接「%」', 's34', (t) => `%${t}`, 's33'], + ['核对通过的数字后面接「k」', 's34', (t) => `k${t}`, 's33'], + ['订单号后面接「万」', 's43', (t) => `万${t}`, 's42'], + ]) { + await tamper(samples.formats, label, (data) => rewriteDoc(data, (doc) => { + segById(doc, at).text = change(segById(doc, at).text) + return { what: label, seg: segById(doc, target) } + }), naBecause('后面接着量级单位'), { seal: true }) + } + // 删除线:核对过的数画上删除线、旁边另写一个数(读的人以为它已作废、以旁边的为准) + await tamper(samples.formats, '核对通过的数字画上删除线、旁边另写一个数', (data) => rewriteDoc(data, (doc) => { + segById(doc, 's3').text += '~~' + segById(doc, 's5').text = `~~ 84,613.65元${segById(doc, 's5').text}` + return { what: '本月销售额 ~~74,613.65元~~ 84,613.65元', seg: segById(doc, 's4') } + }), naBecause('这个数画着删除线'), { seal: true }) + // 长得像数字的字母:l74,613.65元 读作 174,613.65元,45,678.5O 读作 45,678.50 + for (const [label, at, change, target] of [ + ['核对通过的数字前面加小写 L', 's3', (t) => `${t}l`, 's4'], + ['核对通过的数字后面接大写 O', 's34', (t) => `O${t}`, 's33'], + ]) { + await tamper(samples.formats, label, (data) => rewriteDoc(data, (doc) => { + segById(doc, at).text = change(segById(doc, at).text) + return { what: label, seg: segById(doc, target) } + }), naBecause('紧挨着长得像数字的字母或符号'), { seal: true }) + } await tamper(samples.formats, '叠在空格上的横线', (data) => rewriteDoc(data, (doc) => { segById(doc, 's3').text += cp(0x336) return { what: '销售额前面的空格上叠一道长横线(U+0336)', seg: segById(doc, 's4') }