期 5 验收修复 N1 第 2 轮:取自遮罩格的指标只按确定取自遮罩列的认、转抄上游口径卡的也认;显示文字核对不误判正常写法,总览有一项无法核对就不给绿色的通过

复核第 1 轮(abb2707)找到四处:写作目录按证据面板宁可多遮的那一套认指标出处,SQL 认不准时没碰号码列的销售额也成了
「已遮罩」,正式运行引用它就判失败(相对基线回退);「紧挨着负号」连破折号「——」「—」和汉字「一」都算,「运费统一10元」
「合计——7,036.50元」原样导出变红(相对基线回退);紧挨着改字只堵住了数字、负号、小数点,接「万」、画删除线、从右往左
书写的字母、l / I / O 照样全绿,根子在总览把无法核对的项也给绿色;第二张口径卡转抄遮罩指标(nodes.<卡>.metrics[0].value、
vars.<assign_to>.metrics[0].value、nodes.<卡>.text)时号码照样进写作目录、文档证据目录和可验证报告。

取自遮罩格的指标只按确定的那一套认(写作目录、报告自查、可验证报告同一个判断)
- 写作目录(api/evidence.py 的 withheld_metrics)认指标出处时遮罩列只认确定取自遮罩列的(_InputMasks 的 sure:读得出来的
  快照按单元格引用自查的 _cited_masks,读不出来的按数据源现在设的列名),和 [[v:…]] 引用同一格的自查同一个口径:WITH 写了
  列名表、VALUES 当表、子查询里有没起别名的号码表达式、只装着分档标签的列,指标照常显示、照常引用;改了名字、套了函数的
  号码列照旧「已遮罩」。证据面板、推演照旧宁可多遮。报告自查按目录里记的 masked 认,跟着同一个判断
- 可验证报告认老文档(证据目录里还写着原值,整份只给哈希)按同一个判断:api/bundle.py 先按证据面板的那一套认好每份查询
  快照里确定取自遮罩列的列和这次运行查到过的遮罩值(certain_masks),交给 build_bundle(sure_columns、sure_secrets),
  _Store.metric_word 的 sure 按它们认。认口径卡能不能随文件附带照旧宁可多遮(卡上记着输入的值),SQL 认不准的卡同基线只给 id

转抄上游口径卡的指标按上游那个指标认
- recompute.card_input 认出输入转抄了哪张卡的哪几个指标:nodes.<卡>.metrics[i].value、vars.<卡的 assign_to>.metrics[i]、
  整个指标、输入、渲染算转抄那个指标;整张卡、清单文字、整份指标列表、下标不是字面量的同整份查询结果(值里有遮罩值才不给,
  数指标个数的照给);只取名称、单位、口径名的不算
- 证据面板 / 写作目录(_InputMasks._copied)、可验证报告(_Store._copied_kind)都用同一个 _metric_masks / metric_word 往上追,
  上游取自遮罩格,转抄的也不给;追了 CARD_DEPTH(8)张还没追到头(循环里抄自己上一轮的产出)的宁可多遮
- 写作目录读快照前把运行状态里每张口径卡的输入一并读好,不在事件循环里读盘

显示文字核对(verifier.js)
- 判不通过只剩紧挨着的数字、小数点,和真当负号用的三个(半角连字符减号、数学减号、全角减号)紧挨着数字开头、前面又不是
  字母数字时(「SKU-12」「A-3」的连字符是编号的一部分)。破折号(——、—、–、―,小号的、竖排的)、波浪线、汉字「一」、
  假名长音、制表符横线这些易混字当普通的字。连字符、不断行连字符、上下标负号、粗减号这类平时不当负号用的短横紧挨着数字
  开头判无法核对(以前判不通过;基线也是无法核对)
- 判无法核对(带原因):后面接着量级单位(万、亿、千、百、兆、萬、億、%、‰、‱、k、K、M、B 及全角,中间隔着空格也算;
  核对过的字以「万」「亿」结尾再接一个也算),核对过的字画着删除线或紧挨着画了删除线的字,紧挨着长得像数字的字母和数字
  符号(l、I、O、上下标数字、圈码、罗马数字、汉字〇),从右往左书写的字母和方向控制字符(照旧)
- 总览(overallOf):一项无法核对都没有、每份报告都核对上才是绿色的「全部通过」;有无法核对的写中性的「可核对的项全部通过,
  另有 N 项无法核对」,下面醒目地写明这个文件判断不了真伪,要在系统中核对封存(运行 ID、GET /api/runs/<id>/verify、定时任务
  通知里的清单哈希前 12 位)。以前无法核对的项照样给绿色,伪造者只要让读者看到的那个数落进无法核对就行
- 交出 layoutOf 给 node 里逐条核排法;开发数据 39 份报告 1591 处、验收数据 58 份报告 1695 处引用按新规则扫过,没有一处判
  不通过或无法核对

测试
- 新增 tests/test_report_masked_metric_sure.py(四种 SQL 认不准的销售额、分档标签照给照引、确定取自号码列的照旧遮、老文档
  照常附带、写作目录和可验证报告逐个指标同一个判断)、tests/test_report_masked_card_copies.py(三种转抄写法在写作目录、文档、
  自查、证据面板、可验证报告里都不给号码,没有报告的运行、老文档,转抄普通指标照给,认转抄的各种写法,追不到头的宁可多遮)、
  tests/test_bundle_prose_export.py(「统一」「——」「—」「SKU-」「共…家」「–」真跑报告撰写节点、从接口导出,全部通过、总览绿)
- test_bundle_shown:负号只认三个、前面是汉字的照判不通过;像负号的短横、量级单位、删除线、长得像数字的字母各自判无法核对、
  总览中性;正常写法原样导出全绿;总览只在一项无法核对都没有时是绿的
- 样本页新增 prose(写作者自己写在引用旁边的普通文字,原样导出全绿);check-bundle 补数学减号、紧挨着汉字加负号判不通过,
  接万(隔空格)、接亿元、接 %、接 k、订单号接万、画删除线另写一个数、前面加小写 L、后面接大写 O、长得像负号的连字符判无法
  核对,各自点名原因、总览中性并写明去系统里核对;有无法核对项的样本页(base、legacy、continued、missing-card、masked-card、
  masked-source、nan)总览改断言中性,从右往左书写的字母那条不再断言总览是绿的
- 每处改动都做了「去掉实现、测试变红」:写作目录不用 sure、老文档不用 sure、sure 列不用、转抄认不出(三处各自)、追不到头
  按核对不了、负号的字母数字例外、负号恢复成 \p{Pd} 全部、数学减号全角减号不算、量级单位、隔空格、以万结尾、删除线、像数字
  的字母、像负号的短横、总览有无法核对照样绿
This commit is contained in:
ThearchyHelios
2026-10-12 19:09:50 +08:00
parent abb2707904
commit ce71f7d5c1
16 changed files with 1221 additions and 146 deletions
+5 -1
View File
@@ -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),
+99 -12
View File
@@ -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:
+81 -29
View File
@@ -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)
+5 -1
View File
@@ -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
<span class="overall-mark js-only" aria-hidden="true">…</span><span id="overall-text"><span class="js-only">正在核对…</span><span class="no-js">尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本</span></span>
</div>
<p id="overall-sub" class="overall-sub"></p>
<p id="overall-hint" class="overall-hint" role="note" hidden></p>
<section class="seal" aria-labelledby="seal-label">
<h2 id="seal-label">封存清单哈希</h2>
<code id="seal-hash" class="hash">__BUNDLE_SEAL__</code>
+121 -42
View File
@@ -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
}
+6 -6
View File
@@ -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):
+6 -4
View File
@@ -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 [])
+66
View File
@@ -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'] 这种标量引用——查询结果经数据
+11 -1
View File
@@ -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
+1 -1
View File
@@ -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
+16 -10
View File
@@ -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"}
+101
View File
@@ -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"]
+133 -13
View File
@@ -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 = "正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过"
@@ -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"
@@ -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)
+99 -26
View File
@@ -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') }