Files
AgentLab/backend/tests/test_judge_provenance.py
T
ThearchyHelios e4f8ee5d08 Excel 导入改造期 4:证据下钻——报告里引用上传表格的数,点开能看到它来自原表格的哪一格;裁判核对时看得到来源、口径、接受理由和区域外文字
推断的来源(新 engine/direct_select、data/provenance、data/provenance_db;api/evidence 新增推断来源接口)
- 只对直接引用一列的查询下钻:受限文法用和执行时相同的 SQLite 分词读法,别名互换、自连接、表达式、聚合、子查询、集合运算(含藏在注释、字符串、方括号、转义、BOM 之后的)一律只给表级来历;再用 EXPLAIN 字节码和授权回调兜底
- 结果带齐主键、值非空,按主键参数化回查只读快照、类型和值严格一致才给格子;不一致时整份重算库哈希,哈希不符标红
- 从导入清单和溯源找回格子:宽表、交叉表逆透视的长表(日期取自表头格、年份取自统计期、行标签、分段标题)、列表、合计表、按期累积的并集(换算到所属那一期);原文与规范写法不同时一并列出
- 清单读不到、登记哈希与清单不一致、快照已回收、遮罩列,都只给表级来历或不给,写明原因;原件已清除、接受已作废照实显示;未封存的运行照常推断并标明
- 相关核对:这一行、这一期的核对结论与接受理由

裁判摘录(engine/judge)
- 带期 4 标记的报告,上传表格的查询旁加来源行、口径行、接受理由、可见单元格里的区域外文字和人工录入的统计期;限定语写明这些数未经系统核对,长度有上限、截断时写明
- 隐藏行列里的区域外文字不送;设了遮罩的源只写条数

前端证据面板(run/EvidenceProvenance;ManifestBody 从版本页抽出复用)
- 「推断的来源」一节与已封存的证据分开标注:格子坐标、取自哪些格、年份来源、规范写法、回查、相关核对、数据版本与各期;标红与拒绝原因按状态显示

不变的部分
- 文档顶层加 provenance 标记,只有新报告走新路径;期 4 之前的运行、非上传源、手工源的面板、复核、裁判摘录逐字不变,由金样测试钉住
- 执行器给区域外文字记录是否在隐藏行列;不建「导入校验」系统表(D6)
2026-10-02 14:38:10 +08:00

