Files
AgentLab/backend/tests/test_provenance_acceptance.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

1738 lines
95 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 6.2 的 A1–A26、6.5 的性能,WP-E)。
真管线、真运行:从暂存合成工作簿开始,经接口回答问题、试运行、提交(导入),再用真 runner 跑「工具节点
db_query__<源> × N → 报告节点 → 输出」的图(写作模型是剧本化的 MockChatModel,不调用任何真实模型),运行跑完自动
封存;之后一律经 AsyncClient(transport=ASGITransport(app=app)) 请求片段接口和推断来源接口
`GET /api/runs/{id}/evidence/segments/{seg}/provenance`,不直接调内部函数拼结果。只有接口不提供的观察点
(快照记录、导入记录、内容寻址的工件、数据文件、原件字节)才直接读库、看文件;造「数据文件被改过」「登记哈希被改过」
「封存范围内的事件被改过」这类状态时才直接改库、改文件(6.1 的篡改)。
每份响应都按契约(provenance_types)逐层核对键集合、还原成 ProvenanceOut,再断言 `contract_problems(out) == []`
(WP-0 交接:漏了映射的细分、回查参数对不上都靠这一条暴露)。
**夹具组织**:不改状态的用例共用一个模块级的「世界」:十二个合成的源(每期替换、按期累积、D26 写理由接受(替换与
累积各一)、人工录入统计期、设遮罩(宽表、列表各一)、列表、简单导入、手工源、D28 区域外文字及其隐藏行变体)和四次运行(主运行一次
引用全部用例的格子;期 4 之前形状的文档一次(A21 用);停在审批上的一次;留给 A23 篡改事件的一次)。世界在单独的
事件循环里建好,建完关掉数据层和数据库的连接,用例各自的事件循环里重新连。改数据文件、改登记哈希、清除原件、作废
接受、改坏清单的用例(A5、A5b、A9、A14、A25、A26),以及运行期间要装桩的 A6,各建自己的源和运行,互不影响。
夹具全部合成、假名(tests/fixtures/xlsx:分区甲、分区乙……数字随机)。断言照规格写,不放宽、不标 xfail。
"""
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import os
import shutil
import sqlite3
import time
import uuid
from contextlib import closing
from dataclasses import dataclass, field, fields, replace
from pathlib import Path
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from openpyxl import load_workbook
from sqlalchemy import select
from app.core import artifact_store
from app.core.config import settings
from app.data.engine import engines
from app.data.provenance_types import (
REASON_TEXT,
AcceptanceView,
Alert,
CanonicalView,
CellRef,
CellSource,
CompileFacts,
FromCell,
PartView,
PeriodView,
ProvenanceOut,
Reason,
Recheck,
RelatedCheck,
ReportRef,
StateNote,
TableRef,
VersionView,
YearSource,
contract_problems,
refuse,
)
from app.db.base import SessionLocal
from app.db.base import engine as db_engine
from app.db.models import DataSource, Run, RunEvent, SourceSnapshot, TableImport
from app.engine.evidence import iter_segments, iter_units, verify_doc
from app.engine.runner import run_manager
from app.main import app
from tests.fixtures.xlsx import excel_saved, lab, save
from tests.fixtures.xlsx.drift import AUG, SEP
from tests.fixtures.xlsx.flow import DAY_TITLE, SHEET, flow_workbook
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
API = "/api/datasources"
TABLES = ("日客流", "时段客流", "时段客流_表内合计")
REASON_D26 = "合成理由:分区合计口径调整"
SIGNER = "录入员甲"
#: 句子里的标签:不含数字(报告里的裸数字会被标成无出处),一句一个,按片段的引用认片段、不按文字
LABELS = tuple(a + b for a, b in zip("甲乙丙丁戊己庚辛壬癸" * 6, "子丑寅卯辰巳午未申酉戌亥" * 5))
#: D28 夹具在区域外第 32 行写的注释(9 月那一期)
D28_NOTE = "注:9月15日闸机故障,当日客流为估算值"
OUTSIDE_HEAD = "区域外文字(原表区域外的文字原文,未经系统核对;其中的数字不是查询结果,不能用来支持或否定报告中的数字):"
ACCEPT_HEAD = "已接受(用户填写的理由,未经系统核对):"
#: 6.5:超过目标的这个倍数算不通过
FAIL_FACTOR = 1.5
def wide_sql(day: str, column: str = "全日客流") -> str:
return f'SELECT "日期", "{column}" FROM "日客流" WHERE "日期" = \'{day}\''
def long_sql(day: str, hour: str) -> str:
return f'SELECT "日期", "时段", "客流" FROM "时段客流" WHERE "日期" = \'{day}\' AND "时段" = \'{hour}\''
#: 实测 B7 的两种写法和 WP-A 补充的 U+FEFF 写法:藏在后面的 UNION ALL 把分区甲的值装进名为「全日客流」的结果列。
#: 第一段一行都选不出来(只有 8 月的数),结果的 r0 实际来自分区甲;夹具让 2026-08-03 的全日客流恰好等于分区甲,
#: 所以只比值的回查分不出来,只能靠 R7 认出集合运算
A24_SQL = {
"escape": ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE \'2026-09%\' ESCAPE \'\\\' '
'UNION ALL SELECT "日期", "分区甲" FROM "日客流" WHERE "日期" = \'2026-08-03\''),
"bracket": ('SELECT "日期", "全日客流" FROM "日客流" AS [t\'] WHERE "日期" = \'2026-09-01\' '
'UNION ALL SELECT "日期", "分区甲" FROM "日客流" AS [u\'] WHERE "日期" = \'2026-08-03\''),
"bom": ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-09-01\' \ufeffUNION ALL '
'\ufeffSELECT "日期", "分区甲" \ufeffFROM "日客流" WHERE "日期" = \'2026-08-03\''),
}
# ==========================================================================
# 小工具(经 HTTP 导入)
# ==========================================================================
def unique(prefix: str = "p4e") -> str:
return f"{prefix}_{uuid.uuid4().hex[:10]}"
def answers_for(questions: list[dict[str, Any]], mode: str) -> dict[str, dict[str, Any]]:
"""系统发现一律「登记」,q_mode 按 mode,其余能存为空值的选「存为空值」(期 2、3 验收的写法)。"""
out = {}
for q in questions:
values = [o["value"] for o in q["options"]]
if q["id"].startswith("q_relation"):
out[q["id"]] = {"value": "register"}
elif q["id"] == "q_mode":
out[q["id"]] = {"value": mode}
elif "null" in values:
out[q["id"]] = {"value": "null"}
else:
out[q["id"]] = {"value": values[0]}
return out
def rename_wide(rec: dict[str, Any], old: str, new: str) -> dict[str, Any]:
"""把规则起草的宽表名改成 new:表名、写入它的分段(id 一起改)、关系里引用的表名(期 2 验收 9.7 第 1 步)。"""
rec = json.loads(json.dumps(rec, ensure_ascii=False))
for sheet in rec["sheets"]:
for block in sheet["blocks"]:
for seg in block.get("segments", []):
if seg.get("table") == old:
seg["table"] = new
if seg.get("id") == old:
seg["id"] = new
for t in rec["tables"]:
if t["name"] == old:
t["name"] = new
for r in rec["relations"]:
if r.get("table") == old:
r["table"] = new
for side in ("a", "b"):
if isinstance(r.get(side), dict) and r[side].get("table") == old:
r[side]["table"] = new
return rec
def confirm_ids(st: dict[str, Any]) -> list[str]:
return [i["id"] for i in (st.get("trial") or {}).get("confirm_items") or []]
async def commit(c: AsyncClient, st: dict[str, Any], acceptances: list[dict[str, str]] | None = None) -> dict[str, Any]:
body: dict[str, Any] = {"trial_id": st["trial"]["trial_id"], "confirmations": confirm_ids(st)}
if acceptances is not None:
body["acceptances"] = acceptances
r = await c.post(f"{API}/imports/{st['id']}/commit", json=body)
assert r.status_code == 201, r.text
return r.json()
@dataclass
class Source:
"""一个合成的源:名字、id、当前版本、各期原件(字节、文件名)、各期导入记录 id(按导入先后)。"""
name: str
id: str
snapshot: str
raws: list[tuple[bytes, str]]
imports: list[str]
#: 每一次提交后的版本 id(按导入先后);替换模式下当前版本只含最后一期
snapshots: list[str] = field(default_factory=list)
async def first_import(c: AsyncClient, raw: bytes, fn: str, *, mode: str = "replace", name: str | None = None,
rename: bool = True) -> Source:
"""暂存 → 回答问题(q_mode=mode)→ 宽表改名「日客流」(flow 夹具)→ 试运行 → 勾全部确认项提交。"""
name = name or unique()
r = await c.post(f"{API}/imports/stage", files={"file": (fn, raw, XLSX)}, data={"name": name})
assert r.status_code == 201, r.text
st = r.json()
if st.get("questions"):
r = await c.post(f"{API}/imports/{st['id']}/answers", json={"answers": answers_for(st["questions"], mode)})
assert r.status_code == 200, r.text
st = r.json()
if rename:
wide = next(t["name"] for t in st["recipe"]["tables"] if t["name"] not in TABLES)
r = await c.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": rename_wide(st["recipe"], wide, "日客流")})
assert r.status_code == 200, r.text
st = r.json()
r = await c.post(f"{API}/imports/{st['id']}/trial", json={})
assert r.status_code == 200, r.text
st = r.json()
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
out = await commit(c, st)
return Source(name=name, id=out["source"]["id"], snapshot=out["snapshot_id"], raws=[(raw, fn)],
imports=[out["import_id"]], snapshots=[out["snapshot_id"]])
async def add_period(c: AsyncClient, src: Source, raw: bytes, fn: str, *,
acceptances: list[dict[str, str]] | None = None, human: tuple[str, str] | None = None) -> None:
"""上传新一期 → 试运行(human=(起, 止) 时按人工录入的统计期、署名「录入员甲」重新试运行)→ 提交。"""
r = await c.post(f"{API}/{src.id}/reupload", files={"file": (fn, raw, XLSX)})
assert r.status_code == 201, r.text
st = r.json()
if human is not None:
r = await c.post(f"{API}/imports/{st['id']}/trial", json={
"context_inputs": {"统计期": {"start": human[0], "end": human[1]}}, "signed_by": SIGNER})
assert r.status_code == 200, r.text
st = r.json()
assert st["trial"]["status"] in ("passed", "needs_decision"), st["trial"]["problems"]
out = await commit(c, st, acceptances)
src.snapshot = out["snapshot_id"]
src.snapshots.append(out["snapshot_id"])
src.raws.append((raw, fn))
src.imports.append(out["import_id"])
async def simple_upload(c: AsyncClient, raw: bytes, fn: str, name: str) -> Source:
"""期 1 的简单导入(/upload):没有导入清单,表结构快照没有 import_mode。"""
r = await c.post(f"{API}/upload", files={"file": (fn, raw, XLSX)}, data={"name": name})
assert r.status_code == 201, r.text
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one()
return Source(name=name, id=row.id, snapshot=row.current_snapshot_id or "", raws=[(raw, fn)], imports=[])
async def manual_source(name: str, path: Path) -> Source:
"""手工登记的 SQLite 源:查询快照没有 data_version。"""
db = sqlite3.connect(path)
db.executescript('CREATE TABLE "订单汇总" ("地区" TEXT PRIMARY KEY, "订单数" INTEGER, "金额" REAL);')
db.executemany('INSERT INTO "订单汇总" VALUES (?, ?, ?)',
[("分区甲", 523, 40211.5), ("分区乙", 466, 35982.0), ("分区丙", 301, 22870.75)])
db.commit()
db.close()
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={})
session.add(row)
await session.commit()
return Source(name=name, id=row.id, snapshot="", raws=[], imports=[])
async def set_masks(source_id: str, masks: str | None) -> None:
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
options = dict(row.options or {})
if masks:
options["mask_columns"] = masks
else:
options.pop("mask_columns", None)
row.options = options
await session.commit()
def list_book(sheet: str, header: tuple[str, ...], rows: list[tuple[Any, ...]]) -> bytes:
"""合成的简单列表 xlsx:第 1 行表头,之后逐行。"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = sheet
ws.append(list(header))
for row in rows:
ws.append(list(row))
return save(wb)
def rework(raw: bytes, edit: Any, sheet: str = SHEET) -> bytes:
"""在合成夹具上改几处(edit(工作表)),其余照旧。openpyxl 存盘会丢掉公式的缓存值:先按原文件读出缓存值,存盘后
用 excel_saved 填回去,表内合计的公式照样有缓存,核对不受影响(期 3 验收 edit_cells 的写法)。"""
book = load_workbook(io.BytesIO(raw))
values = load_workbook(io.BytesIO(raw), data_only=True)
ws, wv = book[sheet], values[sheet]
cached = {c.coordinate: wv[c.coordinate].value for row in ws.iter_rows() for c in row
if isinstance(c.value, str) and c.value.startswith("=")}
edit(ws)
return excel_saved(save(book), sheet, cached)
def xl(raw: bytes, ref: str, sheet: str = SHEET) -> Any:
"""原件里一格的保存值(公式取缓存值)。只在测试里读、作对照,产品代码不读原件。"""
return load_workbook(io.BytesIO(raw), data_only=True)[sheet][ref].value
def sha256(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
# ==========================================================================
# 运行
# ==========================================================================
@dataclass
class Cite:
"""报告里的一处单元格引用:key 认片段;node 相同的几处共用一个工具节点(同一条查询)。"""
key: str
source: str
sql: str
column: str
row: int = 0
node: str | None = None
@dataclass
class RunInfo:
id: str
doc_artifact: str
doc: dict[str, Any]
#: 用例 key → 片段
segs: dict[str, dict[str, Any]]
#: 用例 key → 所在的句子 id
units: dict[str, str]
#: 用例 key → 目录里的查询编号
aliases: dict[str, str]
def node(nid: str, ntype: str, **config: Any) -> dict[str, Any]:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes: dict[str, Any]) -> dict[str, Any]:
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def _wait(run_id: str, statuses: tuple[str, ...]) -> Run:
for _ in range(1200):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
async def run_report(sources: dict[str, Source], cites: list[Cite], *, gate: bool = False,
legacy: bool = False) -> RunInfo:
"""跑一次「工具节点 × N → 报告节点 →(审批)→ 输出」。每处引用一句话,句子不含数字;查询按工具节点的先后编号。
legacy=True:报告节点的 compose_doc 换成去掉 provenance 键的包装(6.1「期 4 之前的文档」),存下的就是一份真正的
老形状文档。gate=True:报告之后接一个人工审批节点,运行停在审批上(未封存)。"""
from app.engine.nodes import report as report_node
from app.providers import mock_model
tools: list[str] = []
alias_of: dict[str, str] = {}
nodes = [node("start", "input")]
for cite in cites:
nid = f"q_{cite.node or cite.key}"
if nid not in tools:
tools.append(nid)
nodes.append(node(nid, "tool", tool=f"db_query__{sources[cite.source].name}", args={"sql": cite.sql}))
alias_of[cite.key] = f"Q{tools.index(nid) + 1}"
assert len(cites) <= len(LABELS)
lines = ["## 推断来源验收", ""]
for label, cite in zip(LABELS, cites):
lines += [f"{label}的值为 [[v:{alias_of[cite.key]}.r{cite.row}.{cite.column}]]。", ""]
text = "\n".join(lines)
nodes.append(node("write", "report", instructions="写推断来源验收报告", on_violation="flag"))
if gate:
nodes.append(node("gate", "human", mode="approve", title="确认"))
nodes.append(node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.write.text }}"}]))
real_decide, real_compose = mock_model.MockChatModel._decide, report_node.compose_doc
def legacy_compose(*a: Any, **kw: Any) -> dict[str, Any]:
doc = real_compose(*a, **kw)
doc.pop("provenance", None)
return doc
mock_model.MockChatModel._decide = lambda self, messages: AIMessage(content=text)
if legacy:
report_node.compose_doc = legacy_compose
try:
run = await run_manager.start(graph=chain(*nodes), input_payload={})
row = await _wait(run.id, ("interrupted",) if gate else ("succeeded", "failed"))
finally:
mock_model.MockChatModel._decide = real_decide
report_node.compose_doc = real_compose
if gate:
assert row.status == "interrupted", row.error
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
doc_id = (await c.get(f"/api/runs/{row.id}/evidence")).json()["reports"][0]["doc_artifact"]
else:
assert row.status == "succeeded", row.error
doc_id = row.output["_evidence"]["doc_artifact"]
doc = artifact_store.load(doc_id)
assert ("provenance" in doc) is not legacy
catalog = doc["catalog"]
segs: dict[str, dict[str, Any]] = {}
units: dict[str, str] = {}
unit_of = {s["id"]: u["id"] for _, u in iter_units(doc) for s in u.get("segments") or []}
cells = [s for s in iter_segments(doc) if (s.get("cite") or {}).get("kind") == "cell"]
for cite in cites:
alias = alias_of[cite.key]
assert catalog[alias]["node_id"] == f"q_{cite.node or cite.key}", (cite.key, catalog[alias])
hit = [s for s in cells if s["cite"].get("alias") == alias
and (s["cite"].get("locator") or {}).get("row") == cite.row
and (s["cite"].get("locator") or {}).get("column") == cite.column]
assert len(hit) == 1, (cite.key, hit)
segs[cite.key] = hit[0]
units[cite.key] = unit_of[hit[0]["id"]]
return RunInfo(id=row.id, doc_artifact=doc_id, doc=doc, segs=segs, units=units, aliases=alias_of)
# ==========================================================================
# 接口与契约
# ==========================================================================
def _dc(cls: type, d: Any, **nested: Any) -> Any:
"""JSON 的一层 → dataclass:键集合与顺序必须和契约的字段逐个相同(多一个、少一个、改名都报)。"""
assert isinstance(d, dict), (cls.__name__, d)
names = [f.name for f in fields(cls)]
assert list(d) == names, f"{cls.__name__} 的键与契约不符:{list(d)} != {names}"
return cls(**{**d, **nested})
def _opt(cls: type, d: Any, **nested: Any) -> Any:
return None if d is None else _dc(cls, d, **nested)
def _accept(d: Any) -> AcceptanceView | None:
return _opt(AcceptanceView, d)
def from_json(body: dict[str, Any]) -> ProvenanceOut:
"""推断来源接口的响应 → ProvenanceOut(逐层核对键集合),给 contract_problems 用。"""
v = body.get("version")
version = None if v is None else _dc(
VersionView, v, tables=[_dc(TableRef, t) for t in v["tables"]],
parts=[_dc(PartView, p, period=_opt(PeriodView, p["period"]),
acceptances=[_accept(a) for a in p["acceptances"]],
purged=_opt(StateNote, p["purged"]), revoked=_opt(StateNote, p["revoked"])) for p in v["parts"]])
cs = body.get("cell_source")
cell_source = None if cs is None else _dc(
CellSource, cs, **{"from": [_dc(FromCell, f) for f in cs["from"]]}, year=_opt(YearSource, cs["year"]),
canonical=_opt(CanonicalView, cs["canonical"]), recheck=_opt(Recheck, cs["recheck"]))
return _dc(ProvenanceOut, body, report=_dc(ReportRef, body["report"]), cell=_opt(CellRef, body["cell"]),
reason=_opt(Reason, body["reason"]), alert=_opt(Alert, body["alert"]), version=version,
cell_source=cell_source,
checks=[_dc(RelatedCheck, c, acceptance=_accept(c["acceptance"])) for c in body["checks"]])
async def provenance(c: AsyncClient, run: RunInfo, key: str, *, report: str | None = None) -> ProvenanceOut:
"""GET 推断来源:200、键集合与契约相同、contract_problems 为空、报告和片段对得上。"""
seg_id = run.segs[key]["id"]
url = f"/api/runs/{run.id}/evidence/segments/{seg_id}/provenance" + (f"?report={report}" if report else "")
r = await c.get(url)
assert r.status_code == 200, r.text
out = from_json(r.json())
assert contract_problems(out) == [], contract_problems(out)
assert out.segment == seg_id and out.report == ReportRef(node_id="write", doc_artifact=run.doc_artifact)
return out
async def query_step(c: AsyncClient, run: RunInfo, key: str) -> dict[str, Any]:
"""片段接口里这一格的查询步骤(单元格片段的出处链只有这一步)。"""
r = await c.get(f"/api/runs/{run.id}/evidence/segments/{run.segs[key]['id']}")
assert r.status_code == 200, r.text
[step] = r.json()["chain"]
assert step["step"] == "query"
return step
def result_value(run: RunInfo, key: str) -> Any:
"""查询快照里被引用的那一格(内容寻址的工件,观察点)。"""
seg = run.segs[key]
snap = artifact_store.load(run.doc["catalog"][seg["cite"]["alias"]]["artifact"])
loc = seg["cite"]["locator"]
return snap["rows"][loc["row"]][snap["columns"].index(loc["column"])]
def query_snapshot(run: RunInfo, key: str) -> dict[str, Any]:
return artifact_store.load(run.doc["catalog"][run.aliases[key]]["artifact"])
def cell_ref(run: RunInfo, key: str) -> CellRef:
seg = run.segs[key]
loc = seg["cite"]["locator"]
return CellRef(alias=seg["cite"]["alias"], row=loc["row"], column=loc["column"],
artifact=run.doc["catalog"][seg["cite"]["alias"]]["artifact"])
def froms(out: ProvenanceOut) -> list[tuple[str, str | None, str, str, str | None, str | None]]:
return [(f.role, f.column, f.sheet, f.cell, f.text, f.locate_title) for f in out.cell_source.from_]
def check_of(out: ProvenanceOut, cid: str) -> RelatedCheck:
hit = [x for x in out.checks if x.id == cid]
assert len(hit) == 1, (cid, [x.id for x in out.checks])
return hit[0]
def assert_table_only(out: ProvenanceOut, code: str, *, detail: str = "", alert: bool = False,
text: str | None = None) -> None:
"""只给表级来历:原因、细分、原文;标红的同时给 alert;不给格子,相关核对为空,数据版本照给(2.3)。"""
assert out.status == "table_only", (out.status, out.reason)
assert out.reason is not None and (out.reason.code, out.reason.detail) == (code, detail), out.reason
assert out.reason.text == (text if text is not None else REASON_TEXT[code])
if alert:
assert out.alert == Alert(code=code, text=out.reason.text)
else:
assert out.alert is None
assert out.cell_source is None and out.checks == [] and out.version is not None
assert not any(p.has_row for p in out.version.parts)
async def import_row(import_id: str) -> TableImport:
async with SessionLocal() as session:
return await session.get(TableImport, import_id)
async def snapshot_row(snapshot_id: str) -> SourceSnapshot:
async with SessionLocal() as session:
return await session.get(SourceSnapshot, snapshot_id)
async def recipe_seq_of(recipe_id: str | None) -> int | None:
from app.db.models import TableRecipe
async with SessionLocal() as session:
rec = await session.get(TableRecipe, recipe_id) if recipe_id else None
return rec.seq if rec else None
async def manifest_id_of(import_id: str) -> str:
"""导入记录上的清单 id(观察点:只用来对照,产品代码按表结构快照的哈希链取,不经这个可改的列)。"""
async with SessionLocal() as session:
return (await session.get(TableImport, import_id)).manifest_artifact
async def expected_part(src: Source, index: int, *, has_row: bool, period: PeriodView | None, region: str | None,
raw_state: str = "kept", purged: StateNote | None = None, revoked: StateNote | None = None,
acceptances: list[AcceptanceView] | None = None) -> PartView:
"""数据版本里的一期应有的样子(2.8.1):文件名、原件哈希取自上传的字节;seq、配方哈希、提交时间、署名取自这一期的
导入清单(内容寻址的工件,观察点);配方第几版取自配方记录;原件状态、清除、作废是导入记录的当前状态。"""
raw, fn = src.raws[index]
mid = await manifest_id_of(src.imports[index])
m = artifact_store.load(mid)
return PartView(import_id=src.imports[index], seq=m["seq"], manifest=mid, period=period, file_name=fn,
raw_sha256=sha256(raw), region=region, excluded_rows=0, recipe_sha256=m["recipe"]["sha256"],
recipe_seq=await recipe_seq_of(m["recipe"]["id"]), committed_at=m["created_at"],
signed_by=(m.get("signed_by") or {}).get("name"), acceptances=acceptances or [],
raw_state=raw_state, purged=purged, revoked=revoked, has_row=has_row)
def manifest_acceptance(manifest_id: str, check_id: str) -> AcceptanceView:
"""清单里这条接受(内容寻址,是当时的事实)应有的 AcceptanceView。"""
m = artifact_store.load(manifest_id)
[a] = [a for a in m["acceptances"]["overrides"] if a["check_id"] == check_id]
[c] = [c for c in m["checks"] if c["id"] == check_id]
return AcceptanceView(check_id=check_id, title=c["title"], kind="override", reason=a["reason"],
signed_by=a.get("signed_by"), at=a.get("at"))
def august(source: str = "cells") -> PeriodView:
return PeriodView(start="2026-08-01", end="2026-08-31", source=source, cells=[f"{SHEET}!B2"], signed_by=None)
def september(source: str = "cells") -> PeriodView:
if source == "human":
return PeriodView(start="2026-09-01", end="2026-09-30", source="human", cells=[], signed_by=SIGNER)
return PeriodView(start="2026-09-01", end="2026-09-30", source="cells", cells=[f"{SHEET}!B2"], signed_by=None)
def axis(col: str, row: int = 4) -> tuple[str, str | None, str, str, str | None, str | None]:
return ("axis_header", "日期", SHEET, f"{col}{row}", None, None)
def label(ref: str, text: str | None, column: str | None, role: str = "row_label"
) -> tuple[str, str | None, str, str, str | None, str | None]:
return (role, column, SHEET, ref, text, None)
def title(ref: str, column: str = "时段类别") -> tuple[str, str | None, str, str, str | None, str | None]:
return ("section_title", column, SHEET, ref, None, DAY_TITLE)
# ==========================================================================
# 夹具:模块级的世界(不改状态的用例共用)
# ==========================================================================
@dataclass
class World:
base: Path
sources: dict[str, Source]
main: RunInfo
legacy: RunInfo
gate: RunInfo
broken: RunInfo
#: 主运行引用的格子(每处一句话)。flow 是 8 月 D00(每期替换),把 2026-08-03 的分区乙改成 0、全日客流改成等于
#: 分区甲(A24 的陷阱:两列的值恰好相等)
def _main_cites() -> list[Cite]:
cites = [
Cite("a4", "flow", wide_sql("2026-08-05"), "全日客流"),
Cite("a1", "flow", 'SELECT "日期", "分区甲" AS "分区乙" FROM "日客流" WHERE "日期" = \'2026-08-06\'', "分区乙"),
Cite("a2_join", "flow", 'SELECT a."日期", b."全日客流" FROM "日客流" a JOIN "日客流" b ON a."日期" = b."日期" '
'WHERE a."日期" = \'2026-08-07\'', "全日客流"),
Cite("a2_comma", "flow", 'SELECT a."日期", b."全日客流" FROM "日客流" a, "日客流" b WHERE a."日期" = b."日期" '
'AND a."日期" = \'2026-08-07\'', "全日客流"),
Cite("a3", "flow", 'SELECT "日期", "时段", "客流" FROM "时段客流" WHERE "时段" = \'7-8\' '
'AND "日期" = \'2026-08-03\'', "客流"),
Cite("a8", "flow", 'SELECT "日期", "合计项", "客流" FROM "时段客流_表内合计" WHERE "日期" = \'2026-08-10\' '
'AND "合计项" = \'18-22时合计\'', "客流"),
Cite("a11", "flow", 'SELECT SUM("全日客流") AS "合计", COUNT(*) AS "天数" FROM "日客流"', "合计"),
Cite("a16", "flow", 'SELECT "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\'', "全日客流"),
Cite("long", "flow", long_sql("2026-08-20", "13-14"), "客流"),
Cite("a7", "acc", long_sql("2026-09-12", "8-9"), "客流"),
Cite("a18", "acc", long_sql("2026-09-12", "8-9"), "时段", node="a7"),
Cite("a13_bad", "d26", wide_sql("2026-09-10"), "全日客流"),
Cite("a13_other", "d26", wide_sql("2026-09-11"), "全日客流"),
Cite("union_bad", "d26acc", wide_sql("2026-09-10"), "全日客流"),
Cite("union_other", "d26acc", wide_sql("2026-09-11"), "全日客流"),
Cite("a15", "masked", wide_sql("2026-08-16", "分区甲"), "分区甲"),
Cite("a17", "human", wide_sql("2026-09-04"), "全日客流"),
Cite("a19", "list", 'SELECT "地区", "金额" FROM "月报" WHERE "地区" = \'分区丙\'', "金额"),
Cite("a15_pk", "list_masked", 'SELECT "地区", "金额" FROM "月报" WHERE "地区" = \'分区丙\'', "金额"),
Cite("a12", "simple", 'SELECT "地区", "金额" FROM "地区汇总" WHERE "地区" = \'分区乙\'', "金额"),
Cite("a10", "manual", 'SELECT "地区", "订单数" FROM "订单汇总" WHERE "地区" = \'分区丙\'', "订单数"),
Cite("d28", "d28", wide_sql("2026-09-06"), "全日客流"),
Cite("d28h", "d28h", wide_sql("2026-09-06"), "全日客流"),
]
cites += [Cite(f"a24_{k}", "flow", sql, "全日客流") for k, sql in A24_SQL.items()]
# A20 ⑤:同一数据版本(D28 那个源)5 条查询、15 句话
for i, day in enumerate(("2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05")):
sql = f'SELECT "日期", "全日客流", "分区甲", "分区乙" FROM "日客流" WHERE "日期" = \'{day}\''
for col in ("全日客流", "分区甲", "分区乙"):
cites.append(Cite(f"cost_{i}_{col}", "d28", sql, col, node=f"cost_{i}"))
return cites
async def _build(base: Path) -> World:
sources: dict[str, Source] = {}
await run_manager.setup()
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
raw, fn = flow_workbook(AUG, 31, seed=71)
def trap(ws: Any) -> None:
ws["E7"] = 0 # 2026-08-03 的分区乙
ws["E5"] = ws["E6"].value # 全日客流 = 分区甲 + 0
sources["flow"] = await first_import(c, rework(raw, trap), fn)
sources["acc"] = await first_import(c, *flow_workbook(AUG, 31, seed=72), mode="accumulate")
await add_period(c, sources["acc"], *flow_workbook(SEP, 30, seed=73, variant="D24w"))
sources["d26"] = await first_import(c, *flow_workbook(AUG, 31, seed=74))
await add_period(c, sources["d26"], *flow_workbook(SEP, 30, seed=75, variant="D26"),
acceptances=[{"check_id": "R1", "reason": REASON_D26}])
sources["d26acc"] = await first_import(c, *flow_workbook(AUG, 31, seed=89), mode="accumulate")
await add_period(c, sources["d26acc"], *flow_workbook(SEP, 30, seed=90, variant="D26"),
acceptances=[{"check_id": "R1", "reason": REASON_D26}])
sources["human"] = await first_import(c, *flow_workbook(AUG, 31, seed=76))
await add_period(c, sources["human"], *flow_workbook(SEP, 30, seed=77, variant="noperiod"),
human=("2026-09-01", "2026-09-30"))
sources["masked"] = await first_import(c, *flow_workbook(AUG, 31, seed=78))
sources["list"] = await first_import(c, *lab.c01(), rename=False)
sources["list_masked"] = await first_import(c, *lab.c01(), rename=False)
book = list_book("地区汇总", ("地区", "金额", "订单数"),
[("分区甲", 31847.5, 412), ("分区乙", 27093.25, 377), ("分区丙", 18456.0, 268)])
sources["simple"] = await simple_upload(c, book, "地区汇总_2026-08.xlsx", unique())
sources["d28"] = await first_import(c, *flow_workbook(SEP, 30, seed=79, variant="D28"))
raw, fn = flow_workbook(SEP, 30, seed=80, variant="D28")
def hide(ws: Any) -> None:
ws.row_dimensions[32].hidden = True
sources["d28h"] = await first_import(c, rework(raw, hide), fn)
sources["manual"] = await manual_source(unique(), base / "orders.db")
await set_masks(sources["masked"].id, "分区甲")
await set_masks(sources["list_masked"].id, "地区")
main = await run_report(sources, _main_cites())
# list_masked 的遮罩只留在查询快照里(mask_columns):数据源现在的设置清掉,R10 要靠快照记下的那份拦住
await set_masks(sources["list_masked"].id, None)
legacy = await run_report(sources, [Cite("a6", "flow", wide_sql("2026-08-05"), "全日客流")], legacy=True)
gate = await run_report(sources, [Cite("a22", "flow", wide_sql("2026-08-05"), "全日客流")], gate=True)
broken = await run_report(sources, [Cite("a23", "flow", wide_sql("2026-08-05"), "全日客流")])
finally:
await run_manager.shutdown()
for src in sources.values():
await engines.invalidate(src.id)
# 这个事件循环里建的连接留在连接池里,换了事件循环的用例取到会出错:交还之前全部关掉
await db_engine.dispose()
return World(base, sources, main, legacy, gate, broken)
@pytest.fixture(scope="module", autouse=True)
def module_store(tmp_path_factory):
"""整个模块一个数据目录(世界的数据文件、工件、原件存档都在里面);规则起草不完整时的 AI 可用性换成「不可用」
的桩,连模型对象都不建;验收必须跑真管线(recipe_imports.pipeline() 接的是各工作包的真函数)。"""
from app.data import recipe_ai, recipe_engine, recipe_imports, recipe_suggest
from app.data.recipe_types import AiAvailability
p = recipe_imports.pipeline()
assert p.execute is recipe_engine.execute and p.draft is recipe_suggest.draft
async def no_model(session: Any) -> Any:
return None, "", AiAvailability(False, reason="验收测试不接模型")
data = tmp_path_factory.mktemp("p4e") / "data"
(data / "uploads").mkdir(parents=True)
mp = pytest.MonkeyPatch()
mp.setattr(settings, "data_dir", data)
mp.setattr(recipe_ai, "resolve_draft_model", no_model)
yield data
mp.undo()
@pytest.fixture(scope="module")
def world(module_store) -> World:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(_build(module_store))
finally:
loop.close()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def fresh_engines(world):
"""世界的数据文件引擎在用例自己的事件循环里重新建(连接池不跨事件循环),用完关掉。"""
for src in world.sources.values():
await engines.invalidate(src.id)
yield world
for src in world.sources.values():
await engines.invalidate(src.id)
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def own(engine_up):
"""改状态的用例自己的源:用完关掉它们的数据层引擎。"""
made: list[Source] = []
yield made
for src in made:
await engines.invalidate(src.id)
async def own_flow(made: list[Source], *, seed: int, legacy: bool = False) -> tuple[Source, RunInfo]:
"""一个独立的 8 月 D00 每期替换源,跑一次只引用 A4 那一格的运行。"""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
src = await first_import(c, *flow_workbook(AUG, 31, seed=seed))
made.append(src)
run = await run_report({"flow": src}, [Cite("a4", "flow", wide_sql("2026-08-05"), "全日客流")], legacy=legacy)
return src, run
@pytest.fixture
def tripwire(monkeypatch) -> list[str]:
"""把 provenance、direct_select 两个模块的公开函数换成一调用就记下来并抛错的桩(P4-SPEC 3.3):期 4 之前的文档、
非上传源的裁判摘录不许碰它们。"""
from app.data import provenance as provenance_mod
from app.engine import direct_select as direct_select_mod
hits: 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 isinstance(obj, type) \
or getattr(obj, "__module__", None) != mod.__name__:
continue
def boom(*_a: Any, __name: str = f"{mod.__name__}.{name}", **_kw: Any) -> Any:
hits.append(__name)
raise AssertionError(f"不该调用 {__name}")
monkeypatch.setattr(mod, name, boom)
return hits
@pytest.fixture
def judge_spy(monkeypatch) -> list[dict[str, str]]:
"""按需裁判的桩:截下交给裁判的摘录,不调用任何模型、不记判定、不计费(outcome 里 calls 为 0,接口不追加事件,
同一句可以再点一次)。"""
from app.engine import judge as judging
seen: list[dict[str, str]] = []
async def fake_run(request: Any, **_kw: Any) -> dict[str, Any]:
seen.append(dict(request.excerpts))
return {"verdicts": {}, "calls": 0, "cost_usd": 0.0, "limits_hit": [], "unjudged": {}, "gaps": [],
"notes": [], "model": None, "priced": None, "duration_ms": 0}
monkeypatch.setattr(judging, "run_request", fake_run)
return seen
def db_rowid(snapshot: SourceSnapshot, table: str, pk: dict[str, Any]) -> int:
"""按主键在快照库上取 rowid(只读、immutable,不改文件):cell_source.rowid 必须是快照库的 rowid。"""
where = " AND ".join(f'"{k}" = ?' for k in pk)
with closing(sqlite3.connect(f"file:{snapshot.db_path}?mode=ro&immutable=1", uri=True)) as conn:
got = conn.execute(f'SELECT rowid FROM "{table}" WHERE {where}', list(pk.values())).fetchall()
assert len(got) == 1, (table, pk, got)
return got[0][0]
async def assert_drilled(out: ProvenanceOut, run: RunInfo, key: str, *, table: str, column: str, role: str,
pk: dict[str, Any], sheet: str, cell: str, part_seq: int, kind: str = "data",
sealed: bool = True) -> None:
"""inferred 的公共部分:格子、主键、快照库的 rowid、按主键回查的那一句(? 占位、按主键顺序,WP-0 交接)。"""
assert out.status == "inferred", (out.status, out.reason, out.alert)
assert out.reason is None and out.alert is None and out.sealed is sealed
assert out.cell == cell_ref(run, key)
cs = out.cell_source
assert (cs.table, cs.column, cs.column_role, cs.kind) == (table, column, role, kind)
assert cs.pk == pk and list(cs.pk) == list(pk)
# 键序(回查的 WHERE、params 同序)是冻结表结构的 primary_key,不是 SELECT 里的顺序(WP-0 交接)
frozen = artifact_store.load(query_snapshot(run, key)["schema_artifact"])["tables"][table]
assert list(cs.pk) == frozen["primary_key"]
assert (cs.sheet, cs.cell, cs.part_seq) == (sheet, cell, part_seq)
snap = await snapshot_row(query_snapshot(run, key)["data_version"])
assert cs.rowid == db_rowid(snap, table, pk)
where = " AND ".join(f'"{k}" = ?' for k in pk)
assert cs.recheck == Recheck(sql=f'SELECT rowid, "{column}" FROM "{table}" WHERE {where}',
params=list(pk.values()), ok=True)
assert cs.raw_purged is False and cs.merged_fill is False
hits = [p for p in out.version.parts if p.has_row]
assert len(hits) == 1 and hits[0].seq == part_seq
# ==========================================================================
# final.md 第 5 节「期 4」的四条(A1–A6)
# ==========================================================================
async def flow_version(world: World, out: ProvenanceOut, tables: list[TableRef]) -> None:
"""flow 源(8 月 D00、每期替换)的数据版本:一期、清单里的事实逐项对得上(表级来历只靠清单,2.6)。"""
src = world.sources["flow"]
has_row = out.status == "inferred"
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="replace", union=False, tables=tables, manifest_view=True,
parts=[await expected_part(src, 0, has_row=has_row, period=august(), region=f"{SHEET}!B4:AG30")])
DAILY = [TableRef(name="日客流", kind="data")]
async def test_a1_renamed_column_only_gets_table_level_provenance(world, fresh_engines, client):
"""A1:`SELECT "日期", "分区甲" AS "分区乙"` 不下钻——改名之后无法确认这一格对应哪一列(R7 · alias)。
数据版本照给;查询步骤带 `provenance: true`(界面因此才会来问)。"""
w = world
out = await provenance(client, w.main, "a1")
assert_table_only(out, "alias", detail="alias")
assert out.cell == cell_ref(w.main, "a1") and out.sealed is True
await flow_version(w, out, DAILY)
assert (await query_step(client, w.main, "a1"))["provenance"] is True
@pytest.mark.parametrize("key,detail", [("a2_join", "join"), ("a2_comma", "comma_join")])
async def test_a2_self_joins_are_not_drilled(world, fresh_engines, client, key, detail):
"""A2:自连接(JOIN 与逗号两种写法)不下钻:R7 · multi_table,细分分别是 join、comma_join。"""
out = await provenance(client, world.main, key)
assert_table_only(out, "multi_table", detail=detail)
await flow_version(world, out, DAILY)
async def test_a3_a_null_cell_is_not_drilled(world, fresh_engines, client):
"""A3:占位符那一行(7-8 时)存为空值:片段本身是 resolved、显示「—」;推断来源 R11 · null_value。"""
w = world
seg = w.main.segs["a3"]
assert seg["state"] == "deterministic" and seg["cite"]["status"] == "resolved" and seg["text"] == "—"
assert result_value(w.main, "a3") is None
assert xl(w.sources["flow"].raws[0][0], "E10") == "·" # 原表里那一格是占位符
out = await provenance(client, w.main, "a3")
assert_table_only(out, "null_value")
await flow_version(w, out, [TableRef(name="时段客流", kind="data")])
WIDE_IDS = ["R1", "R2", "C1", "C2"]
async def test_a4_a_direct_cell_names_its_sheet_and_cell(world, fresh_engines, client):
"""A4:满足条件时给出推断的来源:客流汇总!G5;日期取自表头格 G4、年份取自统计期(B2)、指标名取自行标签 B5;
按主键回查一致;R1 本期通过、这一行成立;数据版本一期,文件名、原件哈希与上传的文件一致。
G5 的保存值(openpyxl 读夹具字节,只作对照)等于查询结果那一格。"""
w = world
raw, _ = w.sources["flow"].raws[0]
out = await provenance(client, w.main, "a4")
await assert_drilled(out, w.main, "a4", table="日客流", column="全日客流", role="measure",
pk={"日期": "2026-08-05"}, sheet=SHEET, cell="G5", part_seq=1)
cs = out.cell_source
assert cs.part_rowid == cs.rowid # 单期:快照库就是那一期的构建库
assert (cs.header, cs.unit) == ("全日客流(人次)", "人次")
assert froms(out) == [axis("G"), label("B5", "全日客流(人次)", None)]
assert cs.year == YearSource(source="period", cells=[f"{SHEET}!B2"], signed_by=None, mixed=False)
assert cs.canonical is None
assert [x.id for x in out.checks] == WIDE_IDS
r1 = check_of(out, "R1")
assert (r1.kind, r1.part_status, r1.row_status, r1.cell_status, r1.acceptance) == \
("relation_sum_eq", "passed", "passed", None, None)
r2 = check_of(out, "R2")
assert (r2.kind, r2.part_status, r2.row_status, r2.cell_status) == ("relation_not_comparable", "info", None, None)
assert [(x.part_status, x.row_status, x.cell_status) for x in out.checks[2:]] == [("passed", None, None)] * 2
await flow_version(w, out, DAILY)
assert out.version.parts[0].raw_sha256 == sha256(raw)
# 对照原件:G5 的保存值就是报告里那一格;G4 是 8 月 5 日的表头,B5 是指标名
assert xl(raw, "G5") == result_value(w.main, "a4")
assert xl(raw, "G4") == "8月5日" and xl(raw, "B5") == "全日客流(人次)"
assert (await query_step(client, w.main, "a4"))["provenance"] is True
# 指定报告节点(?report=)选的是同一份报告,答复相同
assert await provenance(client, w.main, "a4", report="write") == out
async def test_long_table_cell_names_the_section_title_by_its_recipe_text(world, fresh_engines, client):
"""2.5 的推演表「交叉表逆透视后的长表」:V16;日期 V4;行标签 B16「13-14」;分段标题 B9 只给配方里的定位文字
(text 为 null,locate_title 是配方的标题),时段类别取自它。标签原文与库里的值相同,canonical 为 null。"""
w = world
raw, _ = w.sources["flow"].raws[0]
out = await provenance(client, w.main, "long")
await assert_drilled(out, w.main, "long", table="时段客流", column="客流", role="value",
pk={"日期": "2026-08-20", "时段": "13-14"}, sheet=SHEET, cell="V16", part_seq=1)
assert froms(out) == [axis("V"), label("B16", "13-14", "时段"), title("B9")]
assert out.cell_source.canonical is None
assert xl(raw, "V16") == result_value(w.main, "long") and xl(raw, "B9") == DAY_TITLE
assert [x.id for x in out.checks] == ["R2", "C1", "C2"]
async def test_a5_a_tampered_data_file_is_flagged_red(own, client):
"""A5:A4 的运行跑完之后,数据文件被改了一个字节(chmod 0644 → 改 → chmod 0444,实测 B5):下一次取引擎时指纹
对不上、重新核对整份哈希失败 → table_only · db_tampered,标红;数据版本照给(只靠清单),不给格子。"""
src, run = await own_flow(own, seed=81)
first = await provenance(client, run, "a4")
assert first.status == "inferred"
path = Path((await snapshot_row(src.snapshot)).db_path)
data = bytearray(path.read_bytes())
data[len(data) // 2] ^= 0xFF
os.chmod(path, 0o644)
path.write_bytes(bytes(data))
os.chmod(path, 0o444)
out = await provenance(client, run, "a4")
assert_table_only(out, "db_tampered", alert=True)
assert out.alert.text == REASON_TEXT["db_tampered"] and "可能被修改过" in out.alert.text
assert [p.import_id for p in out.version.parts] == src.imports
async def test_a5b_registered_hash_that_disagrees_with_the_manifest_is_flagged_red(own, client):
"""A5b:用另一个合法的库替换数据文件,并把登记哈希 source_snapshots.db_sha256 改成新文件的哈希:首次开库校验能
通过,只有「登记哈希 ≠ 导入清单里的库哈希」(R13)拦得住 → table_only · chain_mismatch,标红,数据版本照给。"""
src, run = await own_flow(own, seed=82)
assert (await provenance(client, run, "a4")).status == "inferred"
snap = await snapshot_row(src.snapshot)
path = Path(snap.db_path)
other = path.with_name(path.stem + ".other.db")
shutil.copyfile(path, other)
os.chmod(other, 0o644)
with closing(sqlite3.connect(other)) as conn:
conn.execute('UPDATE "日客流" SET "分区甲" = "分区甲" + 1 WHERE "日期" = \'2026-08-20\'')
conn.commit()
new_sha = sha256(other.read_bytes())
assert new_sha != snap.db_sha256
os.chmod(path, 0o644)
os.replace(other, path)
os.chmod(path, 0o444)
async with SessionLocal() as session:
row = await session.get(SourceSnapshot, src.snapshot)
row.db_sha256 = new_sha
await session.commit()
await engines.invalidate(src.id)
out = await provenance(client, run, "a4")
assert_table_only(out, "chain_mismatch", alert=True)
assert out.version.source == src.name and out.version.snapshot_id == src.snapshot
async def test_a6_a_pre_p4_run_keeps_its_panel_and_review(engine_up, client, tripwire):
"""A6:期 4 之前的文档(报告节点的 compose_doc 换成去掉 provenance 键的包装,存下的就是老形状,6.1):
查询步骤没有 provenance 键(界面一个请求都不多发);推断来源接口回 none · legacy_doc,cell 照给;复核(verify_doc)
没有 state_mismatch、证据图里报告文档复验通过;裁判摘录在 SQL 与「列:」之间没有新行,也不碰 provenance、
direct_select 两个模块。三份金样逐字不变见 test_provenance_unchanged.py(6.6)。"""
from app.engine import judge as judging
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
src = await first_import(c, *flow_workbook(AUG, 31, seed=83))
try:
run = await run_report({"flow": src}, [Cite("a6", "flow", wide_sql("2026-08-05"), "全日客流")], legacy=True)
assert "provenance" not in run.doc
step = await query_step(client, run, "a6")
assert "provenance" not in step and step["rows"] and step["sealed"] is True
out = await provenance(client, run, "a6")
assert (out.status, out.reason.code, out.alert, out.version) == ("none", "legacy_doc", None, None)
assert out.reason.text == REASON_TEXT["legacy_doc"] and out.cell == cell_ref(run, "a6")
graph = (await client.get(f"/api/runs/{run.id}/evidence")).json()
assert graph["reports"][0]["hash_ok"] is True and graph["seal"]["ok"] is True
checked = verify_doc(run.doc, run.doc["catalog"], loader=artifact_store.load)
assert checked["ok"] is True and not [v for v in checked["violations"] if v.get("code") == "state_mismatch"]
text = judging.prepare(run.doc, run.doc["catalog"], loader=artifact_store.load).excerpts[run.aliases["a6"]]
assert excerpt_new_lines(text) == []
assert tripwire == []
finally:
await engines.invalidate(src.id)
def excerpt_new_lines(text: 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], text
return lines[sql[0] + 1:cols[0]]
# ==========================================================================
# 补充用例(A7–A26)
# ==========================================================================
async def acc_version(world: World, out: ProvenanceOut, has_row_seq: int | None) -> None:
"""acc 源(8 月 D00 + 9 月 D24w,按期累积)的数据版本:两期按统计期升序,快照清单的 mode、并集。"""
src = world.sources["acc"]
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="accumulate", union=True,
tables=[TableRef(name="时段客流", kind="data")], manifest_view=True,
parts=[await expected_part(src, 0, has_row=has_row_seq == 1, period=august(), region=f"{SHEET}!B4:AG30"),
await expected_part(src, 1, has_row=has_row_seq == 2, period=september(),
region=f"{SHEET}!B4:AF30")])
async def test_a7_a_union_cell_points_into_the_second_period_file(world, fresh_engines, client):
"""A7:累积并集(8 月加 9 月)里 9 月 12 日 8-9 时那一格:第 2 期,9 月文件的 N11(openpyxl 对照);
part_rowid = rowid − 快照清单里这张表这一期的 union 起点 + 1;数据版本两期,只有第 2 期 has_row;
mode accumulate、union true。9 月那一期的时段标签是全角的「8-9」,库里存规范写法「8-9」:canonical 照给。"""
w = world
src = w.sources["acc"]
sep_raw, _ = src.raws[1]
out = await provenance(client, w.main, "a7")
await assert_drilled(out, w.main, "a7", table="时段客流", column="客流", role="value",
pk={"日期": "2026-09-12", "时段": "8-9"}, sheet=SHEET, cell="N11", part_seq=2)
cs = out.cell_source
schema = artifact_store.load(query_snapshot(w.main, "a7")["schema_artifact"])
sm = artifact_store.load(schema["snapshot_manifest"])
[part2] = [p for p in sm["parts"] if p["seq"] == 2]
assert cs.part_rowid == cs.rowid - part2["rows"]["时段客流"]["union"][0] + 1
assert part2["rows"]["时段客流"]["part"][0] == 1 and cs.part_rowid != cs.rowid
assert froms(out) == [axis("N"), label("B11", "8-9", "时段"), title("B9")]
assert cs.canonical == CanonicalView(raw="8-9", canonical="8-9")
assert xl(sep_raw, "N11") == result_value(w.main, "a7")
assert xl(sep_raw, "B11") == "8-9" and xl(sep_raw, "N4") == "9月12日"
await acc_version(w, out, 2)
async def test_a8_a_reported_total_cell(world, fresh_engines, client):
"""A8:原表写明的合计(时段客流_表内合计):L28(第 28 行、2026-08-10 那一列,openpyxl 对照);附带合计标签 B28,
text 取自 DerivedItem.label_raw「18-22 时合计」,canonical 给出规范写法「18-22时合计」;kind reported_total;
相关核对 K1 这一格一致,G1 这一格一致(L28 在夹具里是公式格)。"""
w = world
raw, _ = w.sources["flow"].raws[0]
out = await provenance(client, w.main, "a8")
await assert_drilled(out, w.main, "a8", table="时段客流_表内合计", column="客流", role="value",
pk={"日期": "2026-08-10", "合计项": "18-22时合计"}, sheet=SHEET, cell="L28", part_seq=1,
kind="reported_total")
assert froms(out) == [axis("L"), label("B28", "18-22 时合计", "合计项", role="total_label")]
assert out.cell_source.canonical == CanonicalView(raw="18-22 时合计", canonical="18-22时合计")
assert xl(raw, "L28") == result_value(w.main, "a8") and xl(raw, "B28") == "18-22 时合计"
assert str(load_workbook(io.BytesIO(raw))[SHEET]["L28"].value).startswith("=SUM(") # 公式格
assert [x.id for x in out.checks] == ["K1", "G1", "C1", "C2"]
for cid, kind in (("K1", "derived_sum"), ("G1", "formula_refs")):
x = check_of(out, cid)
assert (x.kind, x.part_status, x.row_status, x.cell_status, x.acceptance) == (kind, "passed", None, "ok", None)
await flow_version(w, out, [TableRef(name="时段客流_表内合计", kind="reported_total")])
async def test_a9_purging_the_original_keeps_the_cell(own, client):
"""A9:A4 的源经接口 purge-raw 清除原件后再点同一格:仍是 inferred、格子不变(坐标来自导入清单,不读原件);
数据版本里 raw_state purged、purged 带时间、署名、理由(导入记录的当前状态);cell_source.raw_purged 为真。"""
src, run = await own_flow(own, seed=84)
before = await provenance(client, run, "a4")
assert before.status == "inferred" and before.version.parts[0].raw_state == "kept"
reason = "合成理由:原件不再保留"
r = await client.post(f"{API}/{src.id}/imports/{src.imports[0]}/purge-raw",
json={"confirm": True, "reason": reason, "signed_by": SIGNER})
assert r.status_code == 200, r.text
out = await provenance(client, run, "a4")
assert out.status == "inferred" and out.cell_source.raw_purged is True
assert replace(out.cell_source, raw_purged=False) == before.cell_source
part = out.version.parts[0]
assert part.raw_state == "purged" and part.has_row is True
assert part.purged is not None and (part.purged.reason, part.purged.signed_by) == (reason, SIGNER)
assert isinstance(part.purged.at, str) and part.purged.at
imp = await import_row(src.imports[0])
assert imp.raw_state == "purged" and imp.purged["reason"] == reason
async def test_a10_a_manual_source_is_not_an_upload(world, fresh_engines, client, tripwire):
"""A10:手工 SQLite 源上的格子:查询步骤**没有** provenance 键;推断来源接口回 none · not_upload;裁判摘录与期 4
之前同一个形状(SQL 与「列:」之间没有新行,带不带文档标记逐字相同),也不碰 provenance、direct_select。"""
from app.engine import judge as judging
w = world
step = await query_step(client, w.main, "a10")
assert "provenance" not in step and step["rows"]
out = await provenance(client, w.main, "a10")
assert (out.status, out.reason.code, out.alert, out.version) == ("none", "not_upload", None, None)
assert out.cell == cell_ref(w.main, "a10")
alias, unit = w.main.aliases["a10"], w.main.units["a10"]
marked = judging.prepare(w.main.doc, w.main.doc["catalog"], units=[unit], loader=artifact_store.load).excerpts
old = {k: v for k, v in w.main.doc.items() if k != "provenance"}
plain = judging.prepare(old, old["catalog"], units=[unit], loader=artifact_store.load).excerpts
assert list(marked) == [alias] and marked == plain
assert excerpt_new_lines(marked[alias]) == []
assert tripwire == []
async def test_a11_an_aggregate_only_gets_table_level_provenance(world, fresh_engines, client):
"""A11:聚合(SUM、COUNT)在 R7 被拒:table_only · expression;数据版本列出涉及的表 [日客流]。"""
out = await provenance(client, world.main, "a11")
assert_table_only(out, "expression", detail="expression")
assert out.version.tables == DAILY
await flow_version(world, out, DAILY)
async def test_a12_a_simple_upload_has_no_lineage(world, fresh_engines, client):
"""A12:期 1 的简单导入(/upload):none · simple_upload,只说一句、不标红、没有数据版本。查询步骤带提示(上传源
都有 data_version),界面会来问一次,得到这一句(2.8.2)。"""
w = world
assert (await query_step(client, w.main, "a12"))["provenance"] is True
out = await provenance(client, w.main, "a12")
assert (out.status, out.reason.code, out.alert, out.version, out.cell_source) == \
("none", "simple_upload", None, None, None)
assert out.reason.text == REASON_TEXT["simple_upload"] and out.cell == cell_ref(w.main, "a12")
async def d26_parts(src: Source, *, has_row: bool, revoked: StateNote | None = None) -> list[PartView]:
mid = await manifest_id_of(src.imports[1])
return [await expected_part(src, 1, has_row=has_row, period=september(), region=f"{SHEET}!B4:AF30",
acceptances=[manifest_acceptance(mid, "R1")], revoked=revoked)]
async def test_a13_an_accepted_mismatch_shows_the_row_and_the_reason(world, fresh_engines, client):
"""A13:每期替换的源,先导入 D00、再导入 D26 并写理由接受 R1(9 月 10 日 全日 ≠ 甲 + 乙);运行固定在 D26 这一版。
不成立的那一天:R1 本期不成立、这一行不成立,接受理由等于提交时写的;另一天:这一行成立,接受同样带着(这一期的
接受)。数据版本只有 D26 这一期(seq 2),它的 acceptances 有这一条。"""
w = world
src = w.sources["d26"]
raw, _ = src.raws[1]
mid = await manifest_id_of(src.imports[1])
accepted = manifest_acceptance(mid, "R1")
assert accepted.reason == REASON_D26 and accepted.signed_by is None and accepted.at
assert "全日客流 = 分区甲 + 分区乙" in accepted.title
# 夹具:9 月 10 日(L 列)全日 ≠ 甲 + 乙,9 月 11 日(M 列)相等
assert xl(raw, "L5") != xl(raw, "L6") + xl(raw, "L7") and xl(raw, "M5") == xl(raw, "M6") + xl(raw, "M7")
for key, day, cell, row in (("a13_bad", "2026-09-10", "L5", "mismatch"),
("a13_other", "2026-09-11", "M5", "passed")):
out = await provenance(client, w.main, key)
await assert_drilled(out, w.main, key, table="日客流", column="全日客流", role="measure",
pk={"日期": day}, sheet=SHEET, cell=cell, part_seq=2)
assert [x.id for x in out.checks] == WIDE_IDS
assert check_of(out, "R1") == RelatedCheck(id="R1", kind="relation_sum_eq", title=accepted.title,
part_status="mismatch", row_status=row, cell_status=None,
acceptance=accepted, detail=None)
assert out.version == VersionView(source=src.name, snapshot_id=src.snapshot, mode="replace", union=False,
tables=DAILY, manifest_view=True,
parts=await d26_parts(src, has_row=True))
assert xl(raw, cell) == result_value(w.main, key)
async def test_union_row_status_runs_on_the_union_rowid(world, fresh_engines, client):
"""2.7 的行级核对在累积并集上按**快照库**的 rowid 跑(cell_source.rowid 是并集 rowid,不是该期的 part_rowid):
8 月 D00 + 9 月 D26(写理由接受)按期累积,9 月 10 日这一行不成立、9 月 11 日成立。按 part_rowid 跑会落到 8 月
的同号行上(8 月逐日成立),把不成立的那天说成成立。接受理由挂在第 2 期的 R1 上,第 1 期没有接受。"""
w = world
src = w.sources["d26acc"]
sep_raw, _ = src.raws[1]
mid = await manifest_id_of(src.imports[1])
accepted = manifest_acceptance(mid, "R1")
for key, day, cell, row, part_rowid in (("union_bad", "2026-09-10", "L5", "mismatch", 10),
("union_other", "2026-09-11", "M5", "passed", 11)):
out = await provenance(client, w.main, key)
await assert_drilled(out, w.main, key, table="日客流", column="全日客流", role="measure",
pk={"日期": day}, sheet=SHEET, cell=cell, part_seq=2)
assert out.cell_source.part_rowid == part_rowid and out.cell_source.rowid == 31 + part_rowid
assert check_of(out, "R1") == RelatedCheck(id="R1", kind="relation_sum_eq", title=accepted.title,
part_status="mismatch", row_status=row, cell_status=None,
acceptance=accepted, detail=None)
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="accumulate", union=True, tables=DAILY,
manifest_view=True,
parts=[await expected_part(src, 0, has_row=False, period=august(), region=f"{SHEET}!B4:AG30"),
await expected_part(src, 1, has_row=True, period=september(), region=f"{SHEET}!B4:AF30",
acceptances=[accepted])])
assert xl(sep_raw, cell) == result_value(w.main, key)
async def test_a14_revoked_acceptance_stays_visible_on_the_pinned_version(own, client):
"""A14:A13 之后经接口 revoke-acceptance 作废 D26 这一期的接受:每期替换模式下就是回滚到 D00 那一版,请求体带
revoke_plan 给的 expected_target_snapshot_id。再打开固定在 D26 版本上的那次运行、点同一格:仍是 inferred(被运行
引用的版本受保护,数据文件还在);version.parts[0].revoked 非空(时间、理由、署名),接受理由照旧在。"""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
src = await first_import(c, *flow_workbook(AUG, 31, seed=85))
await add_period(c, src, *flow_workbook(SEP, 30, seed=86, variant="D26"),
acceptances=[{"check_id": "R1", "reason": REASON_D26}])
own.append(src)
run = await run_report({"d26": src}, [Cite("a13_bad", "d26", wide_sql("2026-09-10"), "全日客流")])
before = await provenance(client, run, "a13_bad")
assert before.status == "inferred" and before.version.parts[0].revoked is None
[record] = [r for r in (await client.get(f"{API}/{src.id}/imports")).json() if r["id"] == src.imports[1]]
plan = record["revoke_plan"]
assert plan["action"] == "rollback" and plan["target_snapshot_id"] == src.snapshots[0]
reason = "合成理由:接受依据有误"
r = await client.post(f"{API}/{src.id}/imports/{src.imports[1]}/revoke-acceptance",
json={"confirm": True, "expected_current_snapshot_id": src.snapshot,
"expected_target_snapshot_id": plan["target_snapshot_id"], "reason": reason,
"signed_by": SIGNER})
assert r.status_code == 200, r.text
out = await provenance(client, run, "a13_bad")
await assert_drilled(out, run, "a13_bad", table="日客流", column="全日客流", role="measure",
pk={"日期": "2026-09-10"}, sheet=SHEET, cell="L5", part_seq=2)
[part] = out.version.parts
assert part.revoked is not None and (part.revoked.reason, part.revoked.signed_by) == (reason, SIGNER)
assert isinstance(part.revoked.at, str) and part.revoked.at
assert out.version.parts == await d26_parts(src, has_row=True, revoked=part.revoked)
assert check_of(out, "R1").acceptance == part.acceptances[0]
async def test_a15_a_masked_column_is_not_drilled(world, fresh_engines, client):
"""A15:数据源设 mask_columns="分区甲",引用分区甲那一格:table_only · masked(行标签原文、主键值都是格子里的
内容,展示出来就绕过了遮罩,调整 4);数据版本里不给「查看导入清单」(manifest_view false)。"""
w = world
src = w.sources["masked"]
out = await provenance(client, w.main, "a15")
assert_table_only(out, "masked")
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="replace", union=False, tables=DAILY, manifest_view=False,
parts=[await expected_part(src, 0, has_row=False, period=august(), region=f"{SHEET}!B4:AG30")])
assert (await query_step(client, w.main, "a15"))["masked"] == ["分区甲"]
async def test_a15_a_masked_primary_key_recorded_in_the_snapshot_is_enough(world, fresh_engines, client):
"""A15 补充(列表源):只遮主键「地区」、引用「金额」也不下钻——否则遮罩的主键值会经 cell_source.pk 和
recheck.params 交给界面。列表的「地区」角色是 text(不是维度、派生、常量),附带的格只有 column 为空的列表头,
拦它的只有 R10 的「主键列」这一项。遮罩在运行时设、运行之后从数据源上清掉:只剩查询快照记下的 mask_columns,
也要拦住(遮罩 = 数据源现在设的加上快照里记的,R10)。"""
w = world
src = w.sources["list_masked"]
snap = query_snapshot(w.main, "a15_pk")
assert snap["mask_columns"] == ["地区"]
schema = artifact_store.load(snap["schema_artifact"])
assert schema["tables"]["月报"]["primary_key"] == ["地区"]
receipt = artifact_store.load(schema["import_manifests"][0])["receipt"]
assert {c["name"]: c["role"] for t in receipt["tables"] if t["name"] == "月报" for c in t["columns"]}["地区"] == "text"
async with SessionLocal() as session:
assert "mask_columns" not in ((await session.get(DataSource, src.id)).options or {})
out = await provenance(client, w.main, "a15_pk")
assert_table_only(out, "masked")
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="replace", union=False,
tables=[TableRef(name="月报", kind="data")], manifest_view=False,
parts=[await expected_part(src, 0, has_row=False, period=None, region="月报!C5:F9")])
assert (await query_step(client, w.main, "a15_pk"))["masked"] == ["地区"]
async def test_a16_missing_primary_key_columns(world, fresh_engines, client):
"""A16:`SELECT "全日客流" …` 没有带上主键列:table_only · pk_missing,原文里写出没带齐的主键列「日期」。"""
out = await provenance(client, world.main, "a16")
assert_table_only(out, "pk_missing", text=refuse("pk_missing", 列=["日期"]).text)
assert "(日期)" in out.reason.text
await flow_version(world, out, DAILY)
async def test_a17_a_human_entered_period(world, fresh_engines, client):
"""A17:统计期人工录入(noperiod,署名「录入员甲」):年份取自人工录入的统计期 year.source human、signed_by
录入员甲;数据版本这一期的 period.source human、没有格子。人工录入的统计期没有 C1,只有 C2。"""
w = world
src = w.sources["human"]
raw, _ = src.raws[1]
out = await provenance(client, w.main, "a17")
await assert_drilled(out, w.main, "a17", table="日客流", column="全日客流", role="measure",
pk={"日期": "2026-09-04"}, sheet=SHEET, cell="F5", part_seq=2)
assert out.cell_source.year == YearSource(source="human", cells=[], signed_by=SIGNER, mixed=False)
assert froms(out) == [axis("F"), label("B5", "全日客流(人次)", None)]
assert out.version.parts == [await expected_part(src, 1, has_row=True, period=september("human"),
region=f"{SHEET}!B4:AF30")]
assert [x.id for x in out.checks] == ["R1", "R2", "C2"]
assert xl(raw, "F5") == result_value(w.main, "a17") and xl(raw, "B2") is None
async def test_a18_a_dimension_cell_is_its_row_label(world, fresh_engines, client):
"""A18:引用长表的维度列(时段,D24w 全角标签的 9 月,累积并集):格子就是行标签格 B11;canonical.raw 是原表的
「8-9」、canonical 是库里的「8-9」;附带的格是日期表头 N4 和分段标题 B9(被引用的就是行标签,不再重复)。"""
w = world
sep_raw, _ = w.sources["acc"].raws[1]
out = await provenance(client, w.main, "a18")
await assert_drilled(out, w.main, "a18", table="时段客流", column="时段", role="dim",
pk={"日期": "2026-09-12", "时段": "8-9"}, sheet=SHEET, cell="B11", part_seq=2)
assert out.cell_source.canonical == CanonicalView(raw="8-9", canonical="8-9")
assert froms(out) == [axis("N"), title("B9")]
assert xl(sep_raw, "B11") == "8-9" and result_value(w.main, "a18") == "8-9"
await acc_version(w, out, 2)
async def test_a19_a_list_cell(world, fresh_engines, client):
"""A19:列表形态(lab.c01,工作表「月报」)里分区丙的金额:F8(openpyxl 对照);附带列表头 F5(text 是表头原文,
column 为 null);被引用的格不在合并区域里,merged_fill false;列表没有年份。"""
w = world
src = w.sources["list"]
raw, _ = src.raws[0]
out = await provenance(client, w.main, "a19")
await assert_drilled(out, w.main, "a19", table="月报", column="金额", role="measure",
pk={"地区": "分区丙"}, sheet="月报", cell="F8", part_seq=1)
assert froms(out) == [("col_header", None, "月报", "F5", "金额", None)]
assert out.cell_source.year is None and out.cell_source.merged_fill is False
assert xl(raw, "F8", "月报") == result_value(w.main, "a19") and xl(raw, "F5", "月报") == "金额"
assert out.version == VersionView(
source=src.name, snapshot_id=src.snapshot, mode="replace", union=False,
tables=[TableRef(name="月报", kind="data")], manifest_view=True,
parts=[await expected_part(src, 0, has_row=True, period=None, region="月报!C5:F9")])
def lines_of(excerpts: dict[str, str], run: RunInfo, key: str) -> list[str]:
return excerpt_new_lines(excerpts[run.aliases[key]])
async def report_excerpts(run: RunInfo, units: list[str] | None = None) -> Any:
"""报告节点的取法(nodes/report.py):遮罩按目录里数据源现在的设置查,loader 用工件库。"""
from app.engine import judge as judging
catalog = run.doc["catalog"]
masked = await judging.source_masks(catalog)
return judging.prepare(run.doc, catalog, units=units, loader=artifact_store.load, masked=masked)
async def test_a20_judge_excerpts_carry_provenance_lines(world, fresh_engines):
"""A20:新文档、配方源的裁判摘录(judge.prepare,报告节点的取法)。
① 摘录在 SQL 与「列:」之间多出 3.2 的来历行:D26 那条查询有「来源:上传的表格」、文件名、区域、「口径:表 日客流
的说明」、「已接受(用户填写的理由,未经系统核对):」和理由原文;D28 那条有带限定语的「区域外文字(…):」小标题
和 B32 的全文(数字照录)及所属的导入和统计期;人工录入统计期那条写「人工录入,署名「录入员甲」,未认证」;
② 第 32 行隐藏后再导入的变体:没有「闸机故障」,只有「另有 1 处区域外文字未列出…」;
③ 给 D28、D26 两个数据源设遮罩后:没有 B32 的全文、没有接受理由原文,只写条数和「理由未列出」;
④ 同样的输入调两次,摘录逐字相同;
⑤ 估价(JudgeRequest.cost,模型 claude-sonnet-5):只判一句、引用一条 D28 查询 ≤ 0.01 美元;15 句、引用同一
数据版本 5 条查询 ≤ 0.05 美元。"""
w = world
run = w.main
d26, d28 = w.sources["d26"], w.sources["d28"]
request = await report_excerpts(run)
ex = request.excerpts
# ① D26:来源、口径、已接受
new = lines_of(ex, run, "a13_bad")
mid = await manifest_id_of(d26.imports[1])
m = artifact_store.load(mid)
assert new and new[0].startswith("来源:上传的表格,每期替换,第 2 次导入:统计期 2026-09-01 至 2026-09-30"), new
assert f"文件「{d26.raws[1][1]}」(sha256 {m['file']['raw_sha256'][:12]})" in new[0]
assert f"区域 {SHEET}!B4:AF30" in new[0]
schema = artifact_store.load(query_snapshot(run, "a13_bad")["schema_artifact"])
assert f"口径:表 日客流 的说明:{schema['tables']['日客流']['comment']}" in new
title = manifest_acceptance(mid, "R1").title
assert ACCEPT_HEAD in new
assert f" 第 2 次导入(统计期 2026-09-01 至 2026-09-30)的核对「{title}」不成立,理由:{REASON_D26}(未署名)" in new
# ① D28:区域外文字的全文、所属的导入和统计期(数字照录)
new = lines_of(ex, run, "d28")
assert OUTSIDE_HEAD in new
assert f" 第 1 次导入(统计期 2026-09-01 至 2026-09-30){SHEET}!B32:「{D28_NOTE}」" in new
# ① 人工录入的统计期
new = lines_of(ex, run, "a17")
assert new and "统计期 2026-09-01 至 2026-09-30(人工录入,署名「录入员甲」,未认证)" in new[0], new
# ② 隐藏行里的区域外文字不送全文,只计条数
new = lines_of(ex, run, "d28h")
assert not any("闸机故障" in line for line in new), new
assert "另有 1 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏),见证据面板" in [x.strip() for x in new]
# ④ 确定:同样的输入两次逐字相同
assert (await report_excerpts(run)).excerpts == ex
# ③ 设遮罩:理由、区域外文字一概不写原文
try:
await set_masks(d28.id, "分区甲")
await set_masks(d26.id, "分区甲")
masked = (await report_excerpts(run)).excerpts
finally:
await set_masks(d28.id, None)
await set_masks(d26.id, None)
new = lines_of(masked, run, "d28")
assert not any(D28_NOTE in line or "客流汇总表" in line for line in new), new
assert "区域外另有 2 处文字(数据源设置了遮罩,未列出),见证据面板" in new
new = lines_of(masked, run, "a13_bad")
assert not any(REASON_D26 in line for line in new), new
assert any(line.endswith("不成立(数据源设置了遮罩,理由未列出)") for line in new), new
# ⑤ 估价
one = await report_excerpts(run, [run.units["d28"]])
assert [c.unit for c in one.cands] == [run.units["d28"]] and lines_of(one.excerpts, run, "d28")
one_usd = one.cost("claude-sonnet-5", one.cands)
fifteen_units = [run.units[k] for k in run.units if k.startswith("cost_")]
assert len(fifteen_units) == 15 and len({run.aliases[k] for k in run.units if k.startswith("cost_")}) == 5
many = await report_excerpts(run, fifteen_units)
assert len(many.cands) == 15 and all(lines_of(many.excerpts, run, k) for k in run.units if k.startswith("cost_"))
many_usd = many.cost("claude-sonnet-5", many.cands)
print(f"\n[A20] 估价:一句一条查询 {one_usd:.4f} 美元;15 句 5 条查询 {many_usd:.4f} 美元")
assert one_usd is not None and one_usd <= 0.01
assert many_usd is not None and many_usd <= 0.05
async def test_a21_on_demand_judge_reaches_the_chain_only_for_marked_documents(world, fresh_engines, engine_up,
client, judge_spy):
"""A21:按需裁判的取证范围(探索运行)。带标记的文档:`_sealed_loader(sealed, chain=True)` 取得到封存范围内的
表结构快照经哈希链列出的导入清单;一份真实存在、但不在这条链上的清单(D26 源被替换掉的 8 月那一期)取不到。
不带标记(chain=False):链上的清单也取不到,和期 4 之前相同。接口一侧:按需裁判送给裁判的摘录,带标记的文档
有来源行,期 4 之前的文档没有。"""
from app.api import evidence as api
w = world
sealed = await api._sealed(w.main.id)
assert sealed.trusted
chained: set[str] = set()
for sid in sealed.schemas:
schema = artifact_store.load(sid)
if isinstance(schema, dict) and schema.get("import_mode") == "recipe":
chained |= set(schema.get("import_manifests") or [])
if schema.get("snapshot_manifest"):
chained.add(schema["snapshot_manifest"])
assert len(chained) >= 8
stray = await manifest_id_of(w.sources["d26"].imports[0])
assert stray not in chained and isinstance(artifact_store.load(stray), dict)
with_chain = api._sealed_loader(sealed, chain=True)
without = api._sealed_loader(sealed, chain=False)
for mid in sorted(chained):
assert with_chain(mid) == artifact_store.load(mid), mid
assert without(mid) is None, mid
assert with_chain(stray) is None and without(stray) is None
r = await client.post(f"/api/runs/{w.legacy.id}/evidence/judge", json={"units": [w.legacy.units["a6"]]})
assert r.status_code == 200, r.text
assert excerpt_new_lines(judge_spy[-1][w.legacy.aliases["a6"]]) == []
r = await client.post(f"/api/runs/{w.main.id}/evidence/judge", json={"units": [w.main.units["a13_bad"]]})
assert r.status_code == 200, r.text
new = excerpt_new_lines(judge_spy[-1][w.main.aliases["a13_bad"]])
assert new and new[0].startswith("来源:上传的表格") and any(REASON_D26 in line for line in new), new
async def test_a22_a_run_waiting_for_approval_is_drilled_and_marked_unsealed(world, fresh_engines, client):
"""A22:报告之后接一个人工审批节点,运行停在审批卡上(未封存):照常推断(审批人正需要核对报告里的数),
sealed false,没有 alert(不会被误判成清单无法读取);查询步骤带 provenance: true。"""
w = world
async with SessionLocal() as session:
assert (await session.get(Run, w.gate.id)).status == "interrupted"
out = await provenance(client, w.gate, "a22")
await assert_drilled(out, w.gate, "a22", table="日客流", column="全日客流", role="measure",
pk={"日期": "2026-08-05"}, sheet=SHEET, cell="G5", part_seq=1, sealed=False)
step = await query_step(client, w.gate, "a22")
assert step["provenance"] is True and step["sealed"] is False
async def test_a23_a_broken_seal_is_drilled_and_marked_unsealed(world, fresh_engines, client):
"""A23:A4 的运行封存之后,直接改库里一条封存范围内的事件(不重算封存哈希):封存核对不通过,sealed false;
照常推断,没有 alert(不是 manifest_unreadable)。"""
w = world
async with SessionLocal() as session:
started = (await session.execute(select(RunEvent).where(
RunEvent.run_id == w.broken.id, RunEvent.type == "node.started", RunEvent.node_id == "start"))).scalar_one()
started.data = {**(started.data or {}), "note": "事后改过"}
await session.commit()
graph = (await client.get(f"/api/runs/{w.broken.id}/evidence")).json()
assert graph["seal"]["sealed"] is True and graph["seal"]["ok"] is False
out = await provenance(client, w.broken, "a23")
await assert_drilled(out, w.broken, "a23", table="日客流", column="全日客流", role="measure",
pk={"日期": "2026-08-05"}, sheet=SHEET, cell="G5", part_seq=1, sealed=False)
@pytest.mark.parametrize("variant", sorted(A24_SQL))
async def test_a24_hidden_compound_queries_are_refused(world, fresh_engines, client, monkeypatch, variant):
"""A24:藏起来的集合运算(实测 B7 的 `ESCAPE '\\'`、`AS [t']` 两种写法,加上 WP-A 补的记号开头的 U+FEFF):
结果列名是「全日客流」,r0 的值实际取自分区甲;夹具让 2026-08-03 两列的值恰好相等,只比值的回查分不出来。
必须由 R7 认出:table_only · multi_table,细分 compound;不给格子。
R15 的兜底(EXPLAIN 里 UNION ALL 有两个 ResultRow)给出的结论和 R7 一字不差,只看接口结论分不出是哪一道
拦下的:R7 失效时 R15 照样给 compound,这条用例照样通过(评审实测:R7 整个拿掉,单元测试 23 条失败、这里
全过)。所以把 provenance_db.compile_facts 换成放行的桩(只读了本表、ResultRow 1、没有 Yield)——这时能给出
compound 的只剩 R7,R7 一漏就会一路下钻到 R17,给出分区甲那一格却说是全日客流(回查的值恰好相等,也拦不住)。
另外两条断言:桩一次也没被调用(R7 在打开数据文件之前就拒绝了);对照组 A4 在同一个桩下照常推断、桩被调用过,
证明桩确实接在接口用的那个名字上,不是换了个没人调的函数。"""
from app.data import provenance_db
w = world
key = f"a24_{variant}"
raw, _ = w.sources["flow"].raws[0]
snap = query_snapshot(w.main, key)
assert snap["columns"] == ["日期", "全日客流"] and snap["rows"] == [["2026-08-03", xl(raw, "E6")]]
assert xl(raw, "E5") == xl(raw, "E6") and xl(raw, "E7") == 0 # 陷阱:两列的值恰好相等
calls: list[str] = []
async def lenient(view: Any, sql: str) -> CompileFacts:
calls.append(sql)
return CompileFacts(tables={"日客流"}, result_rows=1, yields=0)
patch_everywhere(monkeypatch, provenance_db, "compile_facts", lenient)
out = await provenance(client, w.main, key)
assert_table_only(out, "multi_table", detail="compound")
assert calls == [] # R7 拒绝在先,没走到 R15
await flow_version(w, out, DAILY)
control = await provenance(client, w.main, "a4")
assert control.status == "inferred" and control.cell_source is not None
assert calls and set(calls) == {query_snapshot(w.main, "a4")["sql"]}
def patch_everywhere(monkeypatch, module: Any, name: str, fake: Any) -> None:
"""把 module.name 换成 fake;接口模块里按名字导入了同一个函数对象的,一并换掉(不依赖 WP-C 的导入写法)。"""
from app.api import evidence as api
real = getattr(module, name)
monkeypatch.setattr(module, name, fake)
for attr, obj in list(vars(api).items()):
if obj is real:
monkeypatch.setattr(api, attr, fake)
async def test_a25_a_recheck_mismatch_forces_a_full_rehash(own, client, monkeypatch):
"""A25:回查不一致时对数据文件强制重算一次整份哈希(R16,不走 EngineCache 的指纹捷径)。用桩让
provenance_db.recheck 返回与查询快照不同的值:
① 数据文件没动:哈希相等 → table_only · recheck_mismatch,不标红(文件确实没变,是 SQL 的含义和判据认定的不同);
② 数据文件原地改过(同样大小)、再用 os.utime 还原修改时间:指纹 (dev, ino, size, mtime_ns) 不变,EngineCache
不会重新核对;强制重算发现哈希不等 → table_only · db_tampered,标红。"""
from app.data import provenance_db
src, run = await own_flow(own, seed=87)
assert (await provenance(client, run, "a4")).status == "inferred"
real = provenance_db.recheck
async def lying(view: Any, *, table: str, column: str, pk: dict[str, Any]) -> Any:
rows, sql, params = await real(view, table=table, column=column, pk=pk)
return [(rowid, value + 1 if isinstance(value, int) else value) for rowid, value in rows], sql, params
patch_everywhere(monkeypatch, provenance_db, "recheck", lying)
out = await provenance(client, run, "a4")
assert_table_only(out, "recheck_mismatch")
# ②:同样大小的合法库原地写回,还原修改时间
path = Path((await snapshot_row(src.snapshot)).db_path)
st = os.stat(path)
other = path.with_name(path.stem + ".edit.db")
shutil.copyfile(path, other)
os.chmod(other, 0o644)
with closing(sqlite3.connect(other)) as conn:
conn.execute('UPDATE "日客流" SET "分区甲" = "分区甲" + 1 WHERE "日期" = \'2026-08-21\'')
conn.commit()
data = other.read_bytes()
other.unlink()
assert len(data) == st.st_size and sha256(data) != sha256(path.read_bytes())
os.chmod(path, 0o644)
with open(path, "r+b") as f:
f.write(data)
f.truncate()
os.chmod(path, 0o444)
os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns))
after = os.stat(path)
assert (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) == \
(st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
out = await provenance(client, run, "a4")
assert_table_only(out, "db_tampered", alert=True)
async def test_a26_a_pre_p4_document_never_touches_a_broken_manifest(own, client, judge_spy):
"""A26:期 4 之前形状的文档 + 坏清单:把表结构快照列出的导入清单文件改坏一个字节,再走按需裁判(接口),送给
裁判的摘录与清单完好时逐字相同:老文档的 `_sealed_loader` chain 为假,根本不去取清单。"""
src, run = await own_flow(own, seed=88, legacy=True)
assert "provenance" not in run.doc
unit, alias = run.units["a4"], run.aliases["a4"]
r = await client.post(f"/api/runs/{run.id}/evidence/judge", json={"units": [unit]})
assert r.status_code == 200, r.text
intact = judge_spy[-1]
assert alias in intact and excerpt_new_lines(intact[alias]) == []
schema = artifact_store.load(query_snapshot(run, "a4")["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)
r = await client.post(f"/api/runs/{run.id}/evidence/judge", json={"units": [unit]})
assert r.status_code == 200, r.text
assert len(judge_spy) == 2 and judge_spy[-1] == intact
# ==========================================================================
# 6.5 性能(RECIPE_PERF=1 时才跑):端到端耗时,经 AsyncClient 调推断来源接口
# ==========================================================================
perf = pytest.mark.skipif(os.environ.get("RECIPE_PERF") != "1",
reason="性能测试默认跳过:RECIPE_PERF=1 时运行(12 期累积、20 万行列表要造一会儿)")
#: 热路径取几次的中位数
PERF_SAMPLES = 7
async def _median_get(c: AsyncClient, url: str) -> float:
took = []
for _ in range(PERF_SAMPLES):
t0 = time.perf_counter()
r = await c.get(url)
took.append(time.perf_counter() - t0)
assert r.status_code == 200, r.text
return sorted(took)[len(took) // 2]
def _median_chain(run: RunInfo, key: str) -> float:
"""清单取回的耗时(2.9 的开销表):每次一个新的工件备忘,resolve_chain 逐份取回清单、复验哈希、核对链。
接口里同样是每次请求一个新的备忘,所以这就是热路径里清单取回那一段。"""
from app.api.evidence import _Artifacts
from app.data.provenance import resolve_chain
query = query_snapshot(run, key)
schema = artifact_store.load(query["schema_artifact"])
took = []
for _ in range(PERF_SAMPLES):
t0 = time.perf_counter()
resolve_chain(query, schema, _Artifacts().load)
took.append(time.perf_counter() - t0)
return sorted(took)[len(took) // 2]
async def _measure(c: AsyncClient, run: RunInfo, key: str, src: Source, label: str, target: float) -> None:
"""首次请求(引擎缓存清空、整份文件哈希未校验)单独记、不设断言;热路径取中位数,超过目标的 1.5 倍算不通过。
同一格的片段接口耗时作为固定开销(_sealed、verify_manifest、报告文档复验)、清单取回的耗时一并打印,拆出占比。"""
seg = run.segs[key]["id"]
url = f"/api/runs/{run.id}/evidence/segments/{seg}/provenance"
await engines.invalidate(src.id)
t0 = time.perf_counter()
first = await c.get(url)
cold = time.perf_counter() - t0
assert first.status_code == 200, first.text
out = from_json(first.json())
assert contract_problems(out) == [] and out.status == "inferred", (out.status, out.reason)
hot = await _median_get(c, url)
fixed = await _median_get(c, f"/api/runs/{run.id}/evidence/segments/{seg}")
chain = _median_chain(run, key)
print(f"\n[perf] {label}:首次 {cold * 1000:.0f} ms;热路径 {hot * 1000:.0f} ms(目标 {target * 1000:.0f} ms);"
f"同一格的片段接口 {fixed * 1000:.0f} ms(固定开销占 {fixed / hot:.0%});"
f"清单取回 {chain * 1000:.1f} ms(占 {chain / hot:.0%})")
assert hot <= target * FAIL_FACTOR, f"{label} 热路径 {hot * 1000:.0f} ms 超过目标的 {FAIL_FACTOR} 倍"
@perf
async def test_perf_d00_single_period(own, client):
src, run = await own_flow(own, seed=91)
await _measure(client, run, "a4", src, "D00 单期", 0.150)
@perf
async def test_perf_two_period_union(own, client):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
src = await first_import(c, *flow_workbook(AUG, 31, seed=92), mode="accumulate")
await add_period(c, src, *flow_workbook(SEP, 30, seed=93))
own.append(src)
run = await run_report({"acc": src}, [Cite("a7", "acc", long_sql("2026-09-12", "8-9"), "客流")])
await _measure(client, run, "a7", src, "8 月加 9 月并集", 0.200)
@perf
async def test_perf_twelve_period_accumulation(own, client):
"""12 期按期累积(flow_workbook 按月造 12 份,逐期导入):清单取回随期数线性增长,这一条盯它。"""
import calendar
import datetime as dt
months = [(dt.date(2025, m, 1), calendar.monthrange(2025, m)[1]) for m in range(1, 13)]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
src = await first_import(c, *flow_workbook(months[0][0], months[0][1], seed=100), mode="accumulate")
for i, (start, days) in enumerate(months[1:], start=1):
await add_period(c, src, *flow_workbook(start, days, seed=100 + i))
own.append(src)
run = await run_report({"acc": src}, [Cite("p12", "acc", wide_sql("2025-12-15"), "全日客流")])
out = await provenance(client, run, "p12")
assert len(out.version.parts) == 12 and out.version.parts[-1].has_row
await _measure(client, run, "p12", src, "12 期按期累积", 0.300)
def _big_list(rows: int = 200_000) -> bytes:
"""20 万行的单期列表(造法同 test_recipe_perf.big_list,多一列唯一的「编号」作主键:没有主键就只给表级来历,
量不到完整的下钻)。只写值、没有公式。"""
import random
from openpyxl import Workbook
rnd = random.Random(7)
wb = Workbook(write_only=True)
ws = wb.create_sheet("明细")
ws.append(["编号", "地区", "渠道", "销量", "金额"])
for i in range(rows):
ws.append([f"N{i:06d}", f"区{i % 7}", "线上" if i % 2 else "线下", rnd.randint(1, 999), rnd.randint(1, 99999)])
buf = io.BytesIO()
wb.save(buf)
return excel_saved(buf.getvalue(), "明细", {})
@perf
async def test_perf_two_hundred_thousand_row_list(own, client):
recipe = {"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": "明细"}, "blocks": [{
"id": "明细", "layout": "list", "table": "销售明细",
"columns": [{"header": h, "name": h, "type": t} for h, t in
(("编号", "TEXT"), ("地区", "TEXT"), ("渠道", "TEXT"), ("销量", "INTEGER"),
("金额", "INTEGER"))]}]}],
"tables": [{"name": "销售明细", "grain": ["编号"]}]}
raw, fn = _big_list(), "明细_20万行.xlsx"
name = unique("perf")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test", timeout=600) as c:
r = await c.post(f"{API}/imports/stage", files={"file": (fn, raw, XLSX)}, data={"name": name})
assert r.status_code == 201, r.text
st = r.json()
r = await c.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": recipe})
assert r.status_code == 200 and r.json()["recipe_problems"] == [], r.text
r = await c.post(f"{API}/imports/{st['id']}/trial", json={})
st = r.json()
assert st["trial"]["status"] == "passed", st["trial"]["problems"][:3]
assert {t["name"]: t["rows"] for t in st["trial"]["receipt"]["tables"]} == {"销售明细": 200_000}
out = await commit(c, st)
src = Source(name=name, id=out["source"]["id"], snapshot=out["snapshot_id"], raws=[(raw, fn)],
imports=[out["import_id"]])
own.append(src)
run = await run_report({"big": src}, [Cite(
"big", "big", 'SELECT "编号", "金额" FROM "销售明细" WHERE "编号" = \'N123456\'', "金额")])
await _measure(client, run, "big", src, "20 万行列表单期", 0.300)