Files
AgentLab/backend/tests/test_provenance_unchanged.py
ThearchyHelios 25011d074b M2 复核第 1 轮:合并查询按合并 SQL 认遮罩列,报告自查只拦确定取自遮罩列的格子,推演和证据面板对指标原值同一个判断,面板认法和可验证报告同一个函数
接手被限流打断的第 1 轮修复,补完后提交。处理的复核发现:
- 合并查询:合并 SQL 里对遮罩列改名、套函数、拼接、汇总、经 WITH 转一道得来的结果列,以前没有逐格来历就认不出,片段接口、勾行接口原样给出号码、报告自查也漏判。现在 _merge_mask_sets 把各输入要遮的列名当作遮罩列、按 SQLite 读法认合并 SQL;输入快照读不出来又可能带着遮罩的,它的列全部当遮罩列
- 报告自查:_mask_sets 分出 hidden(面板、推演按它遮,宁可多遮)和 sure(自查按它判违规)。SQL 认不准、整份算进来的列,只装着分档标签的列不再判 masked_cell,正式运行不会因为和遮罩列无关的金额判失败;sure 按数据源自己的方言认
- 判失败的下一步:违规里有引用遮罩列的,只说去改写作指令,不建议换模型、不建议先改成标注
- 时间线、缺口不再说「这些值会随报告出具」「出具已按缺口降档」:探索运行、没设出具契约的成果不出具,只说已记为缺口
- 只读引用到的快照:报告节点组装好一稿之后只认正文引用到的那几份查询快照,认过的不再读
- 审计表、导出的 CSV 给 masked_cell 单列一行
- 推演和证据面板:指标原值给不给是同一个判断(recompute.withheld_value),输入分类同一套(input_origin 的 wide:整行、下标不是字面量的、整份结果都认)。数行数的 len(vars.res.rows) 两边都照给,str(vars.res.rows)、整行、数据整形取出来的号码两边都不给;遮没遮核对不了的写「无法核对」,不写「已遮罩」
- 认不出出处的输入按值认:值就是这次运行查到过的遮罩值的不给、不推演
- 证据图 evidence[].label 用到遮罩列的指标写「名称 = 已遮罩」
- 证据面板认遮罩列调可验证报告的那个函数(bundle/build.py 的 _derived_columns),再按数据源方言认一遍,可验证报告整份遮的面板不会照给
- 查询步骤的 SQL、合并 SQL、SQL 检查片段、合并警告里写着的遮罩值展示时换成「已遮罩」,底下写明只是展示时换的
- 交给裁判的摘录:按需裁判、节点内当场裁判都把 SQL 里改了名字的遮罩列并进数据源名下(_judge_masks)
- on_demand:当场裁判过、这句没判成(触顶、出错、被截断)时照实说没判成
2026-10-12 10:17:40 +08:00

397 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""期 4「期 4 之前不变」的金样断言(P4-SPEC 6.6、1.4,WP-E)。
金样 `tests/fixtures/evidence/pre_p4_golden.json` 由 WP-0 在期 4 改动之前的提交(2cdbf9a)上真跑管线生成,自带三份
报告(配方源、简单导入、手工源)的全部输入工件和四项输出:verify_doc、裁判摘录、_chain(单元格片段的查询步骤)、
文档本身。这里不重跑管线(导入清单里有 created_at,重跑得到的 id 会不同),而是:
1. 把金样里的输入工件逐个 put_json 进本测试的临时数据目录,断言返回的 id 等于金样记下的键(内容寻址:对不上说明
金样被改过,后面的比较就没有意义);
2. 用新代码对同样的输入重算,逐字比金样。
为什么要这一层:期 4 的承诺是「期 4 之前的运行、非上传源的面板、复核、裁判摘录一个字都不变」(0.2 补充 1)。
文档标记 `provenance` 是唯一的开关(1.4):没有它的文档必须走和期 4 之前完全相同的路径,连 provenance、
direct_select 两个模块的函数都不许碰(3.3);带上它之后,非上传源仍然一字不差,上传源只在约定的位置多出约定的东西
(查询步骤只多 `provenance: true`;摘录只在 SQL 那一行之后、「列:」之前多出来历行)。
金样里的 `_chain` 输出是用预填 memo 的 `_Masks` 替身算的(不查库),这里用同样的替身;`_Sealed` 按金样记下的封存
范围在内存里拼(6.6)。夹具全部合成、假名,没有任何模型调用。
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any
import pytest
from app.api import evidence as api
from app.core import artifact_store
from app.core.config import settings
from app.data import provenance as provenance_mod
from app.data.provenance_types import DOC_PROVENANCE, CellRef, contract_problems
from app.data.tabular import UNSHAPED_NOTE
from app.engine import direct_select as direct_select_mod
from app.engine import judge
from app.engine.evidence import iter_segments, verify_doc
GOLDEN_PATH = Path(__file__).parent / "fixtures" / "evidence" / "pre_p4_golden.json"
GOLDEN: dict[str, Any] = json.loads(GOLDEN_PATH.read_text("utf-8"))
REPORTS = ("recipe", "simple", "manual")
#: 上传表格的两份(查询快照有 data_version):带上标记后查询步骤多 provenance: true、摘录多来历行
UPLOADS = ("recipe", "simple")
#: 来历行(P4-SPEC 3.2)可能的行首:来源、来源的分期行(两个空格缩进)、口径、已接受、区域外文字、截断说明
NEW_LINE_HEADS = ("来源:", " ", "口径:", "已接受(", "区域外", "另有 ", "…另有部分来历信息未列出")
# ==========================================================================
# 夹具:临时数据目录里重建金样的输入工件
# ==========================================================================
@pytest.fixture(autouse=True)
async def rebuilt(monkeypatch, tmp_path) -> dict[str, str]:
"""每个用例一个独立的数据目录,金样的输入工件和三份文档逐个 put_json 进去,id 必须等于金样的键。
独立目录的理由:A26 那一例要把导入清单的文件改坏,不能影响别的用例;工件按内容寻址、不带运行 id,放在共用的
数据目录里会和别的测试串。"""
data = tmp_path / "data"
data.mkdir()
monkeypatch.setattr(settings, "data_dir", data)
ids: dict[str, str] = {}
for aid, content in GOLDEN["artifacts"].items():
got = await artifact_store.put_json(copy.deepcopy(content), kind="golden_input")
assert got == aid, f"金样里的工件 {aid[:12]}… 重建出来的 id 是 {got[:12]}…:金样被改过"
ids[aid] = got
for name in REPORTS:
rep = GOLDEN["reports"][name]
got = await artifact_store.put_json(copy.deepcopy(rep["outputs"]["doc"]), kind="report_doc")
assert got == rep["doc_artifact"], f"{name} 的文档重建出来的 id 不对:金样被改过"
return ids
@pytest.fixture
def tripwire(monkeypatch) -> list[str]:
"""把 provenance、direct_select 两个模块的公开函数全换成一调用就记下来并抛错的桩(P4-SPEC 3.3、6.6)。
老文档(没有标记)走的路径必须和期 4 之前完全相同,连这两个模块都不许碰:桩被调用一次,用例就失败。函数体内
`from app.data.provenance import judge_lines` 这种写法每次调用时现取模块属性,同样会撞上桩。"""
hits: list[str] = []
patched: list[str] = []
for mod in (provenance_mod, direct_select_mod):
for name, obj in vars(mod).items():
if name.startswith("_") or not callable(obj) or getattr(obj, "__module__", None) != mod.__name__ \
or isinstance(obj, type):
continue
def boom(*_a: Any, __name: str = f"{mod.__name__}.{name}", **_kw: Any) -> Any:
hits.append(__name)
raise AssertionError(f"期 4 之前的文档不该调用 {__name}")
monkeypatch.setattr(mod, name, boom)
patched.append(name)
# 桩真的装上了:入口函数一个不漏(漏了的话「没被调用」就是空话)
assert {"recognize", "tables_in", "judge_lines", "resolve_chain", "chain_artifacts", "version_view", "locate",
"related_checks"} <= set(patched), patched
return hits
# ==========================================================================
# 小工具
# ==========================================================================
def report(name: str) -> dict[str, Any]:
return GOLDEN["reports"][name]
def old_doc(name: str) -> dict[str, Any]:
doc = copy.deepcopy(report(name)["outputs"]["doc"])
assert "provenance" not in doc, "金样里的文档是期 4 之前组装的,不该有 provenance 键"
return doc
def marked_doc(name: str) -> dict[str, Any]:
return {**old_doc(name), "provenance": DOC_PROVENANCE}
def sealed_of(name: str) -> api._Sealed:
"""金样记下的封存范围(6.6):内存里拼,不读运行记录。"""
s = report(name)["sealed"]
return api._Sealed(run_id=report(name)["run_id"], graph={}, seal=dict(s["seal"]), events=[],
queries={k: dict(v) for k, v in s["queries"].items()},
schemas={k: dict(v) for k, v in s["schemas"].items()})
class _StubMasks(api._Masks):
"""金样生成时 `_Masks` 的替身:预填 memo(源名 → (遮罩列小写, 数据源还在)),不查库。别的(按工件记下的遮罩、
这次运行查到过的遮罩值)照 `_Masks` 的办法算。"""
def __init__(self, memo: dict[str, tuple[set[str], bool]], sealed: Any = None) -> None:
super().__init__(sealed)
self._memo = memo
async def of(self, source: Any) -> tuple[set[str], bool]:
name = str(source or "")
if not name:
return set(), True
return self._memo.get(name, (set(), True))
def stub_masks(monkeypatch, name: str) -> None:
memo = {k: ({c.lower() for c in v}, True) for k, v in report(name)["masks"].items()}
monkeypatch.setattr(api, "_Masks", lambda sealed=None: _StubMasks(memo, sealed))
def cell_segments(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [s for s in iter_segments(doc) if (s.get("cite") or {}).get("kind") == "cell"]
async def chains(doc: dict[str, Any], name: str) -> dict[str, list[dict[str, Any]]]:
sealed = sealed_of(name)
out = {}
for seg in cell_segments(doc):
out[seg["id"]] = await api._chain(seg, doc, sealed, report(name)["node_id"])
# 金样存的是 JSON:元组、浮点数都按 JSON 往返一次再比
return json.loads(json.dumps(out, ensure_ascii=False))
def excerpts(doc: dict[str, Any], name: str, *, loader: Any = None, masked: Any = None) -> dict[str, str]:
return judge.prepare(doc, report(name)["catalog"], loader=loader or artifact_store.load, masked=masked).excerpts
def verified(doc: dict[str, Any], name: str) -> dict[str, Any]:
v = verify_doc(doc, report(name)["catalog"], loader=artifact_store.load)
return {"ok": v["ok"], "stats": v["stats"], "violations": v["violations"]}
def state_mismatches(v: dict[str, Any]) -> list[dict[str, Any]]:
return [x for x in v["violations"] if x.get("code") == "state_mismatch"]
def split_new_lines(text: str) -> tuple[list[str], list[str]]:
"""查询摘录 → (去掉来历行之后的行, 来历行)。来历行只许出现在「SQL:」那一行之后、「列:」那一行之前(3.2)。"""
lines = text.split("\n")
sql = [i for i, line in enumerate(lines) if line.startswith("SQL:")]
cols = [i for i, line in enumerate(lines) if line.startswith("列:")]
assert len(sql) == 1 and len(cols) == 1 and sql[0] < cols[0], f"查询摘录的形状变了:\n{text}"
return lines[:sql[0] + 1] + lines[cols[0]:], lines[sql[0] + 1:cols[0]]
def golden_tables(schema: dict[str, Any]) -> dict[str, list[str]]:
"""冻结表结构 → {表: [列…]}(按定义顺序),direct_select.tables_in 的入参(3.3)。"""
return {name: [str(c.get("name")) for c in (meta.get("columns") or [])]
for name, meta in (schema.get("tables") or {}).items()}
def expected_judge_lines(name: str, alias: str) -> list[str]:
"""契约(3.3):_query_excerpt 先用 tables_in 算出 SQL 用到的表,再调 judge_lines,把返回的行原样插进摘录。
金样的报告没有遮罩(masks 为空、快照没有 mask_columns),hidden 为空集。"""
entry = report(name)["catalog"][alias]
query = artifact_store.load(entry["artifact"])
schema = artifact_store.load(query["schema_artifact"]) if query.get("schema_artifact") else None
tables = direct_select_mod.tables_in(query["sql"], golden_tables(schema or {}))
return provenance_mod.judge_lines(query, schema, artifact_store.load, tables=tables, hidden=set())
# ==========================================================================
# 金样本身
# ==========================================================================
def test_golden_records_three_kinds_of_sources(rebuilt):
"""金样的三份报告正好是规格要的三种源(6.6):配方源有 import_manifests、简单导入的表结构快照没有 import_mode、
手工源的查询快照没有 data_version。重建出的工件 id 全部等于金样的键(夹具里已逐个断言)。"""
arts = GOLDEN["artifacts"]
assert set(rebuilt) == set(arts) and all(k == v for k, v in rebuilt.items())
assert set(GOLDEN["reports"]) == set(REPORTS)
rq = arts[report("recipe")["artifacts"][0]]
rs = arts[rq["schema_artifact"]]
assert rq.get("data_version") and rs.get("import_mode") == "recipe" and len(rs["import_manifests"]) == 1
sq = arts[report("simple")["artifacts"][0]]
ss = arts[sq["schema_artifact"]]
assert sq.get("data_version") and "import_mode" not in ss and ss["tables"]["月报"]["comment"] == UNSHAPED_NOTE
mq = arts[report("manual")["artifacts"][0]]
assert "data_version" not in mq and "schema_artifact" not in mq
for name in REPORTS:
assert cell_segments(old_doc(name)), name
assert report(name)["outputs"]["excerpts"], name
assert report(name)["outputs"]["chain"], name
# ==========================================================================
# 期 4 之前的文档(没有标记):一个字都不变,也不碰新模块
# ==========================================================================
@pytest.mark.parametrize("name", REPORTS)
def test_old_documents_verify_exactly_as_before(name, tripwire):
"""① verify_doc 逐字等于金样,没有 state_mismatch(verify_doc 不看未知的顶层键,也不读裁判摘录,1.4)。"""
got = verified(old_doc(name), name)
assert got == report(name)["outputs"]["verify_doc"]
assert state_mismatches(got) == []
assert tripwire == []
@pytest.mark.parametrize("name", REPORTS)
def test_old_documents_give_the_same_judge_excerpts(name, tripwire):
"""② 裁判摘录逐字等于金样(摘录进断点指纹和逐句复用键,变一个字已判过的句子就不复用了,3.1)。"""
assert excerpts(old_doc(name), name) == report(name)["outputs"]["excerpts"]
assert tripwire == []
@pytest.mark.parametrize("name", REPORTS)
async def test_old_documents_give_the_same_query_steps(name, monkeypatch, tripwire):
"""③ 单元格片段的出处链(查询步骤)逐字等于金样:没有 provenance 键,界面不多发请求(2.8.2)。"""
stub_masks(monkeypatch, name)
got = await chains(old_doc(name), name)
assert got == report(name)["outputs"]["chain"]
assert not any("provenance" in step for steps in got.values() for step in steps)
assert tripwire == []
@pytest.mark.parametrize("name", REPORTS)
async def test_old_documents_answer_legacy_doc(name, tripwire):
"""推断来源接口对老文档一律回 none · legacy_doc(R0),不标红、没有数据版本;cell 照样给(与 status 无关,2.8.1)。
R0 在一切取证之前判,所以连查询快照都不用取,也不碰新模块。"""
doc = old_doc(name)
rep = report(name)
chosen = api._Report(node_id=rep["node_id"], doc_artifact=rep["doc_artifact"], ok=True, repairs=0, stats=None,
doc=doc, hash_ok=True, fields=[])
for seg in cell_segments(doc):
out = await api.segment_provenance(chosen, seg, sealed_of(name), masks=_StubMasks({}))
assert contract_problems(out) == []
assert (out.status, out.reason.code, out.alert, out.version, out.cell_source, out.checks) == \
("none", "legacy_doc", None, None, None, [])
assert out.sealed is True and out.segment == seg["id"]
loc = seg["cite"]["locator"]
assert out.cell == CellRef(alias=seg["cite"]["alias"], row=loc["row"], column=loc["column"],
artifact=rep["catalog"][seg["cite"]["alias"]]["artifact"])
assert out.report.node_id == rep["node_id"] and out.report.doc_artifact == rep["doc_artifact"]
assert tripwire == []
async def test_old_document_on_demand_judge_ignores_a_broken_manifest(tripwire):
"""A26(金样一侧):老文档走按需裁判的 loader(`_sealed_loader`,chain 按文档标记取,老文档为假),把表结构快照
列出的导入清单文件改坏一个字节,摘录仍逐字等于金样:chain 为假时根本不去取清单(2.8.3)。"""
name = "recipe"
doc = old_doc(name)
rep = report(name)
schema = artifact_store.load(artifact_store.load(rep["artifacts"][0])["schema_artifact"])
[manifest] = schema["import_manifests"]
path = artifact_store._path_of(manifest)
raw = path.read_bytes()
at = raw.index(b'"seq":')
path.write_bytes(raw[:at] + b'"seQ":' + raw[at + 6:])
with pytest.raises(ValueError):
artifact_store.load(manifest)
# _judge_now 的同一套取法:loader 按文档标记决定扩不扩展、遮罩按这个 loader 查
loader = api._sealed_loader(sealed_of(name), chain=doc.get("provenance") == DOC_PROVENANCE)
assert loader(manifest) is None
masked = await judge.source_masks(rep["catalog"], loader=loader)
assert not any(masked.values()), f"金样的数据源不该设遮罩:{masked}"
assert excerpts(doc, name, loader=loader, masked=masked) == rep["outputs"]["excerpts"]
assert tripwire == []
# ==========================================================================
# 带上标记之后:非上传源一字不差;上传源只在约定的位置多出约定的东西
# ==========================================================================
async def test_marked_manual_document_is_unchanged(monkeypatch):
"""手工源(查询快照没有 data_version)的文档加上标记:①②③仍逐字等于金样(1.4「非上传源」,3.3)。"""
name = "manual"
doc = marked_doc(name)
out = report(name)["outputs"]
assert verified(doc, name) == out["verify_doc"]
assert excerpts(doc, name) == out["excerpts"]
stub_masks(monkeypatch, name)
assert await chains(doc, name) == out["chain"]
@pytest.mark.parametrize("name", UPLOADS)
def test_marked_upload_documents_still_verify_the_same(name):
"""标记写在文档顶层、参与内容哈希,但 verify_doc 不看它:ok、stats、violations 与金样逐字相同,没有 state_mismatch。"""
got = verified(marked_doc(name), name)
assert got == report(name)["outputs"]["verify_doc"] and state_mismatches(got) == []
@pytest.mark.parametrize("name", UPLOADS)
async def test_marked_upload_query_steps_only_gain_the_hint(name, monkeypatch):
"""③ 上传表格的单元格片段:查询步骤只多一个 `provenance: true`,其余每个键逐字等于金样(2.8.2)。"""
stub_masks(monkeypatch, name)
got = await chains(marked_doc(name), name)
want = report(name)["outputs"]["chain"]
assert set(got) == set(want)
for sid, steps in got.items():
assert len(steps) == len(want[sid]) == 1, sid
for step, old in zip(steps, want[sid]):
assert set(step) - set(old) == {"provenance"} and set(old) <= set(step), (sid, set(step) ^ set(old))
assert step["provenance"] is True
assert {k: v for k, v in step.items() if k != "provenance"} == old, sid
@pytest.mark.parametrize("name", UPLOADS)
def test_marked_upload_excerpts_keep_the_golden_text_around_the_new_lines(name):
"""② 上传表格加上标记后,摘录里新增的东西只许插在查询摘录「SQL:」那一行之后、「列:」那一行之前:去掉这段之后
逐字等于金样;表名实体等其余摘录逐字等于金样(3.2 的位置约定)。新行一律以 3.2 的几种行首开头。"""
got = excerpts(marked_doc(name), name)
want = report(name)["outputs"]["excerpts"]
assert set(got) == set(want)
for alias, text in got.items():
if not alias.startswith("Q"):
assert text == want[alias], alias
continue
kept, new = split_new_lines(text)
golden_kept, golden_new = split_new_lines(want[alias])
assert golden_new == [], "金样的查询摘录在 SQL 与「列:」之间不该有别的行"
assert kept == golden_kept, alias
assert all(line.startswith(NEW_LINE_HEADS) for line in new), (alias, new)
def test_marked_recipe_excerpts_gain_the_provenance_lines():
"""② 配方源的文档加上标记:两条查询摘录都多出 3.2 的来历行,内容就是 judge_lines 对同样输入给出的行,原样插在
「SQL:」与「列:」之间(3.3)。断言几样确定的内容:
- 来源行:上传的表格、每期替换、第 1 次导入、统计期取自 客流汇总!B2、文件名与 sha256 前 12 位、区域(S9);
- 口径:SQL 用到的表的说明(冻结表结构的 comment 原文)、查询结果列的说明;
- 区域外文字:金样的清单是期 4 之前导入的,OutsideText 没有 hidden 字段,只计条数、不送全文(调整 10);
- 没有接受理由(D00 没有接受)。"""
name = "recipe"
got = excerpts(marked_doc(name), name)
rep = report(name)
query = artifact_store.load(rep["catalog"]["Q1"]["artifact"])
schema = artifact_store.load(query["schema_artifact"])
manifest = artifact_store.load(schema["import_manifests"][0])
for alias, table in (("Q1", "日客流"), ("Q2", "时段客流")):
_, new = split_new_lines(got[alias])
assert new, f"{alias} 的摘录没有来历行"
assert new == expected_judge_lines(name, alias), alias
source = new[0]
assert source.startswith("来源:上传的表格,每期替换,第 1 次导入:统计期 2026-08-01 至 2026-08-31(取自 客流汇总!B2)")
assert f"文件「{manifest['file']['name']}」(sha256 {manifest['file']['raw_sha256'][:12]})" in source
assert "区域 客流汇总!" in source
comment = schema["tables"][table]["comment"]
assert f"口径:表 {table} 的说明:{comment}" in new
assert not any(line.startswith("已接受") for line in new)
assert "另有 1 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏),见证据面板" in new
assert not any("客流汇总表" in line for line in new), "隐藏与否未记录的区域外文字不该送全文"
_, new = split_new_lines(got["Q1"])
for col in ("日期", "全日客流", "分区甲"):
note = next(c["comment"] for c in schema["tables"]["日客流"]["columns"] if c["name"] == col)
assert f"口径:列 {col} 的说明:{note}" in new
assert len("\n".join(new)) <= 1200
def test_marked_simple_upload_excerpt_gains_only_the_caliber_line():
"""② 简单导入的文档加上标记:只多口径行(调整 6:未规整的表写 UNSHAPED_NOTE,裁判才知道不能直接对列求和);
没有导入清单,来源、已接受、区域外文字三类都不出。"""
name = "simple"
got = excerpts(marked_doc(name), name)
_, new = split_new_lines(got["Q1"])
assert new == expected_judge_lines(name, "Q1")
assert f"口径:表 月报 的说明:{UNSHAPED_NOTE}" in new
assert not any(line.startswith(("来源:", "已接受", "区域外")) for line in new), new