285 lines
14 KiB
Python
Raw 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 来历行(P4-SPEC 3.2–3.4):按文档标记分版本、只对上传表格。
- 期 4 之前的文档(没有标记):摘录逐字等于金样(tests/fixtures/evidence/pre_p4_golden.json),provenance、
direct_select 两个模块一次都不碰(用桩钉住:桩一被访问就抛错);
- 带标记的文档:手工源的摘录不变;上传源的查询摘录只在 SQL 那一行之后、「列:」之前多出 judge_lines 的行,
去掉这些行就等于金样;同样的输入两次得到同样的字;
- 按需裁判的 loader:带标记的文档才取得到哈希链上的清单,老文档连坏掉的清单都碰不到(A26);
- 真导入的 D28(表下有含数字的说明):区域外文字带限定语、带统计期;遮罩时不送;单句的点击估价在上限以内。
金样的工件逐个 put_json 进临时数据目录,先断言返回的 id 等于金样记下的 id(内容寻址,对不上说明金样被改过)。
夹具全部合成、假名;不调用任何模型(只比摘录文字和估价)。
"""
from __future__ import annotations
import copy
import json
import sys
import types
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
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 prov
from app.data.provenance_types import DOC_PROVENANCE, PROV_OUTSIDE_CHARS
from app.data.tabular import UNSHAPED_NOTE
from app.engine import judge
from app.engine.direct_select import tables_in
from app.main import app
from tests.test_provenance_api import import_flow, run_sql, sealed_of
GOLDEN = Path(__file__).parent / "fixtures" / "evidence" / "pre_p4_golden.json"
#: 3.2 区域外文字的小标题(限定语每块只写一次)
OUTSIDE_HEAD = ("区域外文字(原表区域外的文字原文,未经系统核对;其中的数字不是查询结果,不能用来支持或否定报告中的"
"数字):")
# --------------------------------------------------------------------------
# 夹具
# --------------------------------------------------------------------------
@pytest.fixture
async def golden(monkeypatch, tmp_path) -> dict:
"""金样的输入工件写进一个临时数据目录(id 必须等于金样记下的),返回金样本身。"""
data = tmp_path / "data"
(data / "uploads").mkdir(parents=True)
monkeypatch.setattr(settings, "data_dir", data)
g = json.loads(GOLDEN.read_text("utf-8"))
for aid, content in g["artifacts"].items():
assert await artifact_store.put_json(content, kind="golden") == aid
return g
class _Forbidden(types.ModuleType):
"""桩模块:任何属性一被访问就记下来并抛错。期 4 之前的文档、手工源的摘录不该碰 provenance、direct_select。"""
def __init__(self, name: str, touched: list[str]) -> None:
super().__init__(name)
self._touched = touched
def __getattr__(self, attr: str):
if attr.startswith("__"):
raise AttributeError(attr)
self._touched.append(f"{self.__name__}.{attr}")
raise AssertionError(f"不该用到 {self.__name__}.{attr}")
@pytest.fixture
def forbidden(monkeypatch) -> list[str]:
touched: list[str] = []
for name in ("app.data.provenance", "app.engine.direct_select"):
monkeypatch.setitem(sys.modules, name, _Forbidden(name, touched))
return touched
def doc_of(g: dict, kind: str, *, marked: bool) -> tuple[dict, dict]:
report = g["reports"][kind]
doc = copy.deepcopy(report["outputs"]["doc"])
assert "provenance" not in doc
if marked:
doc["provenance"] = DOC_PROVENANCE
return doc, copy.deepcopy(report["catalog"])
def excerpts(doc: dict, catalog: dict, **kw) -> dict[str, str]:
return judge.prepare(doc, catalog, loader=kw.pop("loader", artifact_store.load), **kw).excerpts
def inserted(new: str, old: str) -> list[str]:
"""新摘录比金样多出的行:只许插在第 2 行(SQL)之后、「列:」之前,其余逐字相同。"""
a, b = new.split("\n"), old.split("\n")
extra = len(a) - len(b)
assert extra >= 0 and a[:2] == b[:2] and a[2 + extra:] == b[2:], (new, old)
assert b[2].startswith("列:")
return a[2:2 + extra]
# --------------------------------------------------------------------------
# 按文档标记分版本(3.3)
# --------------------------------------------------------------------------
@pytest.mark.parametrize("kind", ["recipe", "simple", "manual"])
async def test_unmarked_documents_are_unchanged_and_never_touch_the_new_modules(golden, forbidden, kind):
doc, catalog = doc_of(golden, kind, marked=False)
assert excerpts(doc, catalog) == golden["reports"][kind]["outputs"]["excerpts"]
assert forbidden == []
async def test_marked_manual_source_is_unchanged(golden, forbidden):
"""非上传源(查询快照没有 data_version):带标记也一字不差,两个模块照样不碰。"""
doc, catalog = doc_of(golden, "manual", marked=True)
assert excerpts(doc, catalog) == golden["reports"]["manual"]["outputs"]["excerpts"]
assert forbidden == []
async def test_marked_recipe_source_adds_lines_only_between_sql_and_columns(golden):
doc, catalog = doc_of(golden, "recipe", marked=True)
new, old = excerpts(doc, catalog), golden["reports"]["recipe"]["outputs"]["excerpts"]
assert set(new) == set(old)
# 表名实体的摘录不加来历(只改查询摘录)
assert new["t:日客流"] == old["t:日客流"]
for alias, table in (("Q1", "日客流"), ("Q2", "时段客流")):
lines = inserted(new[alias], old[alias])
snap = artifact_store.load(catalog[alias]["artifact"])
schema = artifact_store.load(snap["schema_artifact"])
tables = {t: [c["name"] for c in meta["columns"]] for t, meta in schema["tables"].items()}
assert tables_in(snap["sql"], tables) == [table]
assert lines == prov.judge_lines(snap, schema, artifact_store.load, tables=[table], hidden=set())
assert lines[0].startswith("来源:上传的表格,每期替换,第 1 次导入:统计期 2026-08-01 至 2026-08-31")
assert f"口径:表 {table} 的说明:" in "\n".join(lines)
async def test_marked_simple_upload_adds_only_caliber_lines(golden):
"""简单导入(没有导入清单)只多口径行(调整 6):未规整的表写 UNSHAPED_NOTE;没有来源、接受、区域外文字。"""
doc, catalog = doc_of(golden, "simple", marked=True)
new, old = excerpts(doc, catalog), golden["reports"]["simple"]["outputs"]["excerpts"]
lines = inserted(new["Q1"], old["Q1"])
assert lines and all(line.startswith("口径:") for line in lines)
assert f"口径:表 月报 的说明:{UNSHAPED_NOTE}" in lines
assert new["t:月报"] == old["t:月报"]
async def test_excerpts_are_deterministic(golden):
"""摘录进断点指纹和逐句复用键:同样的输入两次得到同样的字(不取当前时间、不取数据库列)。"""
doc, catalog = doc_of(golden, "recipe", marked=True)
assert excerpts(doc, catalog) == excerpts(copy.deepcopy(doc), copy.deepcopy(catalog))
async def test_masks_and_tables_are_handed_to_judge_lines(golden, monkeypatch):
"""hidden 就是查询摘录现成的那份遮罩(数据源现在设的 ∪ 快照记下的,小写);tables 由 tables_in 按冻结表结构算。"""
calls: list[dict] = []
real = prov.judge_lines
def spy(query, schema, load, *, tables, hidden):
calls.append({"sql": query["sql"], "tables": tables, "hidden": set(hidden), "schema": schema is not None})
return real(query, schema, load, tables=tables, hidden=hidden)
monkeypatch.setattr(prov, "judge_lines", spy)
doc, catalog = doc_of(golden, "recipe", marked=True)
out = excerpts(doc, catalog, masked={"flow_demo": ["分区甲"], "别的源": ["日期"]})
assert [(c["tables"], c["hidden"], c["schema"]) for c in calls] == [
(["日客流"], {"分区甲"}, True), (["时段客流"], {"分区甲"}, True)]
# 遮罩的列:结果里不给,列的说明也不写
assert "口径:列 分区甲 的说明" not in out["Q1"] and "分区甲" not in out["Q1"].split("\n")[-1]
async def test_a_broken_artifact_degrades_to_one_line(golden, monkeypatch):
"""生成来历行时出了意外(工件形状不对):这条查询只写「导入清单无法读取…」一行,整份报告照样能判。"""
def boom(*_a, **_kw):
raise TypeError("坏形状")
monkeypatch.setattr(prov, "judge_lines", boom)
doc, catalog = doc_of(golden, "recipe", marked=True)
new, old = excerpts(doc, catalog), golden["reports"]["recipe"]["outputs"]["excerpts"]
assert inserted(new["Q1"], old["Q1"]) == [prov.SOURCE_BROKEN]
# --------------------------------------------------------------------------
# 按需裁判的取证范围(3.4、2.8.3)
# --------------------------------------------------------------------------
def golden_sealed(g: dict, kind: str) -> api._Sealed:
info = g["reports"][kind]["sealed"]
sealed = sealed_of(queries=list(info["queries"]), schemas=list(info["schemas"]))
sealed.queries.update(info["queries"])
return sealed
async def test_on_demand_loader_reaches_the_chain_only_for_marked_docs(golden):
doc, catalog = doc_of(golden, "recipe", marked=True)
sealed = golden_sealed(golden, "recipe")
old = golden["reports"]["recipe"]["outputs"]["excerpts"]
# 带标记:loader 扩展到表结构快照经哈希链列出的导入清单,来源行照常写
chained = excerpts(doc, catalog, loader=api._sealed_loader(sealed, chain=True))
assert inserted(chained["Q1"], old["Q1"])[0].startswith("来源:上传的表格,每期替换")
assert chained == excerpts(doc, catalog)
# 不扩展的 loader 取不到清单:只写「导入清单无法读取…」一行,口径照写(来自封存范围内的表结构快照)
plain = excerpts(doc, catalog, loader=api._sealed_loader(sealed))
lines = inserted(plain["Q1"], old["Q1"])
assert lines[0] == prov.SOURCE_BROKEN and any(line.startswith("口径:表 日客流") for line in lines)
async def test_pre_p4_on_demand_judging_never_reads_a_broken_manifest(golden, forbidden):
"""A26:老文档(chain 为假)+ 表结构快照列出的导入清单文件被改坏:摘录与金样逐字相同,根本不去取清单。"""
report = golden["reports"]["recipe"]
snap = artifact_store.load(report["catalog"]["Q1"]["artifact"])
[mid] = artifact_store.load(snap["schema_artifact"])["import_manifests"]
path = settings.data_dir / "artifacts" / mid[:2] / f"{mid}.json"
path.write_text(path.read_text("utf-8").replace('"seq":1', '"seq":9', 1), encoding="utf-8")
with pytest.raises(ValueError):
artifact_store.load(mid)
doc, catalog = doc_of(golden, "recipe", marked=False)
sealed = golden_sealed(golden, "recipe")
loader = api._sealed_loader(sealed, chain=doc.get("provenance") == DOC_PROVENANCE)
assert excerpts(doc, catalog, loader=loader) == report["outputs"]["excerpts"]
assert forbidden == []
# --------------------------------------------------------------------------
# 真导入:区域外文字、遮罩、估价(3.2)
# --------------------------------------------------------------------------
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
def no_model(monkeypatch, tmp_path):
from app.data import recipe_ai
from app.data.recipe_types import AiAvailability
data = tmp_path / "data"
(data / "uploads").mkdir(parents=True)
monkeypatch.setattr(settings, "data_dir", data)
async def none(session):
return None, "", AiAvailability(False, reason="测试不接模型")
monkeypatch.setattr(recipe_ai, "resolve_draft_model", none)
async def test_outside_text_masks_and_cost_on_a_real_import(no_model, client):
"""D28(9 月,表下 B32 有一句含数字的说明,新导入的清单记了 hidden=False):摘录送全文、带限定语和统计期;
数据源设遮罩时一格都不送、只写条数;只判一句、引用这一条查询的点击估价不超过 0.01 美元。"""
from app.data.engine import engines
from tests.fixtures.xlsx.drift import SEP
up = await import_flow(client, seed=4201, start=SEP, days=30, variant="D28")
try:
qid, _sid = await run_sql(client, up, 'SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-09-15\'')
g = json.loads(GOLDEN.read_text("utf-8"))
doc = copy.deepcopy(g["reports"]["recipe"]["outputs"]["doc"])
doc["provenance"] = DOC_PROVENANCE
catalog = {"Q1": {**g["reports"]["recipe"]["catalog"]["Q1"], "artifact": qid, "source": up.name,
"tool": f"db_query__{up.name}"}}
request = judge.prepare(doc, catalog)
text = request.excerpts["Q1"]
lines = text.split("\n")
assert lines[2].startswith("来源:上传的表格,每期替换,第 1 次导入:统计期 2026-09-01 至 2026-09-30")
assert OUTSIDE_HEAD in lines
note = next(line for line in lines if "B32:" in line)
assert note.startswith(" 第 1 次导入(统计期 2026-09-01 至 2026-09-30)客流汇总!B32:「") and "9月15日" in note
assert len(note) < PROV_OUTSIDE_CHARS + 60
assert judge.prepare(doc, catalog).excerpts == request.excerpts # 确定
# 遮罩:区域外文字一格都不送,只写条数
masked = judge.prepare(doc, catalog, masked={up.name: ["分区甲"]}).excerpts["Q1"]
assert "9月15日" not in masked and OUTSIDE_HEAD not in masked
assert "(数据源设置了遮罩,未列出)" in masked
# 估价:只判一句、引用这一条查询(claude-sonnet-5 的目录价)
one = [c for c in request.cands if "Q1" in c.aliases][:1]
assert one
cost = request.cost("claude-sonnet-5", one)
assert cost is not None and cost <= 0.01, cost
finally:
await engines.invalidate(up.source_id)