mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
期 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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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'] 这种标量引用——查询结果经数据
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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') }
|
||||
|
||||
Reference in New Issue
Block a user