diff --git a/README.md b/README.md index 873e86f1..61ef6c4a 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,8 @@ flowchart LR 要进口径卡。数据源可设 `mask_columns` 遮罩列(只减少暴露,不是安全边界)。 **点开看到什么。** 数字:指标的原式、代入式和复算结果,输入与快照是否一致,查询的 SQL、被引用的行加前后 -各 2 行、高亮到格,最后是封存状态;只认封存范围内的事件交回过的工件。表名、字段名:出现在哪次查询、类型、 +各 2 行、高亮到格,最后是封存状态;只认封存范围内的事件交回过的工件。上传表格的单元格可以追到原表格子 +(推断的来源)。表名、字段名:出现在哪次查询、类型、 所属的表。表结构快照、SQL、结果列里都没有的名字标成可疑实体(「可能是编造的名字」,附最接近的 3 个); 快照被截断、核对不了的只说核对不了,不冤枉成编造。 diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 9085f4c9..3e2fefec 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -31,22 +31,49 @@ import asyncio import contextlib import csv import io +import logging import re +import sqlite3 from collections import OrderedDict from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any +from dataclasses import asdict, dataclass, field, replace +from pathlib import Path +from typing import Any, get_args from fastapi import APIRouter, Body, Query from fastapi.responses import JSONResponse, Response from sqlalchemy import select, update +from sqlalchemy.exc import StatementError from app.api.coded import CodedHTTPException from app.core.artifact_store import load from app.core.events import EventType -from app.data.engine import masked_columns +from app.data import provenance as prov +from app.data import provenance_db +from app.data.engine import SnapshotTampered, engines, masked_columns +from app.data.names import name_key +from app.data.provenance_types import ( + DOC_PROVENANCE, + Alert, + CellRef, + Chain, + ChainProblem, + LocateProblem, + ProvenanceOut, + RawState, + Reason, + Recheck, + RelatedCheck, + ReportRef, + SelectRefusal, + VersionView, + alert, + refuse, +) +from app.data.table_versions import SnapshotManifestMismatch, SnapshotMissing from app.db.base import SessionLocal from app.db.models import DataSource, Run, RunEvent, Workflow +from app.engine import direct_select from app.engine.evidence import ( ENTITY_KINDS, GUESS_NOTE, @@ -76,6 +103,7 @@ from app.engine.judge import FIELDS_MAX, SETTLED, UNSUPPORTED, VERDICTS, candida from app.engine.toolcalls import QUERY_PREFIX router = APIRouter(prefix="/api/runs", tags=["evidence"]) +logger = logging.getLogger(__name__) SCHEMA = "agentlab.evidence/1" @@ -673,20 +701,8 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No 一次运行里有好几份报告时用 ?report=<节点 id> 指定;不指定取成果里标注的那一份。 """ sealed = await _sealed(run_id) - reports = _reports(sealed) - if not reports: - raise CodedHTTPException(404, "本次运行没有封存的报告文档,没有可查看的片段", REPORT_NOT_FOUND) - chosen = _choose(reports, report, sealed) - if chosen.hash_ok is False: - raise CodedHTTPException(409, "报告文档与哈希不一致,疑似被修改,不能作为证据展示", DOC_TAMPERED) - if chosen.doc is None: - raise CodedHTTPException(404, "无法读取报告文档", DOC_MISSING) - found = find_segment(chosen.doc, segment_id) - if found is None: - raise CodedHTTPException(404, "报告中没有这个片段", SEGMENT_NOT_FOUND) - - block, unit, seg = found - doc = chosen.doc + chosen, (block, unit, seg) = _open_segment(sealed, report, segment_id) + doc = chosen.doc or {} markdown = str(doc.get("markdown") or "") start, end = unit.get("span") or [0, 0] chain = await _chain(seg, doc, sealed, chosen.node_id) @@ -712,6 +728,23 @@ async def evidence_segment(run_id: str, segment_id: str, report: str | None = No } +def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str + ) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]: + """选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。""" + reports = _reports(sealed) + if not reports: + raise CodedHTTPException(404, "本次运行没有封存的报告文档,没有可查看的片段", REPORT_NOT_FOUND) + chosen = _choose(reports, wanted, sealed) + if chosen.hash_ok is False: + raise CodedHTTPException(409, "报告文档与哈希不一致,疑似被修改,不能作为证据展示", DOC_TAMPERED) + if chosen.doc is None: + raise CodedHTTPException(404, "无法读取报告文档", DOC_MISSING) + found = find_segment(chosen.doc, segment_id) + if found is None: + raise CodedHTTPException(404, "报告中没有这个片段", SEGMENT_NOT_FOUND) + return chosen, found + + def _choose(reports: list[_Report], wanted: str | None, sealed: _Sealed) -> _Report: if wanted: chosen = next((r for r in reports if r.node_id == wanted), None) @@ -743,8 +776,10 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed, return [] if cite.get("kind") == "cell": locator = cite.get("locator") or {} + # 只有直接引用的单元格才可能推断来源(P4-SPEC 2.8.2)。指标链里的查询步骤不带:指标片段的推断来源只会 + # 回「不是单元格」,带上提示等于让界面白发一次请求 return [await _query_step(str(entry.get("artifact") or ""), [(locator.get("row"), locator.get("column"))], - doc, sealed, masks)] + doc, sealed, masks, hint=doc.get("provenance") == DOC_PROVENANCE)] if cite.get("kind") == "input": name = (cite.get("locator") or {}).get("field") inputs = _input_payloads(sealed) @@ -857,11 +892,15 @@ class _Masks: async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str, Any], sealed: _Sealed, - masks: _Masks) -> dict[str, Any]: + masks: _Masks, *, hint: bool = False) -> dict[str, Any]: """查询步骤:被引用的格所在的行加前后各 WINDOW 行,高亮那几格,遮掉数据源设了遮罩的列。 快照只认封存范围内的事件交回过的那些(tool.end.query_artifact、node.finished.evidence 的 query 条目);目录里写着、事件里追不到的,照实说不认,一行都不给。 + + hint(期 4,P4-SPEC 2.8.2):调用方是带文档标记的单元格片段时为真。这时快照若是上传表格的(有 data_version), + 步骤多一个 `provenance: true`,界面据此才去请求推断来源接口;其余情况一个键都不加,形状和期 4 之前完全相同 + (期 4 之前的文档、手工源、指标链都不多发请求)。不看封存状态:未封存的运行照常推断,响应里照实标明。 """ catalog = doc.get("catalog") or {} alias = _query_alias(catalog, artifact) @@ -905,7 +944,8 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str step.update(sql=snap.get("sql"), columns=columns, total_rows=len(rows), truncated=bool(snap.get("truncated")), masked=[columns[i] for i in sorted(masked)], # 查询时从驱动的原始值记下的列类型(老快照没有):文本列里的 "2026" 按文本显示,不当数 - column_types=snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}) + column_types=snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}, + **({"provenance": True} if hint and snap.get("data_version") else {})) if len(index) > MAX_WINDOW_ROWS: index, step["window_truncated"] = index[:MAX_WINDOW_ROWS], True step["row_index"] = index @@ -915,6 +955,395 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str return step +# -------------------------------------------------------------------------- +# GET /api/runs/{run_id}/evidence/segments/{segment_id}/provenance(期 4:推断的来源) +# -------------------------------------------------------------------------- + + +@router.get("/{run_id}/evidence/segments/{segment_id}/provenance") +async def evidence_provenance(run_id: str, segment_id: str, report: str | None = None) -> dict[str, Any]: + """点开报告里一个直接引用的查询单元格时,这一格来自原表的哪个工作表、哪一格(P4-SPEC 2.8.1)。 + + 选报告、错误码和片段接口相同(run_not_found、evidence_report_not_found、evidence_doc_tampered、 + evidence_doc_missing、evidence_segment_not_found)。其余情况一律 200:推断不出来不是错误,结论写在 + status、reason、alert 里。按需算、不缓存(2.9):原件是否清除、数据文件是否被改都是当前状态,事后会变。 + + 返回 `asdict(ProvenanceOut)`:CellSource 的「from」在 Python 里叫 from_,asdict 已经换回「from」;返回注解 + 写 dict,不写 ProvenanceOut,和其余证据路由一样不让框架按类型再校验、再序列化一遍(provenance_types 模块说明)。 + """ + sealed = await _sealed(run_id) + chosen, (_block, _unit, seg) = _open_segment(sealed, report, segment_id) + out = await segment_provenance(chosen, seg, sealed, masks=_Masks()) + return asdict(out) + + +#: R14 之后(绑定快照、取引擎、编译核对、回查、行级核对)会抛的三类异常,按 _snapshot_failure 的顺序映射 +_SNAPSHOT_ERRORS: tuple[type[BaseException], ...] = (SnapshotMissing, SnapshotTampered) + + +def _snapshot_failure(exc: BaseException) -> tuple[Reason, Alert | None]: + """R14 之后抛出的数据文件异常 → (原因, 标红提示)(P4-SPEC 2.3 的异常映射)。 + + **顺序是守卫点**:SnapshotManifestMismatch 同时继承 SnapshotMissing 和引擎的 SnapshotTampered + (table_versions.py 的定义),必须最先认,否则会被当成文件被改(db_tampered)或文件不在(snapshot_gone): + 1. SnapshotManifestMismatch:登记的哈希与快照清单对不上 → chain_mismatch,标红; + 2. SnapshotTampered(含借连接时指纹不符):数据文件与登记的版本不一致 → db_tampered,标红; + 3. SnapshotMissing:绑定时文件没了、记录不全 → snapshot_gone,不标红(被运行引用的版本受保护,出现多半是 + 数据源被删或手工清理)。 + 三种都是 table_only:数据版本一节只靠清单,照样给;不给格子,已经算出的行级状态一律丢掉(调用方负责)。 + 不是这三类的异常原样抛出:不该被当成「推断不出来」吞掉。 + """ + if isinstance(exc, SnapshotManifestMismatch): + return refuse("chain_mismatch"), alert("chain_mismatch") + if isinstance(exc, SnapshotTampered): + return refuse("db_tampered"), alert("db_tampered") + if isinstance(exc, SnapshotMissing): + return refuse("snapshot_gone"), None + raise exc + + +class _Artifacts: + """一次请求里取过的工件(P4-SPEC 2.9「同一工件只取一次」)。取回的结果按 _fetch 的口径记 (内容, hash_ok)。 + + - `await get(id)`:在线程里取(快照、清单可能几十 KB 到几 MB,读文件、复验哈希不该卡事件循环,H9); + - `load(id)`:同步取,语义同 artifact_store.load(取不到 None,哈希不符抛 ValueError),给已经在线程里跑的 + 纯函数当 loader 用(WP-B 的 resolve_chain 等)。两条路共用同一份备忘。 + """ + + def __init__(self) -> None: + self._memo: dict[str, tuple[Any, bool | None]] = {} + + async def get(self, artifact: str) -> tuple[Any, bool | None]: + key = str(artifact or "") + if not key: + return None, None + if key not in self._memo: + self._memo[key] = await asyncio.to_thread(_fetch, key) + return self._memo[key] + + def load(self, artifact: str) -> Any: + key = str(artifact or "") + if not key: + return None + if key not in self._memo: + self._memo[key] = _fetch(key) + content, ok = self._memo[key] + if ok is False: + raise ValueError(f"工件 {key[:12]} 与哈希不一致") + return content + + +def _cell_ref(cite: dict[str, Any], entry: dict[str, Any]) -> CellRef | None: + """片段本身是单元格引用(有 alias 和 row、column 齐全的定位)就给,与 status 无关(2.8.1「字段取值」)。""" + locator = cite.get("locator") or {} + row, column = locator.get("row"), locator.get("column") + if cite.get("kind") != "cell" or not cite.get("alias") or not isinstance(row, int) or isinstance(row, bool) \ + or not isinstance(column, str) or not column: + return None + artifact = entry.get("artifact") + return CellRef(alias=str(cite["alias"]), row=row, column=column, artifact=str(artifact) if artifact else None) + + +async def segment_provenance(report: _Report, seg: dict[str, Any], sealed: _Sealed, *, + masks: _Masks) -> ProvenanceOut: + """一个片段的推断来源:按 P4-SPEC 2.3 的规则表顺序判断,第一条不满足的就是结论。 + + **封存状态不进规则**(1.3 调整 11):未封存(停在审批上)、封存核对不通过的运行照常判断,`sealed` 照实给 + sealed.trusted,界面另加一句。查询快照、表结构快照按「本次运行的事件交回过」认(sealed.queries、 + sealed.schemas,_sealed 对未封存的运行按全部事件拼),不经 _sealed_loader:它在 trusted 为假时一律不给, + 会把未封存的运行误判成清单无法读取(2.8.3)。 + + 这里判 R0–R4(只看文档、目录和两份快照),R5 起交给 _drill:清单链(provenance)、SQL 判据(direct_select)、 + 遮罩、空值、数据文件(provenance_db)、找回格子和相关核对。 + """ + doc = report.doc or {} + cite = seg.get("cite") or {} + entry = (doc.get("catalog") or {}).get(cite.get("alias")) or {} + base: dict[str, Any] = { + "report": ReportRef(node_id=report.node_id, doc_artifact=report.doc_artifact), + "segment": str(seg.get("id") or ""), "cell": _cell_ref(cite, entry), "sealed": sealed.trusted, + } + + def none(code: Any) -> ProvenanceOut: + return ProvenanceOut(status="none", reason=refuse(code), **base) + + # R0:期 4 之前组装的文档(没有标记)一律不推断,期 4 之前的面板不变(1.4) + if doc.get("provenance") != DOC_PROVENANCE: + return none("legacy_doc") + # R1:只对直接引用的查询单元格下钻;代码节点、Agent 字段的取数(工具名不是 db_query__ 开头)不算 + if (cite.get("kind") != "cell" or cite.get("status") != "resolved" or seg.get("state") != "deterministic" + or entry.get("kind") != "query" or not str(entry.get("tool") or "").startswith(QUERY_PREFIX)): + return none("not_cell") + artifacts = _Artifacts() + # R2:查询快照是本次运行的事件交回过的,取回时哈希复验通过 + artifact = str(entry.get("artifact") or "") + query, ok = await artifacts.get(artifact) if artifact in sealed.queries else (None, None) + if ok is not True or not isinstance(query, dict): + return none("not_sealed") + # R3:上传表格的查询快照才有 data_version(datasource.py 只给上传源写) + if not query.get("data_version"): + return none("not_upload") + # R4:表结构快照同样要是本次运行交回过、复验通过的;不是按配方导入的(简单导入、v0 迁移出的版本)没有溯源 + schema_id = str(query.get("schema_artifact") or "") + schema, ok = await artifacts.get(schema_id) if schema_id in sealed.schemas else (None, None) + if ok is not True or not isinstance(schema, dict): + return none("not_sealed") + if schema.get("import_mode") != "recipe": + return none("simple_upload") + return await _drill(base, seg=seg, entry=entry, query=query, schema=schema, artifacts=artifacts, + masks=masks, sealed=sealed) + + +async def _drill(base: dict[str, Any], *, seg: dict[str, Any], entry: dict[str, Any], query: dict[str, Any], + schema: dict[str, Any], artifacts: _Artifacts, masks: _Masks, sealed: _Sealed) -> ProvenanceOut: + """R5–R17(P4-SPEC 2.3):第一条不满足的就是结论。 + + - R5、R6 只看内容寻址的工件(resolve_chain,放线程:要逐份取回清单、复验哈希)。链断了就什么来历都不给: + status none、version 为 null,标红; + - 之后的每一条都照样给数据版本(它只靠清单),不给格子:R7–R11 只看查询快照和冻结表结构,R12、R13 对照数据库 + 里的数据文件记录,R14 起打开数据文件。R14 之后任何一步抛出的数据文件异常按 _snapshot_failure 的顺序映射, + 已经算出的格子、行级状态一律丢掉(2.3 末尾); + - 全部满足才给格子(inferred)。 + """ + # R5(取不回、哈希不符)、R6(链本身对不上):resolve_chain 不往外抛,结论在 ChainProblem 里 + chain = await asyncio.to_thread(prov.resolve_chain, query, schema, artifacts.load) + if isinstance(chain, ChainProblem): + return ProvenanceOut(status="none", reason=refuse(chain.code, chain.detail), alert=alert(chain.code), **base) + + frozen = _frozen_tables(schema) + tables = {name: _frozen_columns(meta) for name, meta in frozen.items()} + sql = str(query.get("sql") or "") + # 遮罩 = 数据源现在设的 + 查询当时记下的(同 _query_step 的口径:数据源改名、删掉了也照遮) + source_name = (sealed.queries.get(str(entry.get("artifact") or "")) or {}).get("source") \ + or entry.get("source") or query.get("source") + current, _found = await masks.of(source_name) + hidden = current | _recorded_masks(query) + async with SessionLocal() as session: + states = await provenance_db.import_states(session, [p.import_id for p in chain.parts]) + record = await provenance_db.snapshot_record(session, chain.snapshot_id) + # 版本页同一个口径的表:SQL 里出现的冻结表结构的表(tables_in 宁可多认,2.4) + version = _version_of(chain, direct_select.tables_in(sql, tables), states, manifest_view=not hidden) + + def table_only(reason: Reason, red: Alert | None = None) -> ProvenanceOut: + return ProvenanceOut(status="table_only", reason=reason, alert=red, version=version, **base) + + # R7:受限文法内的直接选取(识别器和执行这条 SQL 的 SQLite 读同一串记号)。传的是快照里原样存下的 SQL + columns = [str(c) for c in query.get("columns") or []] + picked = direct_select.recognize(sql, result_columns=columns, tables=tables) + if isinstance(picked, SelectRefusal): + return table_only(refuse(picked.code, picked.detail)) + # R8:被引用的结果列按第一次出现的位置对应选取项(locate_cell 也按第一次出现取)。识别器已拒绝重名, + # 这里对不上只是防御 + locator = (seg.get("cite") or {}).get("locator") or {} + row, column = locator.get("row"), locator.get("column") + if not isinstance(column, str) or column not in columns or columns.index(column) >= len(picked.columns): + return table_only(refuse("unparsed")) + at = columns.index(column) + target = picked.columns[at] + # R9:表有主键,主键各列都以直接引用出现在结果里;结果各行的主键两两不同(与解析无关的兜底:同一张表经集合 + # 运算拼出来的结果可能重复主键) + meta = frozen.get(picked.table) + pk_names = [str(p) for p in (meta.get("primary_key") or [])] if isinstance(meta, dict) else [] + if not pk_names: + return table_only(refuse("no_pk")) + where = {name_key(c): j for j, c in enumerate(picked.columns)} + missing = [p for p in pk_names if name_key(p) not in where] + if missing: + return table_only(refuse("pk_missing", 列=missing)) + rows = query.get("rows") + if not isinstance(rows, list) or not all(isinstance(r, list) and len(r) == len(columns) for r in rows): + return table_only(refuse("unparsed")) + pk_at = [where[name_key(p)] for p in pk_names] + if _repeats([tuple(r[j] for j in pk_at) for r in rows]): + return table_only(refuse("multi_table", "duplicate_pk")) + # R10:被引用的列、主键列、推断要用到的键列(维度、派生、常量:行标签原文、分段标题的定位文字都是它们的值) + # 都不在遮罩里 + needed = [column, target, *pk_names, *_key_columns(chain, picked.table)] + if any(str(n).lower() in hidden for n in needed): + return table_only(refuse("masked")) + # R11:被引用的值、主键各值都不是空值 + if isinstance(row, bool) or not isinstance(row, int) or not 0 <= row < len(rows): + return table_only(refuse("unparsed")) + value = rows[row][at] + if value is None: + return table_only(refuse("null_value")) + pk = {p: rows[row][j] for p, j in zip(pk_names, pk_at)} + if any(v is None for v in pk.values()): + return table_only(refuse("null_pk")) + # R12:数据文件记录在、没回收、文件在(不标红:被运行引用的版本受保护,出现多半是数据源被删或手工清理) + if record is None or record.retired_at is not None or not record.db_path or not Path(record.db_path).is_file(): + return table_only(refuse("snapshot_gone")) + # R13:登记哈希等于清单里期望的库哈希、source_id 一致(防「数据文件和登记哈希被一起改成一致的假值」: + # 清单是内容寻址的,又挂在封存的表结构快照下面,改不了)。登记哈希为空也算不等。 + # Reason.detail 不带细分:契约只给 DETAILS 和 ChainProblem / LocateProblem 的细分留了位置,R13 规格只写 + # chain_mismatch,和异常映射(SnapshotManifestMismatch)同一个口径;哪一项对不上写进日志 + registered = str(record.db_sha256 or "").strip().lower() + if not registered or registered != chain.expected_db_sha256.strip().lower(): + logger.warning("provenance: 快照 %s 的登记哈希与导入清单里的库哈希不一致", chain.snapshot_id) + return table_only(refuse("chain_mismatch"), alert("chain_mismatch")) + if str(record.source_id) != chain.source_id: + logger.warning("provenance: 快照 %s 的 source_id 与导入清单不一致", chain.snapshot_id) + return table_only(refuse("chain_mismatch"), alert("chain_mismatch")) + try: + return await _open_and_locate(base, table_only, chain=chain, record=record, version=version, + states=states, hidden=hidden, sql=sql, table=picked.table, target=target, + value=value, pk=pk) + except _SNAPSHOT_ERRORS as exc: + # R14 之后(绑定、取引擎、编译核对、回查、行级核对)任何一步:不给格子,已算出的行级状态一律丢掉 + reason, red = _snapshot_failure(exc) + return table_only(reason, red) + + +async def _open_and_locate(base: dict[str, Any], table_only: Callable[..., ProvenanceOut], *, chain: Chain, + record: Any, version: VersionView, states: dict[str, dict[str, Any]], hidden: set[str], + sql: str, table: str, target: str, value: Any, pk: dict[str, Any]) -> ProvenanceOut: + """R14–R17 和行级核对:要打开数据文件的几步。三类数据文件异常原样往外抛,由 _drill 统一映射。""" + # R14:绑定快照(累积快照首次绑定时和快照清单交叉核对),经 EngineCache 打开数据文件(只读、immutable,本进程 + # 首次整份核对哈希)。之后的回查、行级核对都经同一个缓存键,读的就是查询读过的那个文件 + view = provenance_db.view_of(record, source_name=chain.source) + await engines.get(view) + # R15:第二道核对,与解析无关。读到的表恰好只有识别器认出的那一张;字节码里 ResultRow 恰好 1 个、没有 Yield + # (UNION ALL 有多个 ResultRow,UNION、INTERSECT、EXCEPT 有 Yield)。编译不了(SQLite 自己报错)同样不下钻 + try: + facts = await provenance_db.compile_facts(view, sql) + except sqlite3.Error: + logger.warning("provenance: 编译核对失败,只给表级来历", exc_info=True) + return table_only(refuse("unparsed")) + if {name_key(t) for t in facts.tables} != {name_key(table)}: + return table_only(refuse("multi_table", "authorizer")) + if facts.result_rows != 1 or facts.yields != 0: + return table_only(refuse("multi_table", "compound")) + # R16:按主键参数化回查,恰好一行,值和快照里那一格类型、值都相等 + try: + found, recheck_sql, params = await provenance_db.recheck(view, table=table, column=target, pk=pk) + except (sqlite3.Error, StatementError) as e: + _reraise_snapshot_error(e) + # 细分同样不进 Reason.detail(契约外的值),回查出错和回查没找到行给同一个原因,区别只在日志 + logger.warning("provenance: 回查失败,只给表级来历", exc_info=True) + return table_only(refuse("recheck_missing")) + if not found: + return table_only(refuse("recheck_missing")) + if len(found) > 1: + return table_only(refuse("recheck_multiple")) + rowid, now = found[0] + if not _same_cell(now, value): + # 值对不上先强制整份重算一次哈希(不走 EngineCache 的指纹捷径:原地改写同样字节数、再还原修改时间的改动, + # 指纹认不出,P4-SPEC 2.6)。文件确实变了 → 标红;没变 → 这条 SQL 的含义和判据认定的不同,不标红 + if not await provenance_db.rehash(view): + return table_only(refuse("db_tampered"), alert("db_tampered")) + return table_only(refuse("recheck_mismatch")) + # R17:从 (表, 列, rowid) 找回格子(并集 rowid 先换算到某一期)。纯函数,但要解析配方、还原回执,放线程 + located = await asyncio.to_thread(prov.locate, chain, table=table, column=target, rowid=rowid) + if isinstance(located, LocateProblem): + if located.code == "chain_mismatch": + return table_only(refuse("chain_mismatch", located.detail), alert("chain_mismatch")) + return table_only(refuse("no_lineage", located.detail)) + # 附带的格写出的是哪几列的值(R10 已按回执里的键列判过,这里按实际给出的再核一次,防回执和推断不一致) + if any(str(f.column).lower() in hidden for f in located.cell.from_ if f.column): + return table_only(refuse("masked")) + plans = await asyncio.to_thread(prov.related_checks, chain, located, table=table, column=target) + checks: list[RelatedCheck] = [] + for plan in plans: + if plan.rule is None: + checks.append(plan.check) + continue + # 行级状态按**快照库**的 rowid 跑(并集时是并集 rowid),条件和导入时的核对是同一段代码生成的 + try: + status = await provenance_db.row_status(view, plan.rule, rowid) + except (sqlite3.Error, StatementError) as e: + _reraise_snapshot_error(e) + logger.warning("provenance: 行级核对失败,这一行不给结论", exc_info=True) + status = None + checks.append(replace(plan.check, row_status=status)) + state = states.get(located.part.import_id) or {} + cell = replace(located.cell, rowid=rowid, pk=pk, raw_purged=state.get("raw_state") == "purged", + recheck=Recheck(sql=recheck_sql, params=list(params), ok=True)) + version = replace(version, parts=[replace(p, has_row=p.import_id == located.part.import_id) + for p in version.parts]) + return ProvenanceOut(status="inferred", version=version, cell_source=cell, checks=checks, **base) + + +def _reraise_snapshot_error(exc: BaseException) -> None: + """SQLAlchemy 把连接池事件里抛出的异常包成 StatementError 时,数据文件的三类异常要原样交给 _drill 映射, + 不能被当成「这条 SQL 执行失败」吞掉(借连接时指纹不符抛的 SnapshotTampered 就在这一路上)。""" + orig = getattr(exc, "orig", None) + if isinstance(orig, _SNAPSHOT_ERRORS): + raise orig from exc + + +def _frozen_tables(schema: dict[str, Any]) -> dict[str, Any]: + tables = schema.get("tables") if isinstance(schema, dict) else None + return tables if isinstance(tables, dict) else {} + + +def _frozen_columns(meta: Any) -> list[str]: + """冻结表结构里一张表的列名,按定义顺序(`SELECT *` 要按这个顺序比对结果列)。""" + cols = meta.get("columns") if isinstance(meta, dict) else None + return [str(c["name"]) for c in cols or [] if isinstance(c, dict) and c.get("name") is not None] + + +def _recorded_masks(query: dict[str, Any]) -> set[str]: + recorded = query.get("mask_columns") + return {c.lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set() + + +#: 推断要用到的键列(回执 tables[].columns[].role):维度列的行标签原文、派生列由行标签解析、常量列取分段标题的 +#: 定位文字,展示出来就是这几列的值(R10) +_KEY_COLUMN_ROLES = frozenset({"dim", "derive", "const"}) + + +def _key_columns(chain: Chain, table: str) -> list[str]: + """各期回执里这张表的键列(维度、派生、常量)。取全部各期的并集:R10 在知道格子落在哪一期之前判,宁可多遮。""" + out: list[str] = [] + for part in chain.parts: + receipt = part.manifest.get("receipt") if isinstance(part.manifest.get("receipt"), dict) else {} + for t in receipt.get("tables") or []: + if not isinstance(t, dict) or t.get("name") != table: + continue + out += [str(c["name"]) for c in t.get("columns") or [] + if isinstance(c, dict) and c.get("role") in _KEY_COLUMN_ROLES and c.get("name")] + return list(dict.fromkeys(out)) + + +def _repeats(keys: list[tuple[Any, ...]]) -> bool: + """主键元组有没有重复。按 Python 的相等比(5 和 5.0 算同一个):宁可多判重复、少下钻。""" + seen: set[Any] = set() + for key in keys: + try: + marker: Any = key + hash(marker) + except TypeError: + marker = repr(key) + if marker in seen: + return True + seen.add(marker) + return False + + +def _same_cell(now: Any, then: Any) -> bool: + """回查的值和快照里那一格:类型和值都相等(int、float、str 分开比,8754 和 8754.0 不算相等;bool 不算数)。 + + 两边都经 engine._jsonable 同一口径规范化过(快照还经过一次 JSON 存取,int、float、str 原样回来),类型不同 + 只可能是文件或 SQL 的含义变了,不能按数值宽松地放过。""" + if isinstance(now, bool) or isinstance(then, bool): + return False + return type(now) is type(then) and now == then + + +def _version_of(chain: Chain, tables: list[str], states: dict[str, dict[str, Any]], *, + manifest_view: bool) -> VersionView: + """数据版本:清单里的部分由 provenance.version_view 给(内容寻址),原件状态、清除、作废、配方第几版取自导入 + 记录(当前状态,界面标「当前状态」)。manifest_view:数据源设有任何遮罩时不在面板里出「查看导入清单」。""" + view = prov.version_view(chain, tables=tables) + parts = [] + for p in view.parts: + state = states.get(p.import_id) or {} + raw = state.get("raw_state") + parts.append(replace(p, raw_state=raw if raw in get_args(RawState) else None, purged=state.get("purged"), + revoked=state.get("revoked"), recipe_seq=state.get("recipe_seq"))) + return replace(view, parts=parts, manifest_view=manifest_view) + + # -------------------------------------------------------------------------- # 表名、字段名、原话 # -------------------------------------------------------------------------- @@ -1340,17 +1769,56 @@ def _stale(verdict: Any) -> bool: return isinstance(verdict, dict) and bool(verdict.get("post_seal")) and outdated(verdict) -def _sealed_loader(sealed: _Sealed) -> Callable[[str], Any]: +def _sealed_loader(sealed: _Sealed, *, chain: bool = False) -> Callable[[str], Any]: """给裁判取证据的 loader:只认封存范围内的事件交回过的工件(查询、表结构、检索快照和台账里记过的), 取回时照旧复验哈希。别的一律当取不到——人为插进工件表的行、封存之后才追加的事件引用的快照, - 都不能送进裁判的摘录。报告文档里的目录也不例外:目录写着哪件工件,不等于它在封存链上。""" + 都不能送进裁判的摘录。报告文档里的目录也不例外:目录写着哪件工件,不等于它在封存链上。 + + chain(期 4,P4-SPEC 2.8.3):只有带文档标记的文档传真。这时另外认封存范围内的表结构快照经哈希链列出的 + 导入清单、快照清单(以及快照清单各期的导入清单):这些 id 写在内容寻址、又在封存范围内的表结构快照里,属于 + 封存链的延伸,不是数据库列。扩展集合**惰性**计算:第一次被要一件不在基本范围里的工件时才算,同一个 loader + 只算一次(一次请求一个 loader)。chain 为假时和期 4 之前完全相同,期 4 之前的文档不会因为任何一份清单出错。 + """ allowed = {*sealed.queries, *sealed.schemas, *sealed.retrievals, *(str(a) for _, a in sealed.ledger)} + extended: set[str] | None = None def loader(artifact: str) -> Any: - return load(artifact) if sealed.trusted and str(artifact) in allowed else None + nonlocal extended + key = str(artifact) + if not sealed.trusted: + return None + if key in allowed: + return load(key) + if not chain: + return None + if extended is None: + extended = _chain_set(sealed) + return load(key) if key in extended else None return loader +def _chain_set(sealed: _Sealed) -> set[str]: + """封存范围内每份表结构快照经哈希链列出的清单 id 的并集。表结构快照取不回、哈希不符的跳过(不在链上)。""" + out: set[str] = set() + for schema_id in sealed.schemas: + try: + schema = load(schema_id) + except ValueError: + continue + if isinstance(schema, dict): + out |= _chain_artifacts(schema, load) + return out + + +def _chain_artifacts(schema: dict[str, Any], loader: Callable[[str], Any]) -> set[str]: + """一份表结构快照经哈希链列出的清单 id:provenance.chain_artifacts(永不抛异常,坏清单视为不在链上)。 + + 按名字现取(不用模块顶部绑定的 prov):测试可以在 sys.modules 里换掉这个模块,核对这里确实委托给它。""" + from app.data.provenance import chain_artifacts + + return set(chain_artifacts(schema, loader)) + + def _node_judge(sealed: _Sealed, node_id: str) -> dict[str, Any]: """这次运行的图上报告节点写的 judge(裁判模型、句数和时长上限);图读不出来就当没写。""" from app.engine.nodes.report import report_judge @@ -1450,7 +1918,8 @@ async def _judge_now(sealed: _Sealed, report: _Report, units: list[str]) -> dict event = None lost = False if pending: - loader = _sealed_loader(sealed) + # 带期 4 标记的文档才让裁判取得到哈希链上的清单(摘录的来历行要用);老文档的 loader 和以前完全相同 + loader = _sealed_loader(sealed, chain=doc.get("provenance") == DOC_PROVENANCE) masked = await judging.source_masks(catalog, loader=loader) request = judging.prepare(doc, catalog, units=pending, loader=loader, masked=masked) skipped = dict(request.skipped) diff --git a/backend/app/data/engine.py b/backend/app/data/engine.py index 925166eb..f8109720 100644 --- a/backend/app/data/engine.py +++ b/backend/app/data/engine.py @@ -249,7 +249,7 @@ def sqlite_dqs_self_check() -> None: raise RuntimeError(f"SQLite 自检失败:无法确认写错的双引号列名会报错,服务不启动。{problem}") -def open_checked_sqlite(path: str, *, readonly: bool = True) -> sqlite3.Connection: +def open_checked_sqlite(path: str, *, readonly: bool = True, immutable: bool = False) -> sqlite3.Connection: """同步打开一个本地 SQLite 文件,DQS 已关(配方导入的建库、核对用)。 配方执行器建临时库、核对模块跑 SQL 都不经 SQLAlchemy,直接用 sqlite3。它们同样要关 DQS: @@ -261,11 +261,20 @@ def open_checked_sqlite(path: str, *, readonly: bool = True) -> sqlite3.Connecti readonly=False 以 mode=rwc 打开(执行器建库)。不设日志模式:沿用 SQLite 默认的回滚日志, 不开 WAL——WAL 会改写文件头,库文件哈希就对不上了(P2-SPEC 4.2 第 9 步)。 路径按 URI 转义:文件名里的空格、中文、「?」「#」都不会被当成 URI 的参数或片段。 + + immutable=True 另加 `immutable=1`,和 EngineCache 打开版本快照的方式一致(_sqlite_url):证据下钻在快照文件上 + 单开一个连接编译原 SQL(provenance_db.compile_facts,P4-SPEC 2.9),读的必须是查询走的同一种打开方式。 + 只许和 readonly 一起用:immutable 告诉 SQLite 文件不会变、不加锁,可写的连接这样开会写坏库。缺省 False, + 现有调用方的行为不变。 """ from urllib.parse import quote + if immutable and not readonly: + raise ValueError("immutable 只能用于只读连接") mode = "ro" if readonly else "rwc" - return sqlite3.connect(f"file:{quote(os.fspath(path))}?mode={mode}", uri=True, factory=_DqsOffConnection) + extra = "&immutable=1" if immutable else "" + return sqlite3.connect(f"file:{quote(os.fspath(path))}?mode={mode}{extra}", uri=True, + factory=_DqsOffConnection) def _sqlite_extra() -> dict[str, Any]: @@ -569,6 +578,17 @@ class EngineCache: # 正被借出的连接不受影响:dispose 只关池子里空闲的,借出的还回来时随旧池子回收 await old.dispose() + def pinned(self, source: Any) -> Fingerprint | None: + """这个源(版本快照)的引擎核对过的文件指纹;不是快照引擎、或者引擎不在缓存里时为 None。 + + 给证据下钻的编译核对用(provenance_db.compile_facts,P4-SPEC 2.9):它在同一个文件上单开一个连接, + 开连接前、关连接后各拿当时的 stat 和这份指纹比,比得上才说明单开的连接读的是 get() 核对过的那个文件。 + 调用方要在 `await get(source)` 返回之后**同步**调它,中间不能有 await:否则这个键可能已经被 LRU 挤掉, + 或者被别的请求摘掉重核。只读不改:已经标成不可信(stale)的也照样返回那份指纹,调用方比对时自然对不上。 + """ + pin = self._pins.get(cache_key(source)) + return pin.fingerprint if pin is not None else None + async def _verify_once(self, key: str, source: Any, expected: str) -> Fingerprint: """核对快照哈希;同一个键已经有一次在核对,就等那一次的结果。 diff --git a/backend/app/data/provenance.py b/backend/app/data/provenance.py new file mode 100644 index 00000000..40d53901 --- /dev/null +++ b/backend/app/data/provenance.py @@ -0,0 +1,1159 @@ +"""证据下钻(期 4「推断的来源」)的清单与格子:WP-B(P4-SPEC 2.2、2.5、2.7、3.2、7.3)。 + +只做纯函数:输入是已经取回的查询快照、冻结的表结构快照,以及一个按内容哈希取工件的 loader(语义同 +artifact_store.load:取不到返回 None,哈希不符抛 ValueError)。不连库、不读原件、不看当前时间,所以: +- 推断来源接口(WP-C)拿它算「这一格来自原表的哪一格」,回查数据文件、补当前状态都在接口那一侧; +- 裁判摘录拿 judge_lines 生成来历行,同样的工件永远得到同样的字(摘录进断点指纹,P4-SPEC 0.2 补充 3)。 + +**为什么一律沿内容哈希走**(0.2 补充 2):查询快照 → 冻结的表结构快照(import_manifests、snapshot_manifest)→ +导入清单,每一跳都是内容寻址的工件,挂在封存范围内的表结构快照下面,改不了。数据库里的 manifest_artifact 列可以 +被改,这里一次都不读。 + +**为什么宁可不下钻也不猜**(2.5):面板上给出的格子是让人去原表对照的,指错了比不指更糟。所以凡是要「猜」的地方 +(锚格不在任何值区域里、同一块匹配到不止一条轴、期 2 的清单在一张工作表上有两块、常量列找不到分段标题……)一律判 +no_lineage,只给表级来历。 + +不 import app.api、app.engine.judge、app.engine.direct_select(7.3):SQL 判据由调用方算好传进来(tables), +这里不依赖识别器。 +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from app.data.names import name_key +from app.data.provenance_types import ( + PROV_ACCEPT, + PROV_CHARS, + PROV_COLUMN_NOTES, + PROV_OUTSIDE, + PROV_OUTSIDE_CHARS, + PROV_PARTS, + AcceptanceView, + CanonicalView, + CellSource, + Chain, + ChainPart, + ChainProblem, + FromCell, + Loader, + Located, + LocateProblem, + PartView, + PeriodView, + RelatedCheck, + RelatedCheckPlan, + SumEqRule, + TableRef, + VersionView, + YearSource, +) +from app.data.recipe_checks import col_letters, lineage_cell, plan_checks, split_coord +from app.data.recipe_types import ( + CrosstabBlock, + ListBlock, + NotComparable, + Recipe, + SumEq, +) + +_log = logging.getLogger(__name__) + +__all__ = ["chain_artifacts", "resolve_chain", "version_view", "locate", "related_checks", "judge_lines"] + +#: 锚格所在的区域:值格、合计值格(交叉表合计行、列表合计行)。锚格的块取包含它的这类区域的 block +_VALUE_ROLES = frozenset({"value", "derived_value", "total_value"}) +#: 行标签一类的区域:行标签、交叉表合计行的标签(回执里是 derived_label)、列表合计行的标签 +_LABEL_ROLES = frozenset({"row_label", "derived_label", "total_label"}) +#: 区域 role → FromCell.role(P4-SPEC 2.5 第 3 步,写死):derived_label、total_label 都是合计标签 +_FROM_LABEL = {"row_label": "row_label", "derived_label": "total_label", "total_label": "total_label"} +#: 块内认领的去向(同 recipe_engine._BLOCK_ROLES)。期 2 的清单 regions 没有 block 键,算 S9 的区域时按它认块内区域 +_BLOCK_ROLES = frozenset({"value", "derived_value", "derived_label", "col_header", "row_label", "section_title", + "ignored_column", "hidden_excluded", "total_label", "total_value"}) +#: 交叉表的键列(本来就没有溯源段):锚格回退到同一行第一个有溯源段的列。measure / value / text 不回退: +#: 回退取到的是另一列的格子(P4-SPEC 2.5 第 1 步、评审甲 8) +_KEY_ROLES = frozenset({"axis", "dim", "derive", "const"}) +_VALUE_COLUMN_ROLES = frozenset({"measure", "value", "text"}) +#: K / G / T 的核对种类:格级状态只给它们 +_TOTAL_KINDS = frozenset({"derived_sum", "formula_refs", "column_sum"}) + +_MODE_LABEL = {"replace": "每期替换", "accumulate": "按期累积"} + +#: 裁判摘录的固定文字(P4-SPEC 3.2)。限定语写在小标题里,每块只写一次:JUDGE_RULES 第 1 条说「每个数都是系统 +#: 从证据里取出来的」,接受理由和区域外文字里的数不是,不写明的话裁判可能拿它们支持或否定结论句 +SOURCE_BROKEN = "来源:导入清单无法读取或与登记不一致,未列出来历" +_ACCEPT_HEAD = "已接受(用户填写的理由,未经系统核对):" +_OUTSIDE_HEAD = ("区域外文字(原表区域外的文字原文,未经系统核对;其中的数字不是查询结果,不能用来支持或否定报告中的" + "数字):") +_ACCEPT_MASKED = "(数据源设置了遮罩,理由未列出)" +_CUT = "…另有部分来历信息未列出,见证据面板" +_ACCEPT_STATE = {"override": "不成立", "waiver": "无法核对"} + + +# ========================================================================== +# 取工件 +# ========================================================================== + + +def _fetch(load: Loader, aid: Any) -> tuple[dict[str, Any] | None, str | None]: + """取回一件 JSON 对象工件:(内容, 问题)。问题为 None 表示取到了。 + + loader 抛 ValueError 是哈希不符;其他异常(读文件失败之类)同样算取不回。都不往外抛:清单坏了是要照实告诉人的 + 结论(R5 标红),不是 500。 + """ + if not isinstance(aid, str) or not aid: + return None, "missing_id" + try: + got = load(aid) + except ValueError: + return None, "hash_mismatch" + except Exception: # noqa: BLE001 - 取回失败的任何原因都按「取不回」处理 + _log.warning("provenance: 取回工件 %s 失败", aid[:12], exc_info=True) + return None, "unreadable" + if got is None: + return None, "missing" + if not isinstance(got, dict): + return None, "not_object" + return got, None + + +def chain_artifacts(schema: dict[str, Any], load: Loader) -> set[str]: + """表结构快照经哈希链列出的清单 id:import_manifests、snapshot_manifest、快照清单的 parts[].manifest_artifact。 + + 给按需裁判的 loader 用(P4-SPEC 2.8.3):这些 id 写在内容寻址、又在封存范围内的表结构快照里,属于封存链的延伸。 + **永不抛异常**:表结构快照不是对象(取回为 None)、快照清单取不回或哈希不符时,就当它列出的东西不在链上(快照 + 清单本身也不算),只是少列。import_manifests 是表结构快照自己列出的,不用逐个取回就认。 + """ + out: set[str] = set() + try: + if not isinstance(schema, dict): + return out + mids = schema.get("import_manifests") + if isinstance(mids, list): + out.update(m for m in mids if isinstance(m, str) and m) + sm_id = schema.get("snapshot_manifest") + if isinstance(sm_id, str) and sm_id: + sm, _ = _fetch(load, sm_id) + if sm is not None: + out.add(sm_id) + parts = sm.get("parts") + for p in parts if isinstance(parts, list) else []: + mid = p.get("manifest_artifact") if isinstance(p, dict) else None + if isinstance(mid, str) and mid: + out.add(mid) + except Exception: # noqa: BLE001 - 坏形状的表结构快照只会让链变短,不能让按需裁判报错 + _log.warning("provenance: 列出链上的清单失败", exc_info=True) + return out + + +# ========================================================================== +# 清单链(P4-SPEC 2.2) +# ========================================================================== + + +def _is_int(x: Any) -> bool: + return isinstance(x, int) and not isinstance(x, bool) + + +def _recipe_mode(manifest: dict[str, Any]) -> str: + """单期的 mode 取配方的 mode(规范形式省掉缺省值,没写就是 replace,同 Recipe.mode 的缺省)。""" + canonical = (manifest.get("recipe") or {}).get("canonical") if isinstance(manifest.get("recipe"), dict) else None + mode = canonical.get("mode") if isinstance(canonical, dict) else None + return "accumulate" if mode == "accumulate" else "replace" + + +def _union_rows(rows: Any) -> dict[str, list[int]] | None: + """快照清单 parts[].rows(表 → {union: [起, 止], part: [起, 止]})摊平成 表 → [union 起, 止, part 起, 止]。 + 形状不对返回 None(链本身对不上)。""" + if not isinstance(rows, dict): + return None + out: dict[str, list[int]] = {} + for table, r in rows.items(): + u = r.get("union") if isinstance(r, dict) else None + p = r.get("part") if isinstance(r, dict) else None + if not (isinstance(u, list) and isinstance(p, list) and len(u) == 2 and len(p) == 2 + and all(_is_int(x) for x in [*u, *p])): + return None + out[str(table)] = [u[0], u[1], p[0], p[1]] + return out + + +def resolve_chain(query: dict[str, Any], schema: dict[str, Any], load: Loader) -> Chain | ChainProblem: + """查询快照 + 冻结的表结构快照 → 清单链(P4-SPEC 2.2)。 + + 先取回表结构快照列出的全部清单(R5:任何一份取不回或哈希不符 → manifest_unreadable),再核对链是否自洽(R6: + 只看内容寻址的工件就能判的几项 → chain_mismatch)。要对照数据库、数据文件的(source_id、登记哈希,R13)由 + 调用方拿 Chain.source_id、expected_db_sha256 去比。不往外抛。 + + 单期(没有 snapshot_manifest)**不比快照 id**:移除、作废之后只剩一期时,冻结的 import_manifests 沿用那一期 + 原来的清单(source_versions.py 的 schema_cache_for),清单里的 snapshot_id 是当初提交时的快照,不是现在这个。 + 并集比:快照清单的 snapshot_id 等于查询快照的 data_version;parts[].manifest_artifact 逐个等于冻结的 + import_manifests;每期清单的 import_id、seq、source_id 与快照清单记的一致。 + """ + version = query.get("data_version") if isinstance(query, dict) else None + source = str((query.get("source") if isinstance(query, dict) else None) or schema.get("source") or "") + mids = schema.get("import_manifests") + sm_id = schema.get("snapshot_manifest") or None + if not isinstance(mids, list): + return ChainProblem("chain_mismatch", "import_manifests_shape") + # R5:先全部取回、复验哈希。顺序固定(快照清单在前),同一个 id 只取一次 + loaded: dict[str, dict[str, Any]] = {} + for aid in ([sm_id] if sm_id is not None else []) + list(mids): + if isinstance(aid, str) and aid in loaded: + continue + doc, problem = _fetch(load, aid) + if doc is None: + return ChainProblem("manifest_unreadable", f"{problem}:{str(aid)[:12]}") + loaded[aid] = doc + if not isinstance(version, str) or not version: + return ChainProblem("chain_mismatch", "no_data_version") + # R6:链本身是否自洽 + if sm_id is None: + if len(mids) != 1: + return ChainProblem("chain_mismatch", "import_manifests_count") + m = loaded[mids[0]] + expected, source_id = m.get("db_sha256"), m.get("source_id") + if not isinstance(expected, str) or not expected: + return ChainProblem("chain_mismatch", "db_sha256_missing") + if not isinstance(source_id, str) or not source_id: + return ChainProblem("chain_mismatch", "source_id_missing") + if not _is_int(m.get("seq")) or not isinstance(m.get("import_id"), str): + return ChainProblem("chain_mismatch", "part_identity") + part = ChainPart(m["seq"], m["import_id"], mids[0], m, None) + return Chain(source, source_id, version, _recipe_mode(m), False, expected, [part], schema, None) + sm = loaded[sm_id] + if sm.get("snapshot_id") != version: + return ChainProblem("chain_mismatch", "snapshot_id") + parts = sm.get("parts") + if not isinstance(parts, list) or not parts or not all(isinstance(p, dict) for p in parts): + return ChainProblem("chain_mismatch", "parts_shape") + if [p.get("manifest_artifact") for p in parts] != list(mids): + return ChainProblem("chain_mismatch", "parts_vs_import_manifests") + union = sm.get("union") if isinstance(sm.get("union"), dict) else {} + expected, source_id = union.get("db_sha256"), sm.get("source_id") + if not isinstance(expected, str) or not expected: + return ChainProblem("chain_mismatch", "db_sha256_missing") + if not isinstance(source_id, str) or not source_id: + return ChainProblem("chain_mismatch", "source_id_missing") + out: list[ChainPart] = [] + for p in parts: + m = loaded[p["manifest_artifact"]] + if m.get("import_id") != p.get("import_id") or m.get("seq") != p.get("seq") \ + or not _is_int(p.get("seq")) or not isinstance(p.get("import_id"), str): + return ChainProblem("chain_mismatch", "part_identity") + if m.get("source_id") != source_id: + return ChainProblem("chain_mismatch", "source_id") + rows = _union_rows(p.get("rows")) + if rows is None: + return ChainProblem("chain_mismatch", "part_rows") + out.append(ChainPart(p["seq"], p["import_id"], p["manifest_artifact"], m, rows)) + mode = sm.get("mode") if sm.get("mode") in _MODE_LABEL else "accumulate" + return Chain(source, source_id, version, mode, True, expected, out, schema, sm) + + +# ========================================================================== +# 数据版本(表级来历,P4-SPEC 2.8.1 的 version) +# ========================================================================== + + +def _rect(ref: Any) -> tuple[int, int, int, int] | None: + """「C5:AG7」或「B9」→ (c1, r1, c2, r2);认不出返回 None。""" + if not isinstance(ref, str) or not ref: + return None + a, _, b = ref.partition(":") + pa, pb = split_coord(a), split_coord(b or a) + if pa is None or pb is None: + return None + return min(pa[1], pb[1]), min(pa[2], pb[2]), max(pa[1], pb[1]), max(pa[2], pb[2]) + + +def _a1(r: int, c: int) -> str: + return f"{col_letters(c)}{r}" + + +def _str(x: Any) -> str | None: + return x if isinstance(x, str) and x else None + + +def _receipt(manifest: dict[str, Any]) -> dict[str, Any]: + r = manifest.get("receipt") + return r if isinstance(r, dict) else {} + + +def _period(manifest: dict[str, Any]) -> dict[str, Any] | None: + """这一期的统计期:清单顶层的 period(与回执里的同一份),缺了退回回执里的。""" + per = manifest.get("period") + if not isinstance(per, dict): + per = _receipt(manifest).get("period") + return per if isinstance(per, dict) else None + + +def region_of(receipt: dict[str, Any]) -> str | None: + """S9(P4-SPEC 调整 9):回执 regions 里块内各角色区域的外接矩形,按工作表,多张工作表用「、」连。 + + 块内 = 区域带了 block(期 3 起的回执一律带 block 键,区域外文字、统计期为 None);期 2 的回执没有 block 键, + 按块内认领的去向认。取不到为 None。 + """ + boxes: dict[str, list[int]] = {} + for m in receipt.get("regions") or []: + if not isinstance(m, dict) or not isinstance(m.get("sheet"), str): + continue + inside = bool(m.get("block")) if "block" in m else m.get("role") in _BLOCK_ROLES + rect = _rect(m.get("ref")) if inside else None + if rect is None: + continue + b = boxes.setdefault(m["sheet"], list(rect)) + b[0], b[1], b[2], b[3] = min(b[0], rect[0]), min(b[1], rect[1]), max(b[2], rect[2]), max(b[3], rect[3]) + if not boxes: + return None + return "、".join(f"{s}!{_a1(b[1], b[0])}:{_a1(b[3], b[2])}" for s, b in boxes.items()) + + +def excluded_rows_of(receipt: dict[str, Any]) -> int | None: + """S9:回执 rows_excluded 的行数之和;期 3 之前的回执没有这个键时为 None(「未记录」不等于 0)。""" + if "rows_excluded" not in receipt: + return None + total = 0 + for item in receipt.get("rows_excluded") or []: + for span in (item.get("rows") if isinstance(item, dict) else None) or []: + if isinstance(span, list) and len(span) == 2 and _is_int(span[0]) and _is_int(span[1]): + total += span[1] - span[0] + 1 + return total + + +def _check_titles(manifest: dict[str, Any]) -> dict[str, str]: + return {str(c["id"]): str(c.get("title") or c["id"]) for c in manifest.get("checks") or [] + if isinstance(c, dict) and c.get("id")} + + +def acceptances_of(manifest: dict[str, Any]) -> list[AcceptanceView]: + """这一期清单里的全部接受理由:先 overrides(数据质量类不成立),再 waivers(无法核对),各按清单里的顺序。""" + titles = _check_titles(manifest) + acc = manifest.get("acceptances") if isinstance(manifest.get("acceptances"), dict) else {} + out: list[AcceptanceView] = [] + for kind, key in (("override", "overrides"), ("waiver", "waivers")): + for a in acc.get(key) or []: + if not isinstance(a, dict) or not a.get("check_id"): + continue + cid = str(a["check_id"]) + out.append(AcceptanceView(check_id=cid, title=titles.get(cid), kind=kind, # type: ignore[arg-type] + reason=str(a.get("reason") or ""), signed_by=_str(a.get("signed_by")), + at=_str(a.get("at")))) + return out + + +def _period_view(manifest: dict[str, Any]) -> PeriodView | None: + per = _period(manifest) + if per is None or not per.get("start") or not per.get("end"): + return None + return PeriodView(start=str(per["start"]), end=str(per["end"]), + source="human" if per.get("source") == "human" else "cells", + cells=[str(c) for c in per.get("cells") or []], signed_by=_str(per.get("signed_by"))) + + +def _part_view(part: ChainPart) -> PartView: + m = part.manifest + f = m.get("file") if isinstance(m.get("file"), dict) else {} + recipe = m.get("recipe") if isinstance(m.get("recipe"), dict) else {} + signed = m.get("signed_by") if isinstance(m.get("signed_by"), dict) else {} + receipt = _receipt(m) + return PartView( + import_id=part.import_id, seq=part.seq, manifest=part.manifest_id, period=_period_view(m), + file_name=_str(f.get("name")), raw_sha256=_str(f.get("raw_sha256")), region=region_of(receipt), + excluded_rows=excluded_rows_of(receipt), recipe_sha256=_str(recipe.get("sha256")), recipe_seq=None, + committed_at=_str(m.get("created_at")), signed_by=_str(signed.get("name")), acceptances=acceptances_of(m)) + + +def _frozen_tables(schema: dict[str, Any]) -> dict[str, Any]: + tables = schema.get("tables") if isinstance(schema, dict) else None + return tables if isinstance(tables, dict) else {} + + +def _frozen_name(frozen: dict[str, Any], name: str) -> str | None: + """按 name_key 对冻结表结构的表名(原样逐字相同的优先)。""" + if name in frozen: + return name + key = name_key(name) + return next((t for t in frozen if name_key(t) == key), None) + + +def _table_kind(frozen: dict[str, Any], name: str) -> str: + meta = frozen.get(name) + return "reported_total" if isinstance(meta, dict) and meta.get("kind") == "reported_total" else "data" + + +def version_view(chain: Chain, *, tables: list[str]) -> VersionView: + """数据版本(表级来历):全部来自清单链上的内容寻址工件。 + + tables 由调用方用 direct_select.tables_in 算好(SQL 里出现的冻结表结构的表名),这里与冻结表结构按 name_key + 求交集、按出现顺序去重。recipe_seq、raw_state、purged、revoked 是当前状态,留 None 由接口一侧从数据库补; + has_row 全 False,由接口一侧按格子所在的一期标;manifest_view 一律 True,遮罩由接口一侧判断。 + """ + frozen = _frozen_tables(chain.schema) + refs: list[TableRef] = [] + seen: set[str] = set() + for name in tables: + hit = _frozen_name(frozen, str(name)) + if hit is None or hit in seen: + continue + seen.add(hit) + refs.append(TableRef(name=hit, kind=_table_kind(frozen, hit))) # type: ignore[arg-type] + return VersionView(source=chain.source, snapshot_id=chain.snapshot_id, mode=chain.mode, # type: ignore[arg-type] + union=chain.union, tables=refs, manifest_view=True, + parts=[_part_view(p) for p in chain.parts]) + + +# ========================================================================== +# 从 (表, 列, rowid) 找回格子(P4-SPEC 2.5) +# ========================================================================== + + +@dataclass(frozen=True) +class _Mark: + """回执里的一个区域标记,坐标已解析。keyed:回执里有没有 block 键(期 2 的清单没有)。""" + + sheet: str + role: str + block: str | None + keyed: bool + c1: int + r1: int + c2: int + r2: int + + def has(self, r: int, c: int) -> bool: + return self.r1 <= r <= self.r2 and self.c1 <= c <= self.c2 + + def in_block(self, block: str) -> bool: + # 期 2 的清单没有 block 键:能走到这里,说明这张工作表上配方只有一块(_anchor_block),按工作表认 + return self.block == block if self.keyed else True + + +def _marks(receipt: dict[str, Any]) -> list[_Mark]: + out: list[_Mark] = [] + for m in receipt.get("regions") or []: + if not isinstance(m, dict) or not isinstance(m.get("sheet"), str): + continue + rect = _rect(m.get("ref")) + if rect is None: + continue + block = m.get("block") + out.append(_Mark(m["sheet"], str(m.get("role") or ""), block if isinstance(block, str) else None, + "block" in m, *rect)) + return out + + +def _sheet_blocks(recipe: Recipe, receipt: dict[str, Any]) -> list[tuple[str, Any]]: + """(实际工作表名, 块):配方工作表 id 经回执的 sheets.matched 换成实际的工作表名(没认到的退回配方里的名字)。""" + sheets = receipt.get("sheets") if isinstance(receipt.get("sheets"), dict) else {} + matched = sheets.get("matched") if isinstance(sheets.get("matched"), dict) else {} + out: list[tuple[str, Any]] = [] + for sr in recipe.sheets: + actual = matched.get(sr.id) or sr.match.name + out += [(str(actual), b) for b in sr.blocks] + return out + + +def _anchor_block(marks: list[_Mark], sheet: str, r: int, c: int, recipe: Recipe, + receipt: dict[str, Any]) -> str | None: + """锚格所在的块:包含锚格的值区域(value / derived_value / total_value)的 block。 + + 期 2 生成的清单 regions 没有 block 键:同一工作表上配方恰好只有一块时认它,否则不认(不猜)。找不到包含锚格的 + 值区域、或者落在两块的区域里,都返回 None。 + """ + hits = [m for m in marks if m.sheet == sheet and m.role in _VALUE_ROLES and m.has(r, c)] + if not hits: + return None + if all(m.keyed for m in hits): + blocks = {m.block for m in hits} + return blocks.pop() if len(blocks) == 1 and None not in blocks else None + on_sheet = [b for s, b in _sheet_blocks(recipe, receipt) if s == sheet] + return on_sheet[0].id if len(on_sheet) == 1 else None + + +def _find_block(recipe: Recipe, receipt: dict[str, Any], sheet: str, block_id: str) -> Any | None: + on_sheet = [b for s, b in _sheet_blocks(recipe, receipt) if s == sheet and b.id == block_id] + if len(on_sheet) == 1: + return on_sheet[0] + anywhere = [b for _, b in _sheet_blocks(recipe, receipt) if b.id == block_id] + return anywhere[0] if len(anywhere) == 1 else None + + +def _table_out(receipt: dict[str, Any], table: str) -> dict[str, Any] | None: + hits = [t for t in receipt.get("tables") or [] if isinstance(t, dict) and t.get("name") == table] + return hits[0] if len(hits) == 1 else None + + +def _columns(table_out: dict[str, Any]) -> list[dict[str, Any]]: + return [c for c in table_out.get("columns") or [] if isinstance(c, dict) and isinstance(c.get("name"), str)] + + +def _derived_at(receipt: dict[str, Any], coord: str) -> dict[str, Any] | None: + """合计格(DerivedItem)里 cell 等于这一格的那一项:合计标签原文取它的 label_raw,规范写法取 label。""" + for d in receipt.get("derived") or []: + if isinstance(d, dict) and d.get("cell") == coord: + return d + return None + + +@dataclass +class _Ctx: + """locate 一次要用到的东西(都来自格子所在那一期的导入清单)。""" + + chain: Chain + part: ChainPart + part_rowid: int + rowid: int + table: str + column: str + role: str + meta: dict[str, Any] + table_out: dict[str, Any] + columns: list[dict[str, Any]] + receipt: dict[str, Any] + recipe: Recipe + marks: list[_Mark] + sheet: str + ar: int + ac: int + block_id: str + + +def _no(detail: str) -> LocateProblem: + return LocateProblem("no_lineage", detail) + + +def _part_of(chain: Chain, table: str, rowid: int) -> tuple[ChainPart, int] | LocateProblem: + """快照库的 rowid → (格子所在的一期, 该期构建库的 rowid)。 + + 单期:快照库就是那一期的构建库,rowid 直接是溯源段的 rowid。并集:rowid u 落在某一期 rows[表].union = [a, b] + 里,该期的 rowid = u - a + rows[表].part[0](part[0] 恒为 1,物化时已断言)。落不进任何一期、或那一期没有这张 + 表:链和数据文件对不上,chain_mismatch(标红,R17)。 + """ + if not chain.union: + return chain.parts[0], rowid + hits = [(p, rows) for p in chain.parts if (rows := (p.union_rows or {}).get(table)) is not None + and rows[0] <= rowid <= rows[1]] + if len(hits) != 1: + return LocateProblem("chain_mismatch", "rowid_out_of_parts" if not hits else "rowid_in_two_parts") + part, rows = hits[0] + return part, rowid - rows[0] + rows[2] + + +def locate(chain: Chain, *, table: str, column: str, rowid: int) -> Located | LocateProblem: + """(表, 列, 快照库的 rowid) → 原表的格子和附带的格(P4-SPEC 2.5)。 + + 并集时 rowid 是并集 rowid,先换算成某一期的 rowid(_part_of)。之后全部用那一期导入清单的回执:溯源段给锚格, + 区域标记、轴行、标签原文、合计格给附带的格,配方(解析后的模型,规范形式会省掉缺省值)给分段标题和年份规则。 + + CellSource 的 pk、recheck、raw_purged 留给接口一侧(WP-C)回查后填;rowid 填调用方给的快照库 rowid(就是回查 + 得到的那一个)。 + """ + if not _is_int(rowid) or rowid < 1: + return _no("rowid") + got = _part_of(chain, table, rowid) + if isinstance(got, LocateProblem): + return got + part, part_rowid = got + m = part.manifest + receipt = _receipt(m) + canonical = (m.get("recipe") or {}).get("canonical") if isinstance(m.get("recipe"), dict) else None + try: + recipe = Recipe.model_validate(canonical) + except Exception: # noqa: BLE001 - 配方解析不了就推不出附带的格,只给表级来历 + return _no("recipe_invalid") + table_out = _table_out(receipt, table) + if table_out is None: + return _no("table_not_in_receipt") + columns = _columns(table_out) + meta = next((c for c in columns if c["name"] == column), None) + if meta is None: + return _no("column_not_in_receipt") + role = str(meta.get("role") or "value") + lineage = receipt.get("lineage") if isinstance(receipt.get("lineage"), dict) else {} + # 第 1 步:锚格。被引用的列有溯源段就取它;交叉表的键列(本来就没有溯源段)取同一行第一个有溯源段的列 + if role in _VALUE_COLUMN_ROLES: + anchor = lineage_cell(lineage, table, column, part_rowid) + elif role in _KEY_ROLES: + anchor = next((a for c in columns if (a := lineage_cell(lineage, table, c["name"], part_rowid))), None) + else: + return _no("column_role") + if anchor is None: + return _no("no_lineage_for_row") + parsed = split_coord(anchor) + if parsed is None or parsed[0] is None: + return _no("anchor") + sheet, ac, ar = parsed[0], parsed[1], parsed[2] + marks = _marks(receipt) + block_id = _anchor_block(marks, sheet, ar, ac, recipe, receipt) + if block_id is None: + return _no("anchor_block") + block = _find_block(recipe, receipt, sheet, block_id) + if block is None: + return _no("block_not_in_recipe") + ctx = _Ctx(chain, part, part_rowid, rowid, table, column, role, meta, table_out, columns, receipt, recipe, marks, + sheet, ar, ac, block_id) + if isinstance(block, CrosstabBlock): + return _locate_crosstab(ctx, block) + if isinstance(block, ListBlock): + return _locate_list(ctx, block) + return _no("block_layout") + + +def _cell_source(ctx: _Ctx, cell: tuple[int, int], froms: list[FromCell], *, year: YearSource | None, + canonical: CanonicalView | None, merged_fill: bool) -> Located: + # 表的种类按冻结表结构认(同 datasource.reported_total_tables 的口径),冻结表结构里没有这张表时退回回执 + frozen = _frozen_tables(ctx.chain.schema) + if ctx.table in frozen: + kind = _table_kind(frozen, ctx.table) + else: + kind = "reported_total" if ctx.table_out.get("kind") == "reported_total" else "data" + src = CellSource( + table=ctx.table, column=ctx.column, column_role=ctx.role, kind=kind, # type: ignore[arg-type] + rowid=ctx.rowid, part_seq=ctx.part.seq, part_rowid=ctx.part_rowid, sheet=ctx.sheet, + cell=_a1(cell[0], cell[1]), header=_str(ctx.meta.get("header")), unit=_str(ctx.meta.get("unit")), + from_=froms, year=year, canonical=canonical, merged_fill=merged_fill) + return Located(ctx.part, ctx.part_rowid, src) + + +def _canonical(raw: Any, canonical: Any) -> CanonicalView | None: + """标签原文与库里的值(规范写法)不同时给出;任何一边拿不到都不给。""" + if isinstance(raw, str) and raw and isinstance(canonical, str) and canonical and raw != canonical: + return CanonicalView(raw=raw, canonical=canonical) + return None + + +def _year(ctx: _Ctx, block: CrosstabBlock, form: Any) -> YearSource | None: + """年份:交叉表的轴是文本日期(text / mixed)且配方 axis.year_from 有值时取自统计期,其余(日期格)为 None。 + + mixed:清单只记每块的 form,不记哪一格是日期格、哪一格是文本,逐格判断不了,照实标 mixed(评审甲 7③)。 + """ + if form not in ("text", "mixed") or not block.axis.year_from: + return None + per = _period(ctx.part.manifest) + if per is None: + return None + mixed = form == "mixed" + if per.get("source") == "human": + return YearSource(source="human", cells=[], signed_by=_str(per.get("signed_by")), mixed=mixed) + return YearSource(source="period", cells=[str(c) for c in per.get("cells") or []], signed_by=None, mixed=mixed) + + +def _locate_crosstab(ctx: _Ctx, block: CrosstabBlock) -> Located | LocateProblem: + """交叉表:值列有溯源段;日期取轴行、维度和派生列取行标签、常量列取分段标题(2.5 第 2 步的表)。""" + receipt, sheet, ar, ac, role = ctx.receipt, ctx.sheet, ctx.ar, ctx.ac, ctx.role + axes = [a for a in receipt.get("axes") or [] if isinstance(a, dict) and a.get("block") == ctx.block_id] + if len(axes) != 1 or not _is_int(axes[0].get("row")): + return _no("axis") + axis = axes[0] + axis_col = next((c["name"] for c in ctx.columns if c.get("role") == "axis"), block.axis.name) + dim_col = next((c["name"] for c in ctx.columns if c.get("role") == "dim"), None) + const_cols = [c["name"] for c in ctx.columns if c.get("role") == "const"] + # 行标签:同块里 role 为 row_label / derived_label / total_label、矩形覆盖锚格那一行的区域 + labels = [mk for mk in ctx.marks if mk.sheet == sheet and mk.role in _LABEL_ROLES and mk.in_block(ctx.block_id) + and mk.r1 <= ar <= mk.r2] + label: tuple[int, str] | None = None # (标签列, FromCell.role) + if len({mk.c1 for mk in labels}) == 1: + label = (labels[0].c1, _FROM_LABEL[labels[0].role]) + # 这一行属于块里哪个分段:SegmentLabels 不带工作表,先按配方取这一块的分段,再找 rows 含这一行的那一项 + seg_ids = {s.id for s in block.segments} + seg_hits = [s for s in receipt.get("labels") or [] if isinstance(s, dict) and s.get("segment") in seg_ids + and ar in (s.get("rows") or [])] + seg_labels = seg_hits[0] if len(seg_hits) == 1 else None + i = seg_labels["rows"].index(ar) if seg_labels is not None else -1 + seg_raw = seg_canon = None + if seg_labels is not None: + raws, canons = seg_labels.get("raw") or [], seg_labels.get("canonical") or [] + seg_raw = raws[i] if i < len(raws) else None + seg_canon = canons[i] if i < len(canons) else None + label_raw, label_canon = seg_raw, seg_canon + if label is not None and label[1] == "total_label": + # 交叉表的合计行:合计标签原文取这一格 DerivedItem 的 label_raw,规范写法(即库里合计项的值)取 label + item = _derived_at(receipt, f"{sheet}!{_a1(ar, ac)}") + if item is not None: + label_raw, label_canon = item.get("label_raw"), item.get("label") + # 分段标题:配方里这个分段按标题定位;同块 section_title 区域里行号小于这一段第一个标签行的,取最近的一个 + title: tuple[int, int, str | None] | None = None + seg = next((s for s in block.segments if seg_labels is not None and s.id == seg_labels.get("segment")), None) + if seg is not None and seg.locate.by == "section_title" and seg_labels.get("rows"): # type: ignore[union-attr] + first = min(seg_labels["rows"]) # type: ignore[index] + above = sorted((mk.r1, mk.c1) for mk in ctx.marks if mk.sheet == sheet and mk.role == "section_title" + and mk.in_block(ctx.block_id) and mk.r1 < first) + if above: + best = max(r for r, _ in above) + title = (best, min(c for r, c in above if r == best), seg.locate.title) + # 被引用的格 + if role in _VALUE_COLUMN_ROLES: + cell = (ar, ac) + elif role == "axis": + cell = (axis["row"], ac) + elif role in ("dim", "derive"): + if label is None: + return _no("row_label") + cell = (ar, label[0]) + else: # const + if title is None: + return _no("section_title") + cell = (title[0], title[1]) + # 附带的格:这一行其余键列各取一格,除了被引用的格本身 + froms: list[FromCell] = [] + if role != "axis": + froms.append(FromCell(role="axis_header", column=axis_col, sheet=sheet, cell=_a1(axis["row"], ac))) + label_in_from = False + # 有维度列的表:行标签给出维度的值;宽表(只有指标列):行标签是这一列的指标名,column 为 None + if (dim_col is not None or role == "measure") and role not in ("dim", "derive"): + if label is None: + return _no("row_label") + froms.append(FromCell(role=label[1], column=dim_col, sheet=sheet, cell=_a1(ar, label[0]), # type: ignore[arg-type] + text=label_raw if isinstance(label_raw, str) else None)) + label_in_from = dim_col is not None + if const_cols and role != "const": + if title is None: + return _no("section_title") + # 分段标题只有配方里的定位文字(执行器按 match_key 比对,原表里的字可以不同),不当作格子原文 + froms.append(FromCell(role="section_title", column=const_cols[0], sheet=sheet, cell=_a1(title[0], title[1]), + text=None, locate_title=title[2])) + # 规范写法:比的是这一行的标签列(维度列、合计项列)。被引用的就是它、或它在附带的格里时给 + canonical = _canonical(label_raw, label_canon) if dim_col is not None and (role == "dim" or label_in_from) \ + else None + return _cell_source(ctx, cell, froms, year=_year(ctx, block, axis.get("form")), canonical=canonical, + merged_fill=False) + + +def _list_label_col(ctx: _Ctx, block: ListBlock) -> int | None: + """列表合计行的标签列:合计标签所在的列(total_row.label_column)在明细表里的溯源段给出它在哪一列;明细表没有 + 溯源段时(一行明细都没有),退回同块 total_label 区域(只有一列时)。""" + total = block.rows.total_row + lineage = ctx.receipt.get("lineage") if isinstance(ctx.receipt.get("lineage"), dict) else {} + runs = ((lineage.get(block.table) or {}).get(total.label_column) or []) if total is not None else [] + for run in runs: + if isinstance(run, list) and len(run) > 2 and run[1] == ctx.sheet \ + and (parsed := split_coord(str(run[2]))) is not None: + return parsed[1] + cols = {mk.c1 for mk in ctx.marks if mk.sheet == ctx.sheet and mk.role == "total_label" + and mk.in_block(ctx.block_id) and mk.r1 <= ctx.ar <= mk.r2 and mk.c1 == mk.c2} + return cols.pop() if len(cols) == 1 else None + + +def _list_header_cells(ctx: _Ctx, block: ListBlock, c: int, header: str) -> list[FromCell]: + """列表第 c 列的表头格(2.5 第 3 步「多行表头给出每一格」),只列确知写着字的格。 + + 区域标记是同一去向的相邻已认领格合成的段,段内没认领的空格一并覆盖(recipe_engine._row_spans:「中间的空格一并 + 覆盖,着色无妨」),而表头行只认领非空格(喂给 _ListRun 的只有非空格:Grid.row、xlsx_cells.sheet_rows)。所以: + - 一行表头:这一列是按 (表头行, c) 这一格的字认出来的(一行表头拼表头时不看合并区,_compose),这一格必定有字, + 回执里列的 header 就是它的原文; + - 多行表头:上层常是横向合并的「2026年上半年」,合并区里除左上格以外的格是空的,却落在段的中间(lab.c04 的 + D3 在 A3:E3 里),左边纵向合并的「地区」在下一层也是空格。清单不记合并区,段中间的格有没有字分不出,所以只列 + c 恰好是段的端点(起列或止列,必定是认领了的非空格)的那几层,其余层不列:宁可少列一格,也不把空格说成表头。 + 多行表头的 header 是各层用「_」拼起来的,不是哪一格的原文,text 一律为 None。 + """ + hits = [mk for mk in ctx.marks if mk.sheet == ctx.sheet and mk.role == "col_header" + and mk.in_block(ctx.block_id) and mk.c1 <= c <= mk.c2] + if block.header_rows == 1: + rows = sorted({r for mk in hits for r in range(mk.r1, mk.r2 + 1)}) + text = header if len(rows) == 1 else None + else: + rows = sorted({r for mk in hits if c in (mk.c1, mk.c2) for r in range(mk.r1, mk.r2 + 1)}) + text = None + return [FromCell(role="col_header", column=None, sheet=ctx.sheet, cell=_a1(r, c), text=text) for r in rows] + + +def _locate_list(ctx: _Ctx, block: ListBlock) -> Located | LocateProblem: + """列表:每一列都有溯源段(合计表的合计项列除外);附带列表头,合计表另附合计标签(2.5 第 3 步)。""" + receipt, sheet, ar, ac, role = ctx.receipt, ctx.sheet, ctx.ar, ctx.ac, ctx.role + total = block.rows.total_row + is_total = total is not None and bool(total.keep_as) and total.keep_as == ctx.table and ctx.table != block.table + item = _derived_at(receipt, f"{sheet}!{_a1(ar, ac)}") if is_total else None + label_c = _list_label_col(ctx, block) if is_total else None + if role in _VALUE_COLUMN_ROLES: + cell = (ar, ac) + elif role == "dim" and is_total: + if label_c is None: + return _no("total_label") + cell = (ar, label_c) + else: + return _no("column_role") + froms: list[FromCell] = [] + header = ctx.meta.get("header") + if is_total: + # 合计表的列头取明细表同名列的(原表表头原文);合计表这一列的 header 来自配方的写法 + base = _table_out(receipt, block.table) + base_meta = next((c for c in _columns(base) if c["name"] == ctx.column), None) if base else None + header = base_meta.get("header") if base_meta is not None else header + if role != "dim" and isinstance(header, str) and header: + froms += _list_header_cells(ctx, block, cell[1], header) + dim_col = next((c["name"] for c in ctx.columns if c.get("role") == "dim"), None) + if is_total and role != "dim": + if label_c is None: + return _no("total_label") + froms.append(FromCell(role="total_label", column=dim_col, sheet=sheet, cell=_a1(ar, label_c), + text=_str(item.get("label_raw")) if item is not None else None)) + canonical = _canonical(item.get("label_raw"), item.get("label")) if is_total and item is not None else None + # 列表这一块按合并单元格的左上格填充时,执行器给填充格记的溯源是填充位置,不是值所在的左上格;清单里没有合并区 + # 的信息,逐格判断不了,只能照实给出条件说明(评审甲 7②) + return _cell_source(ctx, cell, froms, year=None, canonical=canonical, merged_fill=block.merged_data == "fill") + + +# ========================================================================== +# 相关核对(P4-SPEC 2.7,静态部分) +# ========================================================================== + + +def cell_status(check: dict[str, Any], coord: str, *, is_formula: bool, formula_check: bool) -> str | None: + """合计核对(K、G、T)在这一格上的结论。check 是清单里的 CheckResult(dict),coord 是「工作表!A1」。 + + - G 只核对公式格,写死的数直接跳过(recipe_checks._check_g),而 plan_checks 只要段里有一个公式格就给整段编 + G 号:所以这一格不是公式时为 not_formula,不能因为整段 G 通过就说「这一格一致」(评审甲 6); + - passed:ok; + - 无法核对(或不一致):格在 cells 里 → unverifiable(不一致的那几格为 None:CellStatus 没有这个取值,已提交的 + 导入里也不会出现,界面看本期结论);不在里面、而 failed + unverifiable 没超过 cells 的条数(清单列全了)→ ok; + 否则 unknown:清单只记前 20 格,这一格是否在其中无法确定,照实说,不猜。 + """ + if formula_check and not is_formula: + return "not_formula" + status = check.get("status") + if status == "passed": + return "ok" + if status not in ("unverifiable", "mismatch"): + return None + cells = [str(c) for c in check.get("cells") or []] + failed = check.get("failed") if _is_int(check.get("failed")) else 0 + unverifiable = check.get("unverifiable") if _is_int(check.get("unverifiable")) else 0 + if coord in cells: + # cells 先列不一致的格、再列无法核对的格(recipe_checks._finish) + return None if status == "mismatch" and cells.index(coord) < failed else "unverifiable" + if failed + unverifiable <= len(cells): + return "ok" + return "unknown" + + +def _plan(cid: str, check: dict[str, Any], acceptances: dict[str, AcceptanceView], *, + cell: str | None = None, rule: SumEqRule | None = None, detail: str | None = None) -> RelatedCheckPlan: + return RelatedCheckPlan(RelatedCheck( + id=cid, kind=str(check.get("kind") or ""), title=str(check.get("title") or cid), + part_status=check.get("status"), row_status=None, cell_status=cell, # type: ignore[arg-type] + acceptance=acceptances.get(cid), detail=detail), rule) + + +def related_checks(chain: Chain, located: Located, *, table: str, column: str) -> list[RelatedCheckPlan]: + """这一格的相关核对(P4-SPEC 2.7):只取格子所在那一期的导入清单的 checks。 + + 顺序:关系核对(清单里的顺序:sum_eq 是 table 等于这张表、被引用的列是 total 或 parts 之一的;not_comparable + 是 a 或 b 为这张表的,只作口径说明),再 K、G、T(被引用的格就是某个合计格时,列出那一段的),最后统计期 C1、C2 + (年份取自统计期时)。part_status、cell_status、acceptance 在这里定;row_status 留 None,由接口一侧按 + RelatedCheckPlan.rule 在快照库上跑 SQL 后填。关系取格子所在那一期的配方。 + """ + m = located.part.manifest + receipt = _receipt(m) + checks = [c for c in m.get("checks") or [] if isinstance(c, dict) and c.get("id")] + by_id = {str(c["id"]): c for c in checks} + acceptances: dict[str, AcceptanceView] = {} + for a in acceptances_of(m): + acceptances.setdefault(a.check_id, a) + canonical = (m.get("recipe") or {}).get("canonical") if isinstance(m.get("recipe"), dict) else None + try: + recipe: Recipe | None = Recipe.model_validate(canonical) + except Exception: # noqa: BLE001 - 配方解析不了:关系和合计核对认不出是哪一条,只列统计期 + recipe = None + out: list[RelatedCheckPlan] = [] + if recipe is not None: + relations = {r.id: r for r in recipe.relations} + types = {c["name"]: str(c.get("type") or "") for c in _columns(_table_out(receipt, table) or {})} + for c in checks: + rel = relations.get(str(c["id"])) + if c.get("kind") == "relation_sum_eq" and isinstance(rel, SumEq) and rel.table == table \ + and column in (rel.total, *rel.parts): + names = [rel.total, *rel.parts] + rule = SumEqRule(table=rel.table, total=rel.total, parts=list(rel.parts), + types={n: types[n] for n in names if n in types}) + out.append(_plan(rel.id, c, acceptances, rule=rule)) + elif c.get("kind") == "relation_not_comparable" and isinstance(rel, NotComparable) \ + and table in (rel.a.table, rel.b.table): + out.append(_plan(rel.id, c, acceptances)) + out += _total_checks(recipe, receipt, located, by_id, acceptances) + if located.cell.year is not None: + out += [_plan(cid, by_id[cid], acceptances) for cid in ("C1", "C2") if cid in by_id] + return out + + +def _total_checks(recipe: Recipe, receipt: dict[str, Any], located: Located, by_id: dict[str, dict[str, Any]], + acceptances: dict[str, AcceptanceView]) -> list[RelatedCheckPlan]: + """被引用的格就是某个合计格(DerivedItem.cell)时,那一段的 K、G(列表合计是 T)。编号由 plan_checks 定, + 和导入时的核对、说明生成是同一套。""" + from app.data.recipe_imports import extraction_from_dict + + coord = f"{located.cell.sheet}!{located.cell.cell}" + item = _derived_at(receipt, coord) + if item is None: + return [] + try: + plan = plan_checks(recipe, extraction_from_dict(receipt)) + except Exception: # noqa: BLE001 - 回执形状不对就认不出编号,不列合计核对 + _log.warning("provenance: 回执还原失败,不列合计核对", exc_info=True) + return [] + seg = item.get("segment") + is_formula = bool(item.get("is_formula")) + out: list[RelatedCheckPlan] = [] + for cid, formula_check in ((plan.k.get(seg), False), (plan.g.get(seg), True), (plan.t.get(seg), False)): + check = by_id.get(cid) if cid else None + if check is None or check.get("kind") not in _TOTAL_KINDS: + continue + details = [str(d) for d in check.get("details") or []] + detail = details[0] if check.get("status") == "unverifiable" and details else None + out.append(_plan(cid, check, acceptances, detail=detail, # type: ignore[arg-type] + cell=cell_status(check, coord, is_formula=is_formula, formula_check=formula_check))) + return out + + +# ========================================================================== +# 裁判摘录的来历行(P4-SPEC 3.2) +# ========================================================================== + + +def _period_words(manifest: dict[str, Any]) -> str: + per = _period(manifest) + if per is None or not per.get("start") or not per.get("end"): + return "统计期未记录" + return f"统计期 {per['start']} 至 {per['end']}" + + +def _period_detail(manifest: dict[str, Any]) -> str: + """来源行里的统计期:取自哪几格,或人工录入(R2 的人工录入上下文:署名照写,标「未认证」)。""" + words = _period_words(manifest) + per = _period(manifest) + if per is None or words == "统计期未记录": + return words + if per.get("source") == "human": + who = _str(per.get("signed_by")) + return f"{words}(人工录入,署名「{who}」,未认证)" if who else f"{words}(人工录入,未署名)" + cells = [str(c) for c in per.get("cells") or []] + return f"{words}(取自 {'、'.join(cells)})" if cells else words + + +def _part_line(part: ChainPart) -> str: + m = part.manifest + f = m.get("file") if isinstance(m.get("file"), dict) else {} + receipt = _receipt(m) + bits = [_period_detail(m)] + name, sha = _str(f.get("name")), _str(f.get("raw_sha256")) + if name: + bits.append(f"文件「{name}」" + (f"(sha256 {sha[:12]})" if sha else "")) + region = region_of(receipt) + if region: + bits.append(f"区域 {region}") + excluded = excluded_rows_of(receipt) + if excluded: + bits.append(f"排除 {excluded} 行") + return f"第 {part.seq} 次导入:" + ",".join(bits) + + +def _masked(name: str, hidden: set[str]) -> bool: + return str(name).lower() in hidden + + +@dataclass +class _Lines: + """来历块的各部分。分开存,整体超长时按 3.2 的五步顺序删(_fit),删完再拼成行。""" + + head: list[str] + parts: list[str] # 来源的分期行,按统计期升序 + parts_more: int + tables: list[tuple[str, str]] # 表的说明:(行首, 说明) + columns: list[str] + accepts: list[tuple[int, str]] # (期的位置, 行),按期次升序 + accepts_more: int + outside: list[tuple[int, str]] # (期的位置, 行),按送出的顺序 + outside_more: int # 超出条数上限、被截断步骤删掉的 + outside_unlisted: int # hidden 为 True 或未记录(None)的 + outside_masked: int # 数据源设有遮罩时的总条数(一格都不送) + cut: bool = False + + def render(self) -> list[str]: + out = list(self.head) + out += [f" {x}" for x in self.parts] + if self.parts_more: + out.append(f" 另有 {self.parts_more} 期,见证据面板") + out += [a + b for a, b in self.tables] + out += self.columns + if self.accepts: + out.append(_ACCEPT_HEAD) + out += [f" {x}" for _, x in self.accepts] + if self.accepts_more: + out.append(f" 另有 {self.accepts_more} 条,见证据面板") + if self.outside_masked: + out.append(f"区域外另有 {self.outside_masked} 处文字(数据源设置了遮罩,未列出),见证据面板") + else: + unlisted = (f"另有 {self.outside_unlisted} 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏)," + "见证据面板") + if self.outside: + out.append(_OUTSIDE_HEAD) + out += [f" {x}" for _, x in self.outside] + if self.outside_more: + out.append(f" 另有 {self.outside_more} 处区域外文字未列出(超出条数上限),见证据面板") + if self.outside_unlisted: + out.append(f" {unlisted}") + elif self.outside_unlisted: + out.append(unlisted) + if self.cut: + out.append(_CUT) + return out + + def size(self) -> int: + return len("\n".join(self.render())) + + +def _fit(block: _Lines, limit: int) -> None: + """整块超过 limit 字时按固定顺序删(P4-SPEC 3.2),保证确定: + 1. 来源的分期行,从最旧的期起(总述行保留);2. 列的说明,从最后一列起;3. 已接受,从最旧的期起; + 4. 区域外文字,从最旧的期起;5. 表的说明截到剩余字数,末尾加「…」。删过任何一项,末尾加一行说明。""" + while block.size() > limit: + if block.parts: + block.parts.pop(0) + block.parts_more += 1 + elif block.columns: + block.columns.pop() + elif block.accepts: + block.accepts.pop(0) + block.accepts_more += 1 + elif block.outside: + oldest = min(k for k, _ in block.outside) + at = max(i for i, (k, _) in enumerate(block.outside) if k == oldest) + block.outside.pop(at) + block.outside_more += 1 + elif block.tables: + if not block.cut: + block.cut = True + continue + over = block.size() - limit + lead, body = block.tables[-1] + keep = len(body) - over - 1 + if keep > 0: + block.tables[-1] = (lead, body[:keep] + "…") + else: + block.tables.pop() + else: + break + block.cut = True + + +def judge_lines(query: dict[str, Any], schema: dict[str, Any] | None, load: Loader, *, + tables: list[str], hidden: set[str]) -> list[str]: + """裁判摘录里查询快照 SQL 之后、「列:」之前的来历行(P4-SPEC 3.2)。纯函数,loader 注入,同样的工件永远得到 + 同样的行;不取数据库列、不取当前时间。 + + - tables:调用方用 direct_select.tables_in 算好的 SQL 用到的表(这里与冻结表结构按 name_key 求交集); + - hidden:_query_excerpt 现成的那份遮罩列(小写)。非空表示数据源设有遮罩:接受理由不写、区域外文字一格不送 + (理由、注释里可能写着遮罩列的数),遮罩列的说明不写。 + + 生效范围:来源、已接受、区域外文字要求表结构快照是按配方导入的、链能解析;口径(表和列的说明)有 data_version + 和表结构快照即可,简单导入也写(调整 6)。查询快照没有 data_version(手工源)时返回空列表。链解析失败时写一行 + 「来源:导入清单无法读取…」,不中断摘录。 + """ + if not isinstance(query, dict) or not query.get("data_version") or not isinstance(schema, dict): + return [] + hidden = {str(h).lower() for h in hidden or ()} + frozen = _frozen_tables(schema) + used: list[str] = [] + for name in tables or []: + hit = _frozen_name(frozen, str(name)) + if hit is not None and hit not in used: + used.append(hit) + block = _Lines(head=[], parts=[], parts_more=0, tables=[], columns=[], accepts=[], accepts_more=0, outside=[], + outside_more=0, outside_unlisted=0, outside_masked=0) + _caliber(block, query, frozen, used, hidden) + if schema.get("import_mode") == "recipe": + chain = resolve_chain(query, schema, load) + if isinstance(chain, ChainProblem): + block.head = [SOURCE_BROKEN] + else: + _source(block, chain) + _accepts(block, chain, hidden) + _outside(block, chain, hidden) + _fit(block, PROV_CHARS) + return block.render() + + +def _source(block: _Lines, chain: Chain) -> None: + mode = _MODE_LABEL.get(chain.mode, "每期替换") + if not chain.union: + block.head = [f"来源:上传的表格,{mode},{_part_line(chain.parts[0])}"] + return + spans = [(str(p["start"]), str(p["end"])) for part in chain.parts + if (p := _period(part.manifest)) is not None and p.get("start") and p.get("end")] + span = f"({min(a for a, _ in spans)} 至 {max(b for _, b in spans)})" if spans else "" + block.head = [f"来源:上传的表格,{mode},共 {len(chain.parts)} 期{span}"] + shown = chain.parts[-PROV_PARTS:] + block.parts = [_part_line(p) for p in shown] + block.parts_more = len(chain.parts) - len(shown) + + +def _caliber(block: _Lines, query: dict[str, Any], frozen: dict[str, Any], used: list[str], + hidden: set[str]) -> None: + """口径:SQL 用到的每张表的说明;查询结果列里按 name_key 对得上这些表某列、且列有注释的,列的说明(遮罩列不写)。 + 说明来自冻结的表结构快照,本来就是模型看得到的、不含数字的散文;未规整的表的说明就是 UNSHAPED_NOTE。""" + for name in used: + comment = frozen[name].get("comment") if isinstance(frozen[name], dict) else None + if isinstance(comment, str) and comment.strip(): + block.tables.append((f"口径:表 {name} 的说明:", comment.strip())) + seen: set[str] = set() + for col in query.get("columns") or []: + key = name_key(str(col)) + if len(block.columns) >= PROV_COLUMN_NOTES or _masked(str(col), hidden): + continue + for name in used: + meta = frozen[name] if isinstance(frozen[name], dict) else {} + hit = next((c for c in meta.get("columns") or [] if isinstance(c, dict) + and name_key(str(c.get("name"))) == key), None) + if hit is None: + continue + cname, comment = str(hit.get("name")), hit.get("comment") + if cname not in seen and not _masked(cname, hidden) and isinstance(comment, str) and comment.strip(): + seen.add(cname) + block.columns.append(f"口径:列 {cname} 的说明:{comment.strip()}") + break + + +def _accepts(block: _Lines, chain: Chain, hidden: set[str]) -> None: + """已接受:各期清单 acceptances,按期次排序,最多 PROV_ACCEPT 条(取最近的几期的),其余写「另有 n 条」。""" + rows: list[tuple[int, str]] = [] + for k, part in enumerate(chain.parts): + where = f"第 {part.seq} 次导入({_period_words(part.manifest)})" + for a in acceptances_of(part.manifest): + title = a.title or a.check_id + head = f"{where}的核对「{title}」{_ACCEPT_STATE.get(a.kind, '不成立')}" + if hidden: + rows.append((k, f"{head}{_ACCEPT_MASKED}")) + continue + who = f"署名「{a.signed_by}」,未认证" if a.signed_by else "未署名" + rows.append((k, f"{head},理由:{a.reason}({who})")) + block.accepts = rows[-PROV_ACCEPT:] if rows else [] + block.accepts_more = len(rows) - len(block.accepts) + + +def _outside(block: _Lines, chain: Chain, hidden: set[str]) -> None: + """区域外文字:只送 hidden is False 的项(调整 10);为 True 或老清单没有这个字段的只计条数。先含数字的, + 再按期次从新到旧;每格最多 PROV_OUTSIDE_CHARS 字,最多 PROV_OUTSIDE 格。每行写所属的导入和统计期:并集时 8 月 + 的注释不能被读成 9 月的。数据源设有遮罩时一格都不送,只写总条数。""" + items: list[tuple[int, int, int, int, str]] = [] # (含数字在前, 新的在前, 原顺序, 期的位置, 行) + total = unlisted = 0 + for k, part in enumerate(chain.parts): + where = f"第 {part.seq} 次导入({_period_words(part.manifest)})" + for n, o in enumerate(_receipt(part.manifest).get("outside_text") or []): + if not isinstance(o, dict): + continue + total += 1 + if o.get("hidden") is not False: + unlisted += 1 + continue + text = str(o.get("text") or "") + shown = f"「{text}」" if len(text) <= PROV_OUTSIDE_CHARS else f"「{text[:PROV_OUTSIDE_CHARS]}…」(已截断)" + items.append((0 if o.get("kind") == "text_digits" else 1, -k, n, k, f"{where}{o.get('cell')}:{shown}")) + if hidden: + block.outside_masked = total + return + items.sort(key=lambda x: (x[0], x[1], x[2])) + block.outside = [(k, line) for *_, k, line in items[:PROV_OUTSIDE]] + block.outside_more = max(0, len(items) - PROV_OUTSIDE) + block.outside_unlisted = unlisted + diff --git a/backend/app/data/provenance_db.py b/backend/app/data/provenance_db.py new file mode 100644 index 00000000..1540f070 --- /dev/null +++ b/backend/app/data/provenance_db.py @@ -0,0 +1,257 @@ +"""证据下钻(期 4「推断的来源」)里要碰数据库和数据文件的几步(P4-SPEC 2.3 R12–R16、2.7、2.9)。 + +识别器(engine/direct_select.py)和清单、格子(data/provenance.py)都是纯函数,不连库;这里收拢剩下的: +取数据文件记录、绑定快照、按主键回查、编译核对、强制重算哈希、行级核对、导入记录上的当前状态。 + +**为什么回查、行级核对都经 EngineCache**:查询本身就是经它打开快照的(immutable 只读、本进程首次整份核对哈希、 +之后每次取引擎和借连接都比文件指纹)。走同一条路,下钻核对过的就是查询读过的那个文件,保证范围也和查询相同 +(P4-SPEC 2.6 照实写了这个范围的边界)。编译核对另开一个连接,开前关后各比一次 EngineCache 记下的指纹。 + +**异常**:SnapshotManifestMismatch、SnapshotTampered、SnapshotMissing 一律原样抛出,由接口一侧按 2.3 的 +except 顺序映射成 chain_mismatch / db_tampered / snapshot_gone(api/evidence._snapshot_failure)。这里不吞、 +不改判:同一类异常在接口的每一步都要得到同一个结论。 + +同步的文件操作(编译核对、整份重算哈希)一律放 asyncio.to_thread,不卡事件循环(H9)。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.data import engine as engine_mod +from app.data.engine import SnapshotTampered, engines, open_checked_sqlite +from app.data.names import name_key +from app.data.provenance_types import CompileFacts, StateNote, SumEqRule +from app.data.recipe_checks import sum_eq_conditions +from app.data.table_versions import SnapshotMissing, SourceView, bind_snapshot +from app.db.models import SourceSnapshot, TableImport, TableRecipe + +#: 数据文件记录在、却已回收或文件不在(绑定之前先认出来,免得取引擎时被当成「文件被改」标红) +_GONE = "数据版本 {sid} 的数据文件已回收或不存在,无法按主键回查" +#: EngineCache 里没有这个快照的指纹(不是快照引擎):不该出现,按文件不在处理(P4-SPEC 2.9 末尾) +_NOT_PINNED = "数据版本 {sid} 没有核对过的数据文件记录,无法做编译核对" +#: 编译核对前后,文件和 EngineCache 核对过的那份对不上:和借连接时指纹不符同一个说法 +_REPLACED = engine_mod._REPLACED + + +def _q(name: str) -> str: + """SQL 标识符:一律双引号、内部双引号写两个(和 recipe_checks._q、契约 Recheck 的写法一致)。""" + return '"' + str(name).replace('"', '""') + '"' + + +def _fingerprint(path: str) -> engine_mod.Fingerprint | None: + """文件指纹,口径和 EngineCache 记下的那份完全相同(同一个函数)。单独包一层,测试可以只换这里。""" + return engine_mod._fingerprint(path) + + +# -------------------------------------------------------------------------- +# 数据文件记录与绑定(R12、R14) +# -------------------------------------------------------------------------- + + +async def snapshot_record(session: AsyncSession, snapshot_id: str) -> SourceSnapshot | None: + """查询快照的 data_version 对应的数据文件记录(source_snapshots 一行);没有为 None(R12)。""" + if not snapshot_id: + return None + return await session.get(SourceSnapshot, str(snapshot_id)) + + +def view_of(snapshot: SourceSnapshot, *, source_name: str) -> SourceView: + """数据文件记录 → 绑定了这个快照的 SourceView,供回查、编译核对、行级核对打开数据文件(R14)。 + + 用 table_versions.bind_snapshot,和查询解析源的那条路相同(登记哈希为空拒绝;有快照清单的首次绑定时交叉 + 核对)。传进去的不是数据源记录,而是一个替身: + - id = snapshot.source_id:缓存键(cache_key)和查询时相同,首次整份哈希只算一次; + - name = source_name(查询快照记的数据源名,只用在报错文字里);description、enabled、options 等给缺省值。 + bind_snapshot 直接读 source.description、bool(source.enabled),少了会抛 AttributeError。 + 所以数据源被删了、改名了也能回查:回查只需要快照文件和它的登记哈希。options 为空,查询时限、遮罩都不进 + 这个视图——回查的 SQL 是这里拼的主键查找,遮罩由接口一侧按 R10 先判过。 + + 记录已回收(retired_at)或文件不在时抛 SnapshotMissing(→ snapshot_gone,不标红):不先认出来的话,取引擎时 + 读不到文件会被 EngineCache 报成 SnapshotTampered,误标成「数据文件被改」。SnapshotManifestMismatch、 + SnapshotMissing(登记哈希为空)原样抛出。 + """ + path = str(snapshot.db_path or "") + if snapshot.retired_at is not None or not path or not Path(path).is_file(): + raise SnapshotMissing(_GONE.format(sid=str(snapshot.id)[:12])) + stand_in = SimpleNamespace( + id=snapshot.source_id, name=source_name, description="", enabled=True, options={}, + current_snapshot_id=None, last_check=None, + ) + return bind_snapshot(stand_in, snapshot) + + +# -------------------------------------------------------------------------- +# R15:编译核对 +# -------------------------------------------------------------------------- + + +async def compile_facts(view: SourceView, sql: str) -> CompileFacts: + """在查询读过的同一个文件上编译原 SQL(EXPLAIN,不执行),给出与解析无关的三项事实(R15): + 授权回调报出读了哪些表、字节码里 ResultRow 和 Yield 各有几个。 + + 步骤:经 EngineCache 取引擎(首次时整份核对哈希)→ **同步**取 engines.pinned(view)(中间没有 await,不会被 LRU + 挤掉)→ 在线程里:比指纹 → 单开 immutable 只读连接(和 EngineCache 打开快照的方式相同)→ 装授权回调、EXPLAIN + 一次 → 关连接 → 再比指纹。开前关后的指纹都要等于 EngineCache 核对过的那份,单开的连接读的才是核对过的文件; + 不等抛 SnapshotTampered。pinned 为 None(不是快照引擎)抛 SnapshotMissing。 + + 授权回调只观察、不拦(一律放行):连接本身只读、immutable,EXPLAIN 不执行语句。结论怎么用由接口判(R15 的 + ①②③),这里只报事实。 + """ + await engines.get(view) + pinned = engines.pinned(view) + if pinned is None: + raise SnapshotMissing(_NOT_PINNED.format(sid=str(view.snapshot_id or "")[:12])) + return await asyncio.to_thread(_compile, str(view.database or ""), pinned, sql) + + +def _compile(path: str, pinned: engine_mod.Fingerprint, sql: str) -> CompileFacts: + if _fingerprint(path) != pinned: + raise SnapshotTampered(_REPLACED) + reads: set[str] = set() + + def observe(action: int, arg1: str | None, _arg2: str | None, _db: str | None, _trigger: str | None) -> int: + # SQLITE_READ 的 arg1 是表名(建表时的写法)。子查询、自连接读到的表都会报出来,改名、CTE 报的和合法写法 + # 一样(P4-SPEC 2.4 末尾),所以这只是兜底,主判据仍是识别器 + if action == sqlite3.SQLITE_READ and arg1: + reads.add(arg1) + return sqlite3.SQLITE_OK + + conn = open_checked_sqlite(path, readonly=True, immutable=True) + try: + conn.set_authorizer(observe) + opcodes = [str(row[1]) for row in conn.execute("EXPLAIN " + sql)] + finally: + conn.close() + if _fingerprint(path) != pinned: + raise SnapshotTampered(_REPLACED) + return CompileFacts(tables=reads, result_rows=opcodes.count("ResultRow"), yields=opcodes.count("Yield")) + + +# -------------------------------------------------------------------------- +# R16:按主键回查、强制重算哈希 +# -------------------------------------------------------------------------- + + +async def recheck(view: SourceView, *, table: str, column: str, + pk: dict[str, Any]) -> tuple[list[tuple[int, Any]], str, list[Any]]: + """按主键参数化回查被引用的那一格(R16):返回 (行, sql, params),行是 [(rowid, 值), …]。 + + SQL 的写法以契约 Recheck 为准:`SELECT rowid, "列" FROM "表" WHERE "主键1" = ? AND "主键2" = ?`,标识符一律双引号, + `?` 位置占位符,WHERE 和 params 都按 pk 的键序(调用方按冻结表结构 primary_key 的顺序构造 pk)。面板「技术 + 细节」原样显示这里返回的 sql、params,所以不加 LIMIT:多出的行本身就是 recheck_multiple 的证据。 + + 值经 engine._jsonable 规范化,和查询结果进快照时同一个口径(run_query 逐格调它),调用方直接按类型和值比较。 + 经 EngineCache 打开(同一个缓存键、同一份哈希核对),三类异常原样抛出。 + """ + if not pk: + raise ValueError("回查需要主键") + names = list(pk) + sql = (f"SELECT rowid, {_q(column)} FROM {_q(table)} WHERE " + + " AND ".join(f"{_q(name)} = ?" for name in names)) + params = [pk[name] for name in names] + engine = await engines.get(view) + async with engine.connect() as conn: + result = await conn.exec_driver_sql(sql, tuple(params)) + rows = [(int(r[0]), engine_mod._jsonable(r[1])) for r in result.fetchall()] + return rows, sql, params + + +async def rehash(view: SourceView) -> bool: + """在线程里整份重算数据文件的 sha256,等于 view.expected_sha256 返回 True(R16 的强制重算)。 + + 不经 EngineCache 的指纹捷径:回查的值和快照对不上时才调。EngineCache 只在首次和指纹变化时重算,原地改写同样 + 字节数、再还原修改时间的改动在指纹不变期间认不出(P4-SPEC 2.6);这里强制读一遍,才能把「文件确实被改了」 + 和「这条 SQL 的含义与判据认定的不同」分开。文件读不出来、没有登记哈希时返回 False:核对不了就不能当没变。 + """ + expected = str(view.expected_sha256 or "").strip().lower() + if not expected: + return False + try: + actual = await asyncio.to_thread(engine_mod._file_sha256, str(view.database or "")) + except OSError: + return False + return actual.lower() == expected + + +# -------------------------------------------------------------------------- +# 2.7:关系核对的行级状态 +# -------------------------------------------------------------------------- + + +async def row_status(view: SourceView, rule: SumEqRule, snapshot_rowid: int) -> str | None: + """这一行的关系核对「total = parts 之和」成不成立:passed / mismatch / unverifiable;判不了为 None(2.7)。 + + 条件取自 recipe_checks.sum_eq_conditions,和导入时的核对(_check_sum_eq)是同一段代码生成的:面板说「这一天 + 成立」,和导入时的结论是同一个判断。先判含空值(unverifiable),再判不等(mismatch),其余 passed,与导入时 + 「含空值的行不参与比较、计入无法核对」一致。 + + snapshot_rowid 是**快照库**的 rowid(cell_source.rowid,并集时是并集 rowid),不是该期构建库的 part_rowid: + SQL 跑在快照库上。各期的行是逐字节拷进并集库的,同一个条件照样成立(P3-SPEC 2.7)。 + + 规则里的列在快照库这张表里不存在(不该出现:关系取自格子所在那一期的配方,目标表结构只会多出退役列),或者 + 没有这一行时返回 None,不报错。三类数据文件异常原样抛出。 + """ + _nonnull, anynull, mismatch = sum_eq_conditions(rule.total, list(rule.parts), dict(rule.types)) + sql = (f"SELECT CASE WHEN {anynull} THEN 'unverifiable' WHEN {mismatch} THEN 'mismatch' ELSE 'passed' END " + f"FROM {_q(rule.table)} WHERE rowid = ?") + engine = await engines.get(view) + async with engine.connect() as conn: + present = {name_key(str(r[0])) for r in + (await conn.exec_driver_sql("SELECT name FROM pragma_table_xinfo(?)", (rule.table,))).fetchall()} + if not present or any(name_key(c) not in present for c in [rule.total, *rule.parts]): + return None + row = (await conn.exec_driver_sql(sql, (int(snapshot_rowid),))).first() + return str(row[0]) if row is not None else None + + +# -------------------------------------------------------------------------- +# 导入记录上的当前状态(数据版本一节标「当前状态」的那几项) +# -------------------------------------------------------------------------- + + +async def import_states(session: AsyncSession, import_ids: list[str]) -> dict[str, dict[str, Any]]: + """各期导入记录上的当前状态:{import_id: {raw_state, purged, revoked, recipe_seq}}。 + + - raw_state:kept / purged / absent(table_imports.raw_state); + - purged、revoked:StateNote{at, signed_by, reason},没有记录为 None。清除原件、作废接受都只记在导入记录上, + 不在封存范围内,界面标「当前状态」(P4-SPEC 0.2 补充 2); + - recipe_seq:这一期配方记录的 seq(table_recipes.seq,经 table_imports.recipe_id 找),取不到为 None。 + + 找不到的 import_id 不出现在结果里(调用方按 None 处理)。只取需要的几列:导入记录上还有核对摘要等大字段。 + """ + ids = [str(i) for i in dict.fromkeys(import_ids) if i] + if not ids: + return {} + rows = (await session.execute( + select(TableImport.id, TableImport.raw_state, TableImport.purged, TableImport.revoked, TableImport.recipe_id) + .where(TableImport.id.in_(ids)))).all() + recipe_ids = sorted({str(r.recipe_id) for r in rows if r.recipe_id}) + seqs: dict[str, int] = {} + if recipe_ids: + seqs = {str(rid): int(seq) for rid, seq in (await session.execute( + select(TableRecipe.id, TableRecipe.seq).where(TableRecipe.id.in_(recipe_ids)))).all()} + return {str(r.id): { + "raw_state": r.raw_state or None, + "purged": _note(r.purged), + "revoked": _note(r.revoked), + "recipe_seq": seqs.get(str(r.recipe_id)) if r.recipe_id else None, + } for r in rows} + + +def _note(record: Any) -> StateNote | None: + """table_imports.purged / revoked 的记录 {at, reason, signed_by, …} → StateNote;空的、不是 dict 的为 None。""" + if not isinstance(record, dict) or not record: + return None + + def text(key: str) -> str | None: + value = record.get(key) + return str(value) if value not in (None, "") else None + + return StateNote(at=text("at"), signed_by=text("signed_by"), reason=text("reason")) diff --git a/backend/app/data/provenance_types.py b/backend/app/data/provenance_types.py new file mode 100644 index 00000000..703d49d6 --- /dev/null +++ b/backend/app/data/provenance_types.py @@ -0,0 +1,730 @@ +"""证据下钻(期 4「推断的来源」)的契约:常量、取值、服务端原文、响应形状和各工作包之间传递的中间结构。 + +由编排者在第 0 步(WP-0)提交,各工作包只通过这里的类型和 P4-SPEC.md 第 7 节写明的函数签名交互,**不改签名**; +需要改的写进交付说明,由编排者改。这里只有声明,业务逻辑只有三个小函数(refuse、alert、contract_problems): +接口、测试、前端夹具三处要用同一份原文和同一套不变式。 + +**为什么只用标准库**:识别器(engine/direct_select.py)只许导入守卫、names 和这个模块(P4-SPEC 7.2),这里一旦 +导入 recipe_types、pydantic,判据就被拖进整套配方模块。和 recipe_types 重名的取值(核对状态)在契约测试里钉住一致。 + +**响应形状**(P4-SPEC 2.8.1):`dataclasses.asdict(ProvenanceOut(...))` 就是接口返回的 JSON,键名、顺序和规格逐字 +一致。响应用的 dataclass 一律 kw_only:字段多、并行的几个包各自构造,按位置传参错一位不会报错,按名字传错了立刻报。 +中间结构(Chain、DirectSelect 等)照规格的写法可以按位置构造。 + +**唯一的改名**:CellSource 的「from」是 Python 关键字,不能当属性名。Python 里写 `from_`(构造、读写都用它), +`asdict` / `fields` / `dataclasses.replace` / `typing.get_type_hints`、pydantic 和 JSON 里一律是「from」(见 _json_keys)。 +构造时也认 `**{"from": …}`,从 JSON 读回来可以直接 `CellSource(**d)`。 + +**路由怎么返回**(WP-C):照现有证据路由的写法,`return dataclasses.asdict(out)`,返回注解写 `dict[str, Any]`。 +把 ProvenanceOut 写成返回注解或 `response_model` 也得到同样的 JSON(pydantic 按 `__annotations__` 收集 dataclass +字段,_json_keys 把注解里的名字也改成了「from」,契约测试钉住 TypeAdapter 和 FastAPI 带类型路由的输出等于 asdict), +但不推荐:那样每次响应都要按类型再校验、再序列化一遍,与其余证据路由不一致。 + +**文档标记**(P4-SPEC 1.4):compose_doc 组装的新文档顶层写 `"provenance": DOC_PROVENANCE`。识别器的文法、 +`tables_in` 的读法都绑在这个版本号上;以后放宽文法要升版本号,已有 `provenance: 1` 的文档仍按 1 的文法判。 +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Literal, get_args + +_log = logging.getLogger(__name__) + +# ========================================================================== +# 版本与常量 +# ========================================================================== + +#: 文档标记的版本号:带这个标记的文档才走期 4 的行为(查询步骤的提示字段、推断来源接口、裁判摘录的新行) +DOC_PROVENANCE = 1 +#: 推断来源接口响应的 schema +SCHEMA = "agentlab.provenance/1" + +#: 裁判摘录(P4-SPEC 3.2):来源的分期行最多几期(取最近的几期,其余写「另有 n 期」) +PROV_PARTS = 3 +#: 已接受的理由最多几条 +PROV_ACCEPT = 4 +#: 区域外文字最多几格(每条查询) +PROV_OUTSIDE = 4 +#: 区域外文字每格最多几个字,超出写「…(已截断)」 +PROV_OUTSIDE_CHARS = 200 +#: 列的说明最多几行 +PROV_COLUMN_NOTES = 6 +#: 整块来历(来源到区域外文字,含口径)最多几个字,超出按 3.2 的五步顺序删 +PROV_CHARS = 1200 + +# ========================================================================== +# 取值 +# ========================================================================== + +#: inferred:给出格子;table_only:只给表级来历(version 非空),附原因;none:什么都不画,或只画一句原因 / 红色提示 +Status = Literal["inferred", "table_only", "none"] + +#: 不下钻的原因(P4-SPEC 2.3 的规则表、2.4 的识别器分组)。同一个 code 可能出现在不同 status 下: +#: chain_mismatch 在 R6(清单链本身对不上)是 none,在 R13、R17 和异常映射里是 table_only +ReasonCode = Literal[ + # R0:文档没有标记(期 4 之前组装的) + "legacy_doc", + # R1:不是直接引用的查询单元格(指标、实体、代码节点和 Agent 字段的取数) + "not_cell", + # R2 / R4:查询快照或表结构快照不在本次运行的事件里、取回复验不通过 + "not_sealed", + # R3:查询快照没有 data_version(手工源) + "not_upload", + # R4:表结构快照不是按配方导入的(简单导入、v0 迁移出的版本) + "simple_upload", + # R5(红):清单取不回来或哈希不符 + "manifest_unreadable", + # R6 / R13 / R17 / 异常映射第 1 条(红) + "chain_mismatch", + # R7 / R8 / R9 / R15:SQL 判据(2.4 的分组,细分写在 detail,取值见 DETAILS) + "expression", "alias", "multi_table", "unparsed", + # R9 + "no_pk", "pk_missing", + # R10 + "masked", + # R11 + "null_value", "null_pk", + # R12 / 异常映射第 3 条 + "snapshot_gone", + # 异常映射第 2 条、R16 强制重算后哈希不等(红) + "db_tampered", + # R16 + "recheck_missing", "recheck_multiple", "recheck_mismatch", + # R17 + "no_lineage", +] + +#: 标红的提示。出现时 reason.code 与它相同,界面只画提示、不再画 reason.text(P4-SPEC 4.1、4.2 末尾) +AlertCode = Literal["db_tampered", "chain_mismatch", "manifest_unreadable"] + +#: SQL 判据的拒绝分组(DirectSelect / SelectRefusal,P4-SPEC 2.4) +RefusalCode = Literal["expression", "alias", "multi_table", "unparsed"] + +#: 每个分组允许的细分(Reason.detail / SelectRefusal.detail)。duplicate_pk(R9)、authorizer、compound(R15) +#: 由接口一侧补上,同样归 multi_table。界面只显示分组的原文,detail 给测试和日志用。 +#: +#: 识别器(WP-A)的每个拒绝分支都必须落在这里,测试对每个拒绝用例断言 `detail in DETAILS[code]`。原型 +#: p4-probe/probe_recognizer.py 返回的 expression_or_alias、subquery_or_compound、table_shape 之类是原型自己的名字, +#: 移植时要逐个映射,不能照抄。2.4 没逐条写的几种按文法归: +#: INDEXED BY(表名后面的词被当成省略 AS 的表别名,再往后不是 WHERE / ORDER / LIMIT)→ unparsed · tail_shape; +#: 表函数(表名紧跟「(」)→ unparsed · table_shape;FROM 后面直接是「(」(子查询)→ multi_table · subquery; +#: 带库名的表(main."日客流")→ unparsed · qualified_table;聚合、函数调用、运算 → expression · expression。 +#: 漏了映射时 refuse 不抛异常(照样给分组的原文,细分原样留下、记一条警告),contract_problems 会报出来 +DETAILS: dict[str, tuple[str, ...]] = { + "expression": ("expression", "string_literal", "literal_keyword", "window", "collate", "distinct"), + "alias": ("alias",), + "multi_table": ("join", "comma_join", "subquery", "cte", "compound", "authorizer", "duplicate_pk"), + "unparsed": ("not_select", "multi_statement", "table_shape", "qualified_table", "qualifier", "star_mismatch", + "unknown_table", "unknown_column", "ambiguous_column", "tail_shape"), +} + +#: 单期是配方的 mode,并集是快照清单的 mode +Mode = Literal["replace", "accumulate"] +#: 表的种类:冻结表结构里 kind 缺省(None)就是 data,reported_total 是原表写明的合计 +TableKind = Literal["data", "reported_total"] +#: 回执里列的角色(recipe_types.ColumnOut.role) +ColumnRole = Literal["axis", "dim", "derive", "const", "measure", "value", "text"] +#: 附带的格(FromCell.role):日期表头格、行标签、分段标题、列表头、合计标签(含回执里的 derived_label) +FromRole = Literal["axis_header", "row_label", "section_title", "col_header", "total_label"] +#: 原件状态(table_imports.raw_state,当前状态) +RawState = Literal["kept", "purged", "absent"] +#: 接受的种类:overrides(数据质量类不成立)→ override;waivers(无法核对)→ waiver +AcceptanceKind = Literal["override", "waiver"] +#: 本期结论:照搬清单里 CheckResult.status(与 recipe_types.CheckStatus 相同,契约测试钉住) +PartStatus = Literal["passed", "mismatch", "unverifiable", "info"] +#: 这一行的关系核对结果(只给关系核对) +RowStatus = Literal["passed", "mismatch", "unverifiable"] +#: 这一格的合计核对结果(只给 K、G、T) +CellStatus = Literal["ok", "unverifiable", "unknown", "not_formula"] +#: 年份取自哪里:统计期从格子解析(period)、人工录入(human) +YearFrom = Literal["period", "human"] + +#: loader 的语义同 artifact_store.load:取不到返回 None,哈希不符抛 ValueError +Loader = Callable[[str], Any] + +# ========================================================================== +# 服务端原文(P4-SPEC 4.2):界面原样显示 reason.text、alert.text,不另写 +# ========================================================================== + +_RECHECK_TEXT = "按主键回查数据文件,结果与查询结果不一致,不推断来源" + +#: code → 原文。pk_missing 里的「{列}」由 refuse 用 fmt 填(没带齐的主键列,多列用「、」连) +REASON_TEXT: dict[str, str] = { + "legacy_doc": "这份报告生成于系统升级前,不推断原表格子", + # not_cell、not_upload 界面不画(P4-SPEC 4.1 渲染矩阵),原文只给日志和测试,规格的原文表里没有,由 WP-0 补 + "not_cell": "这一处不是直接引用的查询单元格,不推断原表格子", + "not_sealed": "查询快照或表结构快照不在本次运行的记录里,不推断来源", + "not_upload": "这次查询的数据源不是上传的表格,不推断原表格子", + "simple_upload": "这份表格按表头行直接导入,没有记录原表格子的溯源", + "manifest_unreadable": "导入清单无法读取或与哈希不一致,无法给出来历", + # 规格原文是「…可能被修改过:只显示导入清单记录的来历」。chain_mismatch 也用于 R6(清单链本身对不上, + # version 为 null,什么来历都不显示),那句后半句在 R6 时不属实,所以去掉;table_only 时数据版本一节照样画出来, + # 不需要再说(交付说明里写明) + "chain_mismatch": "数据文件的登记与导入清单不一致,可能被修改过", + "db_tampered": "数据文件与登记的版本不一致,可能被修改过:只显示导入清单记录的来历", + "snapshot_gone": "这一版的数据文件已回收或不存在,无法按主键回查,只显示导入清单记录的来历", + "expression": "这一格是计算、聚合或改写的结果,只给出表级来历", + "alias": "查询给列改了名,无法确认这一格对应哪一列,只给出表级来历", + "multi_table": "查询涉及多张表、同一张表多次,或含子查询、公用表表达式、集合运算,只给出表级来历", + "unparsed": "查询的写法超出可追溯的范围,只给出表级来历", + "no_pk": "这张表没有主键,无法按主键回查,只给出表级来历", + "pk_missing": "查询结果没有带齐主键列({列}),无法按主键回查,只给出表级来历", + "masked": "这一格涉及设置了遮罩的列,不推断来源", + "null_value": "这一格是空值,不推断来源", + "null_pk": "这一行的主键是空值,无法按主键回查", + "recheck_missing": _RECHECK_TEXT, + "recheck_multiple": _RECHECK_TEXT, + "recheck_mismatch": _RECHECK_TEXT, + "no_lineage": "导入清单里没有这一格的溯源记录,只给出表级来历", +} + +#: 标红提示的原文:与同名 reason 的原文相同(两处同时出现时界面只画提示) +ALERT_TEXT: dict[str, str] = {code: REASON_TEXT[code] for code in get_args(AlertCode)} + +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") +#: 回查 SQL 里双引号括起的标识符(内部的双引号写两个):数 `?` 占位符之前先去掉,列名里的问号不算 +_QUOTED = re.compile(r'"(?:[^"]|"")*"') + + +# ========================================================================== +# 响应(GET /api/runs/{run_id}/evidence/segments/{segment_id}/provenance,P4-SPEC 2.8.1) +# ========================================================================== + + +def _stored_as(py: str, js: str) -> property: + """Python 名字 `py` 的属性:读写实例字典里的 `js` 键(_json_keys 用)。没有这个键时抛 AttributeError,hasattr 照常。""" + def get(self: Any) -> Any: + try: + return self.__dict__[js] + except KeyError: + raise AttributeError(py) from None + + def set_(self: Any, value: Any) -> None: + self.__dict__[js] = value + + return property(get, set_) + + +def _json_keys(**aliases: str) -> Callable[[type], type]: + """dataclass 的字段在 Python 里叫 `py`,在 asdict / fields / replace / get_type_hints / pydantic / JSON 里叫 `js` + (只给 CellSource.from 用)。 + + 做法:dataclass 照常按 `py` 生成 __init__、__repr__、__eq__;生成之后 + - 字段表 `__dataclass_fields__` 和注解 `__annotations__` 里的名字都改成 `js`(顺序不变)。只改字段表的话, + pydantic 按注解收集字段,对不上的直接跳过:FastAPI 带类型的路由、TypeAdapter 的输出会悄悄少掉「from」; + - 值存在实例字典的 `js` 键下,`py` 是读写它的属性。pydantic 从 JSON 校验出实例时直接按字段名填实例字典, + 不经过 __init__,存在 `py` 下的话这样得到的实例读 `from_` 会抛 AttributeError; + - __init__ 也认 `js` 这个关键字(dataclasses.replace、`Cls(**json)` 传的是它)。两个都传时以 `py` 为准: + `replace(obj, from_=新值)` 会同时带上旧的 `from`。 + """ + def wrap(cls: type) -> type: + fields_: dict[str, Any] = cls.__dataclass_fields__ # type: ignore[attr-defined] + renamed: dict[str, Any] = {} + for name, f in fields_.items(): + if name in aliases: + f.name = aliases[name] + renamed[f.name] = f + cls.__dataclass_fields__ = renamed # type: ignore[attr-defined] + cls.__annotations__ = {aliases.get(k, k): v for k, v in cls.__dict__["__annotations__"].items()} + for py, js in aliases.items(): + setattr(cls, py, _stored_as(py, js)) + generated = cls.__init__ + + def __init__(self: Any, *args: Any, **kwargs: Any) -> None: + for py, js in aliases.items(): + if js in kwargs: + value = kwargs.pop(js) + kwargs.setdefault(py, value) + generated(self, *args, **kwargs) + + __init__.__doc__ = generated.__doc__ + __init__.__qualname__ = f"{cls.__qualname__}.__init__" + cls.__init__ = __init__ # type: ignore[method-assign] + return cls + return wrap + + +@dataclass(kw_only=True) +class ReportRef: + #: 报告节点 id + node_id: str + #: 报告文档的工件 id + doc_artifact: str + + +@dataclass(kw_only=True) +class CellRef: + """片段本身是单元格引用(cite.kind == "cell",有 alias 和 locator)就给,与 status 无关(legacy_doc 时也给)。""" + + #: 目录里的查询编号(Q3) + alias: str + #: 快照里的行号,从 0 数(locator.row) + row: int + #: 被引用的结果列名(locator.column) + column: str + #: 目录条目的 artifact(查询快照 id);条目没记时 None + artifact: str | None = None + + +@dataclass(kw_only=True) +class Reason: + """status 不是 inferred 时必有。用 refuse() 构造,原文取自 REASON_TEXT。""" + + code: ReasonCode + #: 细分(DETAILS 里的值、ChainProblem / LocateProblem 的 detail);没有细分为空串 + detail: str = "" + text: str + + +@dataclass(kw_only=True) +class Alert: + """标红的提示。用 alert() 构造,原文取自 ALERT_TEXT。""" + + code: AlertCode + text: str + + +@dataclass(kw_only=True) +class TableRef: + """SQL 用到的表:direct_select.tables_in 与冻结表结构的交集,按第一次出现的顺序。""" + + name: str + kind: TableKind = "data" + + +@dataclass(kw_only=True) +class PeriodView: + """一期的统计期(导入清单的 period)。""" + + start: str + end: str + source: Literal["cells", "human"] + #: 解析出统计期的格子,带工作表名(「客流汇总!B2」);人工录入时为空 + cells: list[str] = field(default_factory=list) + #: 人工录入时的署名(未认证) + signed_by: str | None = None + + +@dataclass(kw_only=True) +class AcceptanceView: + """一条接受理由(导入清单 acceptances 的一项,内容寻址,是当时的事实)。 + + 不叫 Acceptance:recipe_types.Acceptance 已有同名类,字段不同。 + """ + + check_id: str + #: 同一份清单 checks 里 id 相同的那条的 title;找不到时 None + title: str | None + kind: AcceptanceKind + reason: str + signed_by: str | None = None + #: 清单里这条接受的 at;老清单没有时 None + at: str | None = None + + +@dataclass(kw_only=True) +class StateNote: + """导入记录上的当前状态(清除原件、作废接受):table_imports.purged / revoked 里的 at、signed_by、reason。""" + + at: str | None = None + signed_by: str | None = None + reason: str | None = None + + +@dataclass(kw_only=True) +class PartView: + """数据版本里的一期(按统计期升序)。前半来自导入清单(内容寻址),raw_state 起的三项来自导入记录(当前状态)。 + + version_view(WP-B)只填清单里的那部分:recipe_seq、raw_state、purged、revoked 留 None,has_row 全 False, + 由接口一侧(WP-C)从数据库补、按格子所在的一期标 has_row。 + """ + + import_id: str + #: 导入记录的 seq(同 ChainPart.seq) + seq: int + #: 导入清单的工件 id(「查看导入清单」按它经工件接口取) + manifest: str + #: 清单没有统计期时 None(界面写「统计期未记录」) + period: PeriodView | None + file_name: str | None + raw_sha256: str | None + #: S9:回执 regions 里块内各角色区域的外接矩形,按工作表(「客流汇总!B4:AG30」),多张表用「、」连;取不到为 None + region: str | None = None + #: S9:回执 rows_excluded 的行数之和;期 3 之前的回执没有这个键时为 None + excluded_rows: int | None = None + recipe_sha256: str | None = None + #: 配方是第几版:取自配方记录(当前状态),取不到为 None + recipe_seq: int | None = None + #: 清单的 created_at + committed_at: str | None = None + #: 清单的 signed_by.name(署名,未认证) + signed_by: str | None = None + #: 这一期的全部接受理由(不只是相关的):先 overrides,再 waivers,各按清单里的顺序 + acceptances: list[AcceptanceView] = field(default_factory=list) + raw_state: RawState | None = None + purged: StateNote | None = None + revoked: StateNote | None = None + #: 这一格所在的一期:inferred 时恰好一项为 True,其余 False + has_row: bool = False + + +@dataclass(kw_only=True) +class VersionView: + """数据版本(表级来历):status 为 none 时整节为 null。""" + + #: 查询快照记的数据源名 + source: str + #: 查询快照的 data_version + snapshot_id: str + mode: Mode + #: 累积并集(表结构快照有 snapshot_manifest) + union: bool + tables: list[TableRef] = field(default_factory=list) + #: 能不能在面板里查看导入清单:数据源设有任何遮罩(当前设置 ∪ 快照记录)时 False。 + #: version_view(WP-B)不知道遮罩,一律给 True,由接口一侧(WP-C)改 + manifest_view: bool = True + parts: list[PartView] = field(default_factory=list) + + +@dataclass(kw_only=True) +class FromCell: + """这一行其余键列各取自哪一格(日期表头格、行标签格、分段标题格、列表头、合计标签)。""" + + role: FromRole + #: 这一格给出的是哪一列的值;宽表指标列的行标签(指标名取自哪一格)为 None + column: str | None + sheet: str + #: 不带工作表名的 A1 坐标(「G4」) + cell: str + #: 只放格子原文:行标签取 labels[].raw,合计标签取 DerivedItem.label_raw;拿不到为 None,不拿规范写法冒充 + text: str | None = None + #: 只给 section_title:配方里的定位文字 locate.title(原表里的字可以不同) + locate_title: str | None = None + + +@dataclass(kw_only=True) +class YearSource: + """年份取自哪里:交叉表的轴是文本日期(text / mixed)且配方 axis.year_from 有值时才有,其余为 null。""" + + source: YearFrom + #: period:统计期的格子(带工作表名);human:空 + cells: list[str] = field(default_factory=list) + #: human:录入统计期时的署名(未认证);period:None + signed_by: str | None = None + #: 这一块的日期表头有的是日期格、有的是文本(AxisOut.form == "mixed"),逐格判断不了 + mixed: bool = False + + +@dataclass(kw_only=True) +class CanonicalView: + """这一行的标签原文和数据库里的值(规范写法)不同时给出(P4-SPEC 2.5 dim 一行、6.2 A8、A18),否则为 null。 + + 比的是这一行的**标签列**:交叉表逆透视出的 dim 列(原文取自 labels[].raw)、合计表的合计项列(原文取自 + DerivedItem.label_raw)。raw 是那一格的原文,canonical 是这一行该列在数据库里的值(即 pk 里那一列的值)。 + **被引用的是值列也给**:A8 合计表那一格是 {"raw": "18-22 时合计", "canonical": "18-22时合计"},累积并集里全角 + 标签那一期的值格是 {"raw": "8-9", "canonical": "8-9"};被引用的就是标签列时同样给(A18)。 + 宽表指标列的行标签是指标名,不是哪一列的值,不给;拿不到逐行原文的(列表形态没有 SegmentLabels)不给。 + """ + + raw: str + canonical: str + + +@dataclass(kw_only=True) +class Recheck: + """按主键参数化回查数据文件的那一句(推断出格子时 ok 恒为 True,不一致的走 table_only)。 + + **占位符用 `?`(位置参数),不用 `:p0`**(规格 2.3 R16 写的 `:p0` 以这里和 2.8.1 为准): + `SELECT rowid, "列" FROM "表" WHERE "主键1" = ? AND "主键2" = ?`。标识符一律双引号、内部的双引号写两个; + WHERE 按冻结表结构 primary_key 的顺序逐列 `= ?` 用 AND 连;params 与占位符一一对应,同样按 primary_key 的顺序 + (等于 CellSource.pk 的值的顺序)。面板「技术细节」原样显示 sql 和 params,两者必须对得上;provenance_db.recheck + 返回的 (行, sql, params) 就是这里的 sql、params。 + """ + + sql: str + #: 与 sql 里的 `?` 按位置一一对应(primary_key 的顺序) + params: list[Any] = field(default_factory=list) + ok: bool + + +@_json_keys(from_="from") +@dataclass(kw_only=True) +class CellSource: + """推断出的格子:只在 inferred 时有,否则 null。 + + locate(WP-B)填格子、附带的格、年份、规范写法、merged_fill、part_seq、part_rowid;pk、rowid、raw_purged、recheck + 由接口一侧(WP-C)回查后填。Python 里「from」写成 from_,JSON 里是「from」(见模块说明)。 + """ + + table: str + column: str + column_role: ColumnRole + kind: TableKind = "data" + #: 主键列 → 值(查询快照里那一行的值),键按冻结表结构 primary_key 的顺序(Recheck.params 同序) + pk: dict[str, Any] = field(default_factory=dict) + #: 快照库的 rowid(并集时是并集 rowid,不是 part_rowid) + rowid: int | None = None + #: 格子所在的那一期的 seq(导入记录的 seq,同 ChainPart.seq)和该期构建库里的 rowid(单期时 part_rowid == rowid) + part_seq: int + part_rowid: int + sheet: str + #: 不带工作表名的 A1 坐标(「G5」) + cell: str + #: 取自 receipt.tables 里这一列的 header、unit;没有为 None + header: str | None = None + unit: str | None = None + from_: list[FromCell] = field(default_factory=list) + year: YearSource | None = None + canonical: CanonicalView | None = None + #: 这一期的原件已清除(导入记录的 raw_state == "purged",当前状态) + raw_purged: bool = False + #: 列表这一块的配方写了 merged_data == "fill":值可能取自合并区域的左上格 + merged_fill: bool = False + recheck: Recheck | None = None + + +@dataclass(kw_only=True) +class RelatedCheck: + """相关核对的一条(进响应,不含行级规则:规则只在服务端内部流转,见 RelatedCheckPlan)。""" + + #: 核对 id(R1、K1、G1、T1、C1…) + id: str + #: CheckResult.kind(relation_sum_eq、relation_not_comparable、derived_sum、formula_refs、column_sum、context_agree…) + kind: str + title: str + part_status: PartStatus + #: 只给关系核对(relation_sum_eq);列在当前目标表结构里不存在时 None + row_status: RowStatus | None = None + #: 只给 K、G、T + cell_status: CellStatus | None = None + #: 挂在这条核对上的接受理由(同一期清单 acceptances 里 check_id 相同的那条) + acceptance: AcceptanceView | None = None + #: K 无法核对时的原因摘要:CheckResult.details 的第一条,可能带数字(系统呈现,不经模型转述) + detail: str | None = None + + +@dataclass(kw_only=True) +class ProvenanceOut: + """推断来源接口的响应。asdict 之后就是 P4-SPEC 2.8.1 的 JSON;不变式见 contract_problems。""" + + schema: str = SCHEMA + report: ReportRef + #: 片段 id + segment: str + cell: CellRef | None = None + status: Status + reason: Reason | None = None + alert: Alert | None = None + #: 运行已封存且封存核对通过(同 sealed.trusted)。False 时照常给结论,界面加一句未封存 + sealed: bool + version: VersionView | None = None + cell_source: CellSource | None = None + checks: list[RelatedCheck] = field(default_factory=list) + + +# ========================================================================== +# 跨包的中间结构(P4-SPEC 7.2–7.4) +# ========================================================================== + + +@dataclass +class DirectSelect: + """识别器认可的直接选取(WP-A recognize 的结果)。""" + + #: 冻结表结构里的表名(原样) + table: str + alias: str | None + #: 第 i 个结果列对应的表列名(原样,已按 name_key 解析);* 已展开 + columns: list[str] + + +@dataclass +class SelectRefusal: + """识别器拒绝:code 是 2.4 的分组,detail 必须是 DETAILS[code] 里的细分值(映射见 DETAILS 上面的说明)。""" + + code: RefusalCode + detail: str + + +@dataclass +class ChainPart: + #: 导入记录的 seq(清单里的 seq、快照清单 parts[].seq),不是在 parts 里的位置:每期替换的第二期只有一项,seq 是 2。 + #: 界面和摘录里的「第 k 次导入」、CellSource.part_seq、PartView.seq 都是它 + seq: int + import_id: str + manifest_id: str + #: 已取回的导入清单 + manifest: dict[str, Any] + #: 表 → [union 起, 止, part 起, part 止](快照清单 parts[].rows 摊平);单期为 None + union_rows: dict[str, list[int]] | None + + +@dataclass +class Chain: + """查询快照 → 表结构快照 → 清单(resolve_chain 的结果,P4-SPEC 2.2)。""" + + #: 查询快照记的数据源名 + source: str + #: 清单里的 source_id(与 source_snapshots.source_id 交叉核对,R13) + source_id: str + #: 查询快照的 data_version + snapshot_id: str + mode: Mode + union: bool + #: 单期:导入清单的 db_sha256;并集:快照清单的 union.db_sha256 + expected_db_sha256: str + #: 按统计期升序 + parts: list[ChainPart] + #: 冻结的表结构快照(已取回) + schema: dict[str, Any] + #: 快照清单(并集才有) + snapshot_manifest: dict[str, Any] | None + + +@dataclass +class ChainProblem: + """resolve_chain 失败:取不回或哈希不符 → manifest_unreadable(R5);链本身对不上 → chain_mismatch(R6)。""" + + code: Literal["manifest_unreadable", "chain_mismatch"] + #: 给测试和日志的细分;规格里写的是 ChainProblem("manifest_unreadable") 这种只给 code 的构造,所以有缺省 + detail: str = "" + + +@dataclass +class Located: + """locate 的结果:格子所在的一期、该期的 rowid、推断出的格子(rowid、pk、recheck 留给 WP-C 填)。""" + + part: ChainPart + part_rowid: int + cell: CellSource + + +@dataclass +class LocateProblem: + """locate 失败:找不到溯源段、锚格所在的块、轴或分段标题 → no_lineage;并集 rowid 越界 → chain_mismatch(红)。""" + + code: Literal["no_lineage", "chain_mismatch"] + detail: str = "" + + +@dataclass +class SumEqRule: + """关系核对的行级规则:只在服务端内部流转,不进响应。WP-C 据此用 sum_eq_conditions 拼 SQL、按快照库的 rowid 跑。""" + + table: str + total: str + parts: list[str] + #: 列 → INTEGER / REAL / TEXT(取自格子所在那一期的 receipt.tables) + types: dict[str, str] + + +@dataclass +class RelatedCheckPlan: + """related_checks 的一项:check 进响应(row_status 由 WP-C 填),rule 是关系核对的行级规则(其余核对为 None)。""" + + check: RelatedCheck + rule: SumEqRule | None + + +@dataclass +class CompileFacts: + """R15 的第二道核对(EXPLAIN 编译、不执行):授权回调读到的表、字节码里 ResultRow 和 Yield 的个数。""" + + tables: set[str] + result_rows: int + yields: int + + +# ========================================================================== +# 小函数 +# ========================================================================== + + +def refuse(code: ReasonCode, detail: str = "", **fmt: Any) -> Reason: + """按 REASON_TEXT 生成 Reason。 + + pk_missing 的原文里有「{列}」,用 fmt 填:`refuse("pk_missing", 列=["日期", "时段"])` →「…主键列(日期、时段)…」, + 列表、元组按「、」连。建议填没带齐的那几列。 + + code 不认识、原文里有占位符却没给对应的 fmt,抛 ValueError:两者都是调用处写死的常量,那个分支的任何一条测试都会 + 暴露,不该带着半截原文上界面。 + + SQL 判据的四个分组带了 DETAILS 之外的细分**不抛**:细分来自识别器对这条 SQL 的判断,取决于数据,没被测试覆盖 + 的写法(INDEXED BY、表函数之类)漏了映射时,抛异常会让推断来源接口回 500,而不是只给表级来历。这时照样按分组给 + 原文,细分原样留下,记一条警告;contract_problems 会把它报出来,接口和验收测试断言 contract_problems 为空就能抓到。 + """ + if code not in REASON_TEXT: + raise ValueError(f"不认识的原因代码:{code!r}") + if code in DETAILS and detail and detail not in DETAILS[code]: + _log.warning("provenance: %s 不认识细分 %r,应为 %s 之一(照样按分组给原文)", code, detail, DETAILS[code]) + template = REASON_TEXT[code] + values = {k: "、".join(str(x) for x in v) if isinstance(v, (list, tuple)) else str(v) for k, v in fmt.items()} + missing = [m for m in _PLACEHOLDER.findall(template) if m not in values] + if missing: + raise ValueError(f"{code} 的原文需要 {missing} 填值") + text = template.format_map(values) if _PLACEHOLDER.search(template) else template + return Reason(code=code, detail=detail, text=text) + + +def alert(code: AlertCode) -> Alert: + """按 ALERT_TEXT 生成标红的提示。""" + if code not in ALERT_TEXT: + raise ValueError(f"不认识的提示代码:{code!r}") + return Alert(code=code, text=ALERT_TEXT[code]) + + +def contract_problems(out: ProvenanceOut) -> list[str]: + """响应的不变式(P4-SPEC 2.3、2.8.1、4.1 渲染矩阵),给接口和验收测试断言用;空列表表示通过。 + + 不在构造时强制:违反不变式是实现的错,应该在测试里暴露,不该在线上把整个请求变成 500。 + """ + out_problems: list[str] = [] + bad = out_problems.append + if out.schema != SCHEMA: + bad(f"schema 应为 {SCHEMA}") + if out.status not in get_args(Status): + bad(f"status 不认识:{out.status!r}") + if out.reason is not None and out.reason.code not in get_args(ReasonCode): + bad(f"reason.code 不认识:{out.reason.code!r}") + if (out.reason is not None and out.reason.code in DETAILS and out.reason.detail + and out.reason.detail not in DETAILS[out.reason.code]): + # 空细分不报:R8 的防御分支规格没给细分,refuse 的缺省也是空串 + bad(f"{out.reason.code} 的细分 {out.reason.detail!r} 不在 DETAILS 里(识别器漏了映射)") + if out.alert is not None: + if out.alert.code not in get_args(AlertCode): + bad(f"alert.code 不认识:{out.alert.code!r}") + if out.reason is None or out.reason.code != out.alert.code: + bad("有 alert 时 reason.code 必须与 alert.code 相同") + if out.reason is not None and out.reason.code in get_args(AlertCode) and out.alert is None: + bad(f"{out.reason.code} 必须同时给 alert(标红)") + if out.status == "inferred": + if out.reason is not None or out.alert is not None: + bad("inferred 时 reason、alert 都应为 null") + if out.version is None or out.cell_source is None: + bad("inferred 时 version、cell_source 都必须有") + else: + cs = out.cell_source + if cs.rowid is None or not cs.pk or cs.recheck is None or not cs.recheck.ok: + bad("inferred 时 cell_source 的 rowid、pk、recheck 必须填好,且 recheck.ok 为真") + elif cs.recheck.params != list(cs.pk.values()) or _QUOTED.sub("", cs.recheck.sql).count("?") != len(cs.pk): + bad("recheck 的 sql 用 ? 占位,个数和 params 都与 pk 一一对应(按 primary_key 的顺序)") + hits = [p for p in out.version.parts if p.has_row] + if len(hits) != 1: + bad(f"inferred 时 version.parts 恰好一项 has_row,现在是 {len(hits)} 项") + elif hits[0].seq != cs.part_seq: + bad("has_row 的那一期与 cell_source.part_seq 不一致") + else: + if out.reason is None: + bad(f"{out.status} 时必须给 reason") + if out.cell_source is not None or out.checks: + bad(f"{out.status} 时 cell_source 为 null、checks 为空") + if out.version is not None and any(p.has_row for p in out.version.parts): + bad("没有推断出格子时 has_row 全为 False") + if out.status == "table_only" and out.version is None: + bad("table_only 时 version 必须有") + if out.status == "none" and out.version is not None: + bad("none 时 version 为 null") + return out_problems diff --git a/backend/app/data/recipe_checks.py b/backend/app/data/recipe_checks.py index 9bc80f27..e095ada7 100644 --- a/backend/app/data/recipe_checks.py +++ b/backend/app/data/recipe_checks.py @@ -446,20 +446,36 @@ def _check_t(sql: _Sql, cid: str, title: str, items: list[DerivedItem], full_cal # ---------------------------------------------------------------- R:关系 -def _check_sum_eq(sql: _Sql, rel: SumEq, title: str, columns: list[ColumnOut], grain: list[str], - lineage: dict[str, dict[str, list[list[Any]]]]) -> CheckResult: - table, total, parts = rel.table, rel.total, list(rel.parts) +def sum_eq_conditions(total: str, parts: list[str], types: dict[str, str]) -> tuple[str, str, str]: + """关系核对「total = parts 之和」逐行判定用的三段 SQL 条件:(nonnull, anynull, mismatch)。 + + - nonnull:参与的列都不是空值(这一行比得上); + - anynull:任一列是空值(这一行无法核对); + - mismatch:合计与各部分之和不等。只在 nonnull 成立时有意义,调用方自己和 nonnull 组合(AND,或者 CASE 里 + 先判 anynull)。有一列是 REAL 时按相对误差 1e-9 比(浮点加法有舍入),全是整数时严格比。 + + 为什么抽出来:导入时的核对(_check_sum_eq)和证据面板的行级核对(provenance_db.row_status,P4-SPEC 2.7) + 必须用同一段代码生成的条件。面板说「这一天成立」,就得和导入时的结论是同一个判断,不能各写一份、日后改了一边。 + types 是列名 → INTEGER / REAL / TEXT(回执里这一期的列类型),缺的列按非 REAL 处理。 + """ names = [total, *parts] nonnull = " AND ".join(f"{_q(c)} IS NOT NULL" for c in names) anynull = " OR ".join(f"{_q(c)} IS NULL" for c in names) - checked = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {nonnull}")[0] - nulls = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {anynull}")[0] - types = {c.name: c.type for c in columns} added = " + ".join(_q(p) for p in parts) if any(types.get(c) == "REAL" for c in names): - cond = f"ABS({_q(total)} - ({added})) > 1e-9 * MAX(1, ABS({_q(total)}))" + mismatch = f"ABS({_q(total)} - ({added})) > 1e-9 * MAX(1, ABS({_q(total)}))" else: - cond = f"{_q(total)} <> {added}" + mismatch = f"{_q(total)} <> {added}" + return nonnull, anynull, mismatch + + +def _check_sum_eq(sql: _Sql, rel: SumEq, title: str, columns: list[ColumnOut], grain: list[str], + lineage: dict[str, dict[str, list[list[Any]]]]) -> CheckResult: + table, total, parts = rel.table, rel.total, list(rel.parts) + nonnull, anynull, cond = sum_eq_conditions(total, parts, {c.name: c.type for c in columns}) + checked = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {nonnull}")[0] + nulls = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {anynull}")[0] + added = " + ".join(_q(p) for p in parts) where = f"{nonnull} AND {cond}" failed = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {where}")[0] picked = ", ".join(["rowid", *(_q(g) for g in grain), _q(total), f"({added})"]) diff --git a/backend/app/data/recipe_engine.py b/backend/app/data/recipe_engine.py index d25961cc..7c33e010 100644 --- a/backend/app/data/recipe_engine.py +++ b/backend/app/data/recipe_engine.py @@ -239,6 +239,11 @@ def _in_spans(spans: list[tuple[int, int]], starts: list[int], x: int) -> bool: return i >= 0 and spans[i][0] <= x <= spans[i][1] +def _hidden_cell(sh: _Sheet, r: int, c: int) -> bool: + """这一格所在的行或列是否被隐藏(OutsideText.hidden)。行列都看:隐藏列里的备注和隐藏行里的一样不外发。""" + return sh.hidden_row(r) or sh.hidden_col(c) + + def _open_sqlite(path: str, *, readonly: bool) -> sqlite3.Connection: """关 DQS 的 sqlite3 连接,和核对模块共用 engine.open_checked_sqlite:关不掉就拒绝连接。""" return _data_engine.open_checked_sqlite(path, readonly=readonly) @@ -2475,7 +2480,10 @@ class _Engine: coord = sh.coord(r, c) sh.roles[role] += 1 sh.regions.feed_row(r, [(c, role)]) - self.ex.outside_text.append(OutsideText(sh.name, coord, text, kind, period_source)) # type: ignore[arg-type] + # 期 4(P4-SPEC 3.2、调整 10):记下这格是否在隐藏行列里。裁判摘录只把确知可见的区域外文字全文外发, + # 隐藏行列里常是内部备注(H10 的依据正是隐藏内容里实测有口令),不能借裁判多一条外发通道 + self.ex.outside_text.append(OutsideText(sh.name, coord, text, kind, period_source, # type: ignore[arg-type] + hidden=_hidden_cell(sh, r, c))) if kind == "text_digits" and not period_source: self.problems.add( "outside_digits", @@ -2551,7 +2559,9 @@ class _Engine: sh.regions.feed_row(r, [(c, "context")]) if not is_pure_period_cell(text, prefer): out.annotated[coord] = period_residue(text) or "" - self.ex.outside_text.append(OutsideText(sh.name, coord, text, "text_digits", True)) + # 带附加文字的统计期格同样是区域外文字,同样记是否隐藏(同 _outside_text) + self.ex.outside_text.append(OutsideText(sh.name, coord, text, "text_digits", True, + hidden=_hidden_cell(sh, r, c))) self.ex.period = out cells = list(out.cells) if failed: diff --git a/backend/app/data/recipe_types.py b/backend/app/data/recipe_types.py index 452c7cb2..7643144f 100644 --- a/backend/app/data/recipe_types.py +++ b/backend/app/data/recipe_types.py @@ -1,4 +1,5 @@ -"""配方导入的契约(期 2,波次 0 由编排者提交;期 3 的改动见 P3-SPEC 9.2,同样由波次 0 提交):配方 JSON 的模型、 +"""配方导入的契约(期 2,波次 0 由编排者提交;期 3 的改动见 P3-SPEC 9.2,同样由波次 0 提交;期 4 只给 OutsideText +加了 hidden,见 P4-SPEC 7.1):配方 JSON 的模型、 执行结果和各模块之间传递的数据结构。 各工作包之间只通过这里的类型和 P2-SPEC.md、P3-SPEC.md 里写明的函数签名交互。这里**只有声明**,没有业务逻辑 @@ -706,6 +707,12 @@ class OutsideText: kind: Literal["text", "text_digits"] #: 这格同时是统计期来源(带附加文字的统计期格,PeriodOut.annotated) period_source: bool = False + #: 期 4(P4-SPEC 7.1,WP-0):这格所在的行或列是否被隐藏,执行器写入(_outside_text、统计期格附带的文字两处)。 + #: True = 在隐藏行列里;False = 可见;None = 导入时没有记录(期 4 之前的清单没有这个键, + #: recipe_imports._known 读老清单时缺键就是 None,不用改读法)。 + #: 裁判摘录只送 hidden is False 的项(P4-SPEC 3.2):隐藏行里的内部备注不能多一条经裁判外发的通道, + #: 不知道是否隐藏的(None)同样只计条数。差异卡、确认项只按坐标、文字、kind、period_source 比较,不看它 + hidden: bool | None = None @dataclass diff --git a/backend/app/engine/direct_select.py b/backend/app/engine/direct_select.py new file mode 100644 index 00000000..b9b5e9c9 --- /dev/null +++ b/backend/app/engine/direct_select.py @@ -0,0 +1,533 @@ +"""期 4「推断的来源」的 SQL 判据:认出「从一张表里直接选取若干列」这一种形状(P4-SPEC 2.4、7.2)。 + +下钻要回答「查询结果的第 i 列是哪张表的哪一列」。只有一张表、每个结果列都是这张表某列的直接引用时,这个问题才有 +唯一的答案;改名、表达式、多表、子查询、集合运算之后,结果列装的可能是另一列的值,值恰好相等时就会下钻到错误的 +格子。所以这里只认一种形状,形状之外一律拒绝:**宁可不下钻,也不能下钻错**。 + +**读法和执行这条 SQL 的 SQLite 相同**:分词用守卫的 `guard._tokens(sql, guard._SQLITE)`。查询快照里存的 SQL 是 +`guard.check(…, dialect="sqlite")` 的返回值,执行的也正是它(engine.run_query)。`_SQLITE` 读法:单引号字符串里 +反斜杠不是转义;`[…]` 是标识符,里面没有转义,遇到第一个 `]` 就结束;`"…"`、`` `…` `` 是标识符,两个引号表示 +一个引号。识别器和数据库读的是同一串记号,中间没有第二种读法。 + +**SQLite 分词的两处细节**(tokenize.c,实测 3.53): +- U+FEFF(字节 EF BB BF,CC_BOM)出现在记号开头时是空白,出现在名字中间时是名字的一部分。不能照「码位 ≥ 0x80 + 一律算名字」读:那样紧跟在 U+FEFF 后面的 UNION 成了一个普通的词,藏在它后面的 UNION ALL、GROUP BY 就看不见了, + SQLite 却照样执行,结果列里混着另一列的值; +- 竖制表符出现在记号开头时是非法字符,紧跟在其他空白后面时是空白(CC_SPACE 的循环用 sqlite3Isspace,含 \\v)。 + 这里一律当空白:SQLite 不当空白的位置整条 SQL 执行不了,不会留下查询快照,判据碰不到;能执行的位置读法相同。 + +**为什么不用 `evidence._sql_scan`**:它按多方言「宁可少认一张表」设计,单引号里认反斜杠转义、方括号里含引号时不当 +标识符。藏在 `ESCAPE '\\'` 或 `AS [t']` 后面的 `UNION ALL` 在它眼里不存在,SQLite 却照样执行(P4-SPEC 附录 B7)。 +对 `sql_tables` 那只是少认一张表,对下钻就是错格子。`_sql_scan`、`sql_tables` 本身不动(H7)。 + +**执行时与存下的字面不同的一处**:engine 经 SQLAlchemy 的 `text()` 执行,它把 `\\:` 改成 `:`、把 `:名字` 当成绑定 +参数。后者没有给值,执行会失败,不会留下查询快照;前者只删掉冒号前的反斜杠,SQLite 读法里反斜杠不影响任何记号的 +边界,只改字符串和带引号名字的内容,而配方导入的表名、列名不含反斜杠和冒号(names.name_problem),对不上任何表列。 + +**分组**(界面显示分组的原文,细分给测试和日志用,取值见 provenance_types.DETAILS): +- expression:表达式、聚合、字符串或字面量关键字、窗口、COLLATE、DISTINCT; +- alias:给列改名(含省略 AS 的改名); +- multi_table:JOIN、逗号连接、子查询、公用表表达式、集合运算; +- unparsed:不是 SELECT、多条语句、表的写法不认识、限定名不是本表、列或表找不到、重名、`*` 对不上、尾部写法不认识。 + +判据绑在文档标记 `DOC_PROVENANCE = 1` 上(P4-SPEC 1.4):以后放宽文法要升版本号、给 recognize 加 version 参数, +已有 `provenance: 1` 的文档仍按这一版判,结论不跟着变。 + +只许导入守卫、names 和 provenance_types(P4-SPEC 7.2):判据是纯函数,不连库,不依赖证据层和接口层。 +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from app.data.guard import _SQLITE, _tokens +from app.data.names import name_key +from app.data.provenance_types import DirectSelect, SelectRefusal + +# ========================================================================== +# 分词:守卫的 SQLite 读法之上,再按 SQLite 的规则把代码段切成词和标点 +# ========================================================================== + +#: SQLite 在记号之间认的空白:CC_SPACE 的五个,加竖制表符。不能用 str.isspace:全角空格、不换行空格在 SQLite 里是 +#: 名字的一部分(除记号开头的 U+FEFF 之外,码位 ≥ 0x80 的字符一律算名字)。竖制表符出现在记号开头时 SQLite 报非法 +#: 记号,紧跟其他空白时当空白;一律当空白是安全的:前一种位置执行不了,后一种读法相同(模块说明) +_SPACE = frozenset(" \t\n\v\f\r") +#: U+FEFF 只在记号开头是空白(CC_BOM),名字中间是名字的一部分(「日\ufeff期」是一个名字),所以不放进 _SPACE、 +#: 只在 _split_code 判断记号开头的地方单独认 +_BOM = "\ufeff" +_ASCII_LETTERS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +_ASCII_DIGITS = frozenset("0123456789") + + +def _name_start(ch: str) -> bool: + """不加引号的名字的开头:ASCII 字母、下划线、码位 ≥ 0x80 的字符(同 SQLite,也同 2 版实体语法)。 + + U+FEFF 例外:出现在记号开头时 SQLite 当空白,调用处(_split_code)先把它判掉,轮不到这里。名字中间的 U+FEFF + 仍算名字:_name_char 借这个函数认后续字符,所以这里不排除它。""" + return ch in _ASCII_LETTERS or ch == "_" or ord(ch) >= 0x80 + + +def _name_char(ch: str) -> bool: + """名字的后续字符:开头字符之外再加 ASCII 数字和 $(SQLite 的 IdChar)。不用 isalnum:「٣」这类非 ASCII + 数字本来就 ≥ 0x80,算名字;ASCII 以外的 isdigit 不能决定切词。""" + return _name_start(ch) or ch in _ASCII_DIGITS or ch == "$" + + +@dataclass(frozen=True) +class _Lex: + #: word:不加引号的词;name:带引号的名字("…" `…` […]);str:字符串;blob:x'…';num:数字;punct:标点; + #: sep:分号 + kind: str + #: word、num、punct 是原文;name、str 是去掉定界符、还原了成对引号的内容 + text: str + #: 在原 SQL 里的结束位置:认 x'…' 时要知道 x 和引号之间有没有空白 + end: int + + +def _split_code(text: str, base: int, out: list[_Lex]) -> None: + """把一段代码(守卫已经把注释、字符串、带引号的名字、分号切出去了)按 SQLite 的规则切成词、数字和标点。 + + 数字只吃数字和小数点:`1e5`、`0x1F` 会被切成「数字 + 词」,`1union` 也会切出 UNION。SQLite 遇到数字后紧跟 + 名字字符会报非法记号、根本不执行,所以这样切只会让判据多看见词、多拒,不会少看见。 + + 循环顶层的每个位置都是一个记号的开头(代码段的开头紧跟在注释、字符串、带引号的名字或分号后面,也是): + U+FEFF 在这里当空白,同 SQLite 的 CC_BOM;进了名字以后由 _name_char 当名字字符吃掉。 + """ + i, n = 0, len(text) + while i < n: + ch = text[i] + if ch in _SPACE or ch == _BOM: + i += 1 + elif _name_start(ch): + j = i + 1 + while j < n and _name_char(text[j]): + j += 1 + out.append(_Lex("word", text[i:j], base + j)) + i = j + elif ch in _ASCII_DIGITS or (ch == "." and i + 1 < n and text[i + 1] in _ASCII_DIGITS): + j = i + 1 + while j < n and (text[j] in _ASCII_DIGITS or text[j] == "."): + j += 1 + out.append(_Lex("num", text[i:j], base + j)) + i = j + else: + out.append(_Lex("punct", ch, base + i + 1)) + i += 1 + + +def _lex(sql: str, *, strict: bool = True) -> list[_Lex] | None: + """整条 SQL → 词序列。引号没闭合时 strict 返回 None(SQLite 会报非法记号,存下的 SQL 不该这样); + 不 strict 时照样切,没闭合的那一段内容取到末尾(tables_in 宁可多认)。 + + 注释当空白跳过:SQLite 也这样读,注释两边的代码各自成词(`SEL/**/ECT` 是两个词)。 + """ + out: list[_Lex] = [] + pos = 0 + for tok in _tokens(sql, _SQLITE): + start, pos = pos, pos + len(tok.text) + if tok.kind == "comment": + continue + if tok.kind == "sep": + out.append(_Lex("sep", ";", pos)) + elif tok.kind in ("string", "ident"): + if tok.close == 0 and strict: + return None + inner = tok.text[tok.open:len(tok.text) - tok.close] + quote = tok.text[0] + if tok.kind == "string": + inner = inner.replace("''", "'") + prev = out[-1] if out else None + # x'00':紧挨在单独一个 x 后面的单引号串是 BLOB 字面量,不是「名字 x 加字符串别名」 + if prev is not None and prev.kind == "word" and prev.text in ("x", "X") and prev.end == start: + out[-1] = _Lex("blob", inner, pos) + else: + out.append(_Lex("str", inner, pos)) + else: + # 方括号里没有转义;双引号、反引号里两个引号表示一个 + if quote in "\"`": + inner = inner.replace(quote * 2, quote) + out.append(_Lex("name", inner, pos)) + else: + _split_code(tok.text, start, out) + return out + + +def _kw(lex: _Lex | None) -> str | None: + """不加引号的词按关键字比较时用的键(只对 ASCII 不区分大小写,同 SQLite);不是词返回 None。 + + 带引号的名字永远不是关键字:`"union"` 是一个叫 union 的列。 + """ + return name_key(lex.text) if lex is not None and lex.kind == "word" else None + + +def _is_name(lex: _Lex | None) -> bool: + return lex is not None and lex.kind in ("word", "name") + + +# ========================================================================== +# 关键字表 +# ========================================================================== + +#: P4-SPEC 2.4:尾部(以及整条语句里)不得出现的词,按细分归组。七组的并集就是规格的那张表,测试钉住 +_COMPOUND = frozenset({"union", "intersect", "except"}) +_CTE = frozenset({"with", "recursive"}) +_SUBQUERY = frozenset({"select", "values"}) +_JOIN = frozenset({"join", "natural", "cross", "inner", "left", "right", "full", "outer"}) +_WINDOW = frozenset({"over", "window"}) +#: GROUP BY / HAVING:结果是聚合出来的 +_AGGREGATE = frozenset({"group", "having"}) +#: 尾部的 FROM、DISTINCT 只会出现在 `IS [NOT] DISTINCT FROM` 这类写法里:不影响结果列,但超出这一版文法 +_TAIL_ODD = frozenset({"from", "distinct"}) +FORBIDDEN = _COMPOUND | _CTE | _SUBQUERY | _JOIN | _WINDOW | _AGGREGATE | _TAIL_ODD + +#: 不加引号时是字面量的关键字(P4-SPEC 2.4)。CURRENT_* 永远是关键字;TRUE、FALSE 只在表里有同名列时才指那一列, +#: 读法取决于表结构,一律拒最保守 +_LITERAL_KEYWORDS = frozenset({"null", "true", "false", "current_date", "current_time", "current_timestamp"}) + +#: SQLite 的保留字里不能当不加引号的名字用的那些:出现在选取项、表名的位置,这一项就是表达式(CASE …、NOT x、 +#: EXISTS (…))或者写法不对。CAST、RAISE、MATCH、GLOB、END、FILTER 之类不在里面:SQLite 允许它们当名字(fallback), +#: 后面紧跟「(」时由下一步按函数调用拒,单独出现、又真有这一列时它就是那一列 +_EXPR_WORDS = frozenset({ + "case", "not", "exists", "when", "then", "else", "select", "distinct", "all", + "from", "where", "group", "having", "order", "limit", "as", "on", "using", + "and", "or", "is", "in", "between", "escape", "collate", "isnull", "notnull", +}) + +#: 选取项后面紧跟这些词:这一项是运算的一部分(`"日期" IS NULL`、`"a" BETWEEN …`),不是省略 AS 的改名 +_OPERATOR_WORDS = frozenset({ + "is", "isnull", "notnull", "not", "and", "or", "in", "like", "glob", "regexp", "match", "between", + "escape", "filter", +}) + +#: 表名后面紧跟这些词时它们不是表别名:子句开头、连接、`INDEXED BY` / `NOT INDEXED`、ON / USING +_AFTER_TABLE = frozenset({ + "where", "order", "limit", "group", "having", "window", "indexed", "not", "on", "using", "as", + *_JOIN, *_COMPOUND, +}) + +#: 尾部允许的开头(P4-SPEC 2.4 文法):只过滤、排序、截取行,不改变结果列的来历 +_TAIL_START = frozenset({"where", "order", "limit"}) + + +def _refuse(code: str, detail: str) -> SelectRefusal: + return SelectRefusal(code, detail) # type: ignore[arg-type] + + +# ========================================================================== +# recognize +# ========================================================================== + + +def _statement(lexes: list[_Lex]) -> list[_Lex] | SelectRefusal: + """去掉尾部的分号;分号后面还有东西就是多条语句。 + + 存下的 SQL 是 guard.check 的返回值,本来就没有分号和注释。P4-SPEC 7.2 写的是「出现就按 multi_statement 拒」, + 但 2.4 的文法写了 `[;]`,原型的 24 例(期望不变)和 6.3 要求注释里的 JOIN、尾分号照样认可,两处矛盾。这里取 + 后者,按 SQLite 的读法接受尾分号、把注释当空白:两者都不改变这条语句选了什么,读法又和执行时相同,不会因此 + 认错;真正的第二条语句照样拒。 + """ + end = len(lexes) + while end and lexes[end - 1].kind == "sep": + end -= 1 + body = lexes[:end] + if any(lx.kind == "sep" for lx in body): + return _refuse("unparsed", "multi_statement") + return body + + +def _global_shape(body: list[_Lex]) -> SelectRefusal | None: + """整条语句里只要出现就不是「一张表的直接选取」的关键字:集合运算、公用表表达式、子查询、连接、窗口。 + + 先于逐项的形状判断:B7 那类写法(`… ESCAPE '\\' UNION ALL …`)不管前面哪一项长什么样,都要报成集合运算。 + 词只取代码段里的:字符串、带引号的名字、注释里出现的不算,和 SQLite 的读法一样。 + """ + words = {_kw(lx) for lx in body[1:] if lx.kind == "word"} + for group, detail, code in ((_COMPOUND, "compound", "multi_table"), (_CTE, "cte", "multi_table"), + (_SUBQUERY, "subquery", "multi_table"), (_JOIN, "join", "multi_table"), + (_WINDOW, "window", "expression")): + if words & group: + return _refuse(code, detail) + return None + + +@dataclass +class _Item: + qualifier: str | None + column: str + + +def _item_start(lex: _Lex | None) -> SelectRefusal | None: + """选取项的第一个记号不是名字时,这一项是什么。是名字返回 None。""" + if lex is None: + return _refuse("unparsed", "not_select") + if lex.kind == "str": + return _refuse("expression", "string_literal") + kw = _kw(lex) + if kw in _LITERAL_KEYWORDS: + return _refuse("expression", "literal_keyword") + if kw in _EXPR_WORDS or not _is_name(lex): + # 数字、blob、括号、正负号、CASE / CAST / NOT … + return _refuse("expression", "expression") + return None + + +def _after_item(lex: _Lex | None) -> SelectRefusal: + """选取项后面只能紧跟「,」或 FROM(P4-SPEC 2.4)。其余的分别是什么。""" + kw = _kw(lex) + if lex is None: + # 没有 FROM:不是从表里选 + return _refuse("unparsed", "table_shape") + if kw == "as": + return _refuse("alias", "alias") + if kw == "collate": + return _refuse("expression", "collate") + if kw in _OPERATOR_WORDS: + return _refuse("expression", "expression") + if lex.kind in ("name", "str") or (lex.kind == "word" and kw not in _EXPR_WORDS): + # `"日期" "分区乙"`、`"日期" '分区乙'`、`"日期" 别名`:SQLite 都当省略 AS 的改名 + return _refuse("alias", "alias") + # 「(」是函数调用,其余标点、数字是运算 + return _refuse("expression", "expression") + + +def _select_list(body: list[_Lex], k: int) -> tuple[list[_Item] | None, int] | SelectRefusal: + """解析选取项,返回 (项列表,FROM 的位置)。`*` 单独出现时项列表为 None。""" + if k < len(body) and body[k].kind == "punct" and body[k].text == "*": + nxt = body[k + 1] if k + 1 < len(body) else None + if _kw(nxt) != "from": + if nxt is not None and nxt.kind == "punct" and nxt.text == ",": + # `*, "日期"`:* 只许单独出现,否则结果列和表的列对不上位置 + return _refuse("unparsed", "star_mismatch") + return _after_item(nxt) + return None, k + 1 + items: list[_Item] = [] + while True: + lex = body[k] if k < len(body) else None + if lex is not None and lex.kind == "punct" and lex.text == "*": + # `"日期", *` + return _refuse("unparsed", "star_mismatch") + refusal = _item_start(lex) + if refusal is not None: + return refusal + assert lex is not None + k += 1 + qualifier: str | None = None + column = lex.text + if k < len(body) and body[k].kind == "punct" and body[k].text == ".": + target = body[k + 1] if k + 1 < len(body) else None + if target is not None and target.kind == "punct" and target.text == "*": + # `t.*`:不在这一版文法里 + return _refuse("unparsed", "star_mismatch") + if not _is_name(target): + return _refuse("expression", "expression") + assert target is not None + qualifier, column = lex.text, target.text + k += 2 + if k < len(body) and body[k].kind == "punct" and body[k].text == ".": + # `main.日客流.日期`:带库名的限定名 + return _refuse("unparsed", "qualifier") + items.append(_Item(qualifier, column)) + nxt = body[k] if k < len(body) else None + if nxt is not None and nxt.kind == "punct" and nxt.text == ",": + k += 1 + continue + if _kw(nxt) == "from": + return items, k + return _after_item(nxt) + + +def _from_clause(body: list[_Lex], k: int) -> tuple[str, str | None, int] | SelectRefusal: + """FROM 后面:表名、可选的表别名。返回 (表名,别名,尾部的起点)。""" + lex = body[k] if k < len(body) else None + if lex is not None and lex.kind == "punct" and lex.text == "(": + # FROM (SELECT …);`FROM (日客流)` 在 SQLite 里也能执行,一律不认 + return _refuse("multi_table", "subquery") + if not _is_name(lex) or _kw(lex) in _AFTER_TABLE | _EXPR_WORDS: + # 字符串当表名(SQLite 的历史兼容)、数字、缺表名 + return _refuse("unparsed", "table_shape") + assert lex is not None + table = lex.text + k += 1 + nxt = body[k] if k < len(body) else None + if nxt is not None and nxt.kind == "punct" and nxt.text == ".": + return _refuse("unparsed", "qualified_table") + if nxt is not None and nxt.kind == "punct" and nxt.text == "(": + # 表函数(json_each(…)) + return _refuse("unparsed", "table_shape") + alias: str | None = None + if _kw(nxt) == "as": + target = body[k + 1] if k + 1 < len(body) else None + if not _is_name(target) or _kw(target) in _AFTER_TABLE | _EXPR_WORDS: + # `AS '别名'`:SQLite 认字符串当别名,这一版文法不认 + return _refuse("unparsed", "table_shape") + assert target is not None + alias = target.text + k += 2 + elif nxt is not None and (nxt.kind == "name" + or (nxt.kind == "word" and _kw(nxt) not in _AFTER_TABLE | _EXPR_WORDS)): + alias = nxt.text + k += 1 + return table, alias, k + + +def _tail(body: list[_Lex], k: int) -> SelectRefusal | None: + """尾部只能以 WHERE / ORDER / LIMIT 开头,里面不得有 P4-SPEC 2.4 那张表里的词。 + + 整条语句里的集合运算、子查询、连接、窗口已经在 _global_shape 拦了,这里只剩 GROUP / HAVING(聚合)和尾部的 + FROM、DISTINCT。 + """ + if k >= len(body): + return None + head = body[k] + kw = _kw(head) + if kw not in _TAIL_START: + if head.kind == "punct" and head.text == ",": + return _refuse("multi_table", "comma_join") + if kw in _AGGREGATE: + return _refuse("expression", "expression") + # INDEXED BY、NOT INDEXED、第二个别名、括号…… + return _refuse("unparsed", "tail_shape") + words = {_kw(lx) for lx in body[k:] if lx.kind == "word"} + if words & _AGGREGATE: + return _refuse("expression", "expression") + if words & _TAIL_ODD: + return _refuse("unparsed", "tail_shape") + return None + + +def _index(names: Iterable[str]) -> dict[str, str | None]: + """name_key → 原名。两个名字撞了键(冻结表结构里不该出现)时记 None:说不清是哪一个,按找不到处理。""" + out: dict[str, str | None] = {} + for name in names: + key = name_key(name) + out[key] = None if key in out else name + return out + + +def recognize(sql: str, *, result_columns: list[str], tables: dict[str, list[str]] + ) -> DirectSelect | SelectRefusal: + """tables:冻结表结构里的 {表名: [列名…]}(按定义顺序)。 + + 认可(P4-SPEC 2.4 的文法)→ DirectSelect;否则 → SelectRefusal(code, detail)。纯函数,不连库,不抛异常。 + 文法对应 DOC_PROVENANCE = 1(1.4);以后放宽文法时加 version 参数,不改这一版的结论。 + + 判断顺序:分词和语句个数 → 是不是 SELECT(WITH 开头是公用表表达式)→ 整条语句里的集合运算、公用表表达式、 + 子查询、连接、窗口 → 选取项 → FROM → 尾部 → 名字解析(表、限定名、列、重名)→ 结果列与选取项逐个对应。 + """ + lexes = _lex(sql) + if lexes is None: + return _refuse("unparsed", "not_select") + body = _statement(lexes) + if isinstance(body, SelectRefusal): + return body + first = _kw(body[0]) if body else None + if first == "with": + return _refuse("multi_table", "cte") + if first != "select": + return _refuse("unparsed", "not_select") + refusal = _global_shape(body) + if refusal is not None: + return refusal + + k = 1 + if _kw(body[k] if k < len(body) else None) == "all": + k += 1 + if _kw(body[k] if k < len(body) else None) == "distinct": + return _refuse("expression", "distinct") + parsed = _select_list(body, k) + if isinstance(parsed, SelectRefusal): + return parsed + items, k = parsed + shape = _from_clause(body, k + 1) + if isinstance(shape, SelectRefusal): + return shape + table_written, alias, k = shape + refusal = _tail(body, k) + if refusal is not None: + return refusal + + # ---- 名字解析:一律按 name_key(只对 ASCII 不区分大小写,同 SQLite) ---- + table = _index(tables).get(name_key(table_written)) + if table is None: + return _refuse("unparsed", "unknown_table") + defined = list(tables[table]) + if items is None: + # `*`:结果列必须和表的列按 name_key 逐个相等、顺序一致,才能按位置对应 + if [name_key(c) for c in result_columns] != [name_key(c) for c in defined]: + return _refuse("unparsed", "star_mismatch") + return DirectSelect(table, alias, defined) + # 有别名时只认别名:SQLite 里表起了别名之后,原表名不能再当限定名(`SELECT 日客流.日期 FROM 日客流 AS t` + # 报 no such column),所以这一条比「表名或别名」严,严出来的只是执行不了的写法 + owner = name_key(alias if alias is not None else table_written) + if any(it.qualifier is not None and name_key(it.qualifier) != owner for it in items): + return _refuse("unparsed", "qualifier") + columns_index = _index(defined) + columns: list[str] = [] + for it in items: + column = columns_index.get(name_key(it.column)) + if column is None: + return _refuse("unparsed", "unknown_column") + if column in columns: + # `SELECT "日期", "日期"`、`SELECT Key, key`:locate_cell 按第一次出现取,说不清是哪一个 + return _refuse("unparsed", "ambiguous_column") + columns.append(column) + # 结果列按位置对应第 i 个选取项,不按名字认(SQLite 返回的列名有时是原写法、有时是定义名,P4-SPEC 2.4)。 + # 个数必须相等;名字只作一致性核对:直接引用的列,SQLite 返回的名字按 name_key 一定等于那一列, + # 不等说明这份结果不是这条 SQL 的,不下钻 + if len(result_columns) != len(columns) or any( + name_key(r) != name_key(c) for r, c in zip(result_columns, columns)): + return _refuse("unparsed", "unknown_column") + return DirectSelect(table, alias, columns) + + +# ========================================================================== +# tables_in +# ========================================================================== + +#: 这些词后面紧跟的单引号串,SQLite 当表名读(`FROM '日客流'` 能执行,历史兼容)。IN:SQLite 文法 `expr IN nm` +#: 的 nm 可以是字符串,`WHERE 日期 IN '单列表'` 读的是那张表(列数和左边对得上就能执行,行值也行) +_TABLE_BEFORE_STRING = frozenset({"from", "join", "in"}) +#: 逗号后面的单引号串当表名读的子句:FROM 列表里(含 ON / USING 之后接着逗号连接) +_TABLE_LIST_CLAUSES = frozenset({"from", "join", "on", "using"}) +#: 切换子句的词:tables_in 据此判断逗号后面是不是还在 FROM 列表里 +#: 不含 AS:`FROM a AS x, '时段客流'` 的逗号仍在 FROM 列表里 +_CLAUSE_WORDS = frozenset({ + "select", "from", "join", "where", "group", "having", "order", "limit", "on", "using", "window", "values", + "union", "intersect", "except", "with", +}) + + +def tables_in(sql: str, tables: dict[str, list[str]]) -> list[str]: + """SQL 里出现的冻结表结构的表名(原样),按第一次出现的顺序去重。读法同 recognize,宁可多认(P4-SPEC 2.4)。 + + 每个带引号的名字、代码里的每个词都按 name_key 对表名:列名或别名恰好和某张表同名时会多出这张表,后果只是 + 摘录里多一行说明;漏认的后果是裁判拿不到「原表写明的合计,不要相加」。不用 2 版的 `sql_tables`:它的读法和 + SQLite 不同,藏在 `ESCAPE '\\'` 后面的合计表会漏掉。 + + 字符串一般不算(`WHERE 分区 = '日客流'` 不是读表),只有 SQLite 会把它当表名读的位置才算:紧跟 FROM / JOIN / + IN、紧跟「.」(`main.'日客流'`),以及 FROM 列表里的逗号、括号之后。「紧跟」按 SQLite 的分词算:中间隔着的空白 + (含记号开头的 U+FEFF、空白后面的竖制表符)不算记号。注释里的不算:SQLite 不读注释。 + """ + index = _index(tables) + found: dict[str, None] = {} + lexes = _lex(sql, strict=False) or [] + clause: str | None = None + prev: _Lex | None = None + for lx in lexes: + candidate: str | None = None + if lx.kind in ("word", "name"): + candidate = lx.text + elif lx.kind == "str" and prev is not None: + pkw = _kw(prev) + if (pkw in _TABLE_BEFORE_STRING or (prev.kind == "punct" and prev.text == ".") + or (prev.kind == "punct" and prev.text in ",(" and clause in _TABLE_LIST_CLAUSES)): + candidate = lx.text + if candidate is not None: + table = index.get(name_key(candidate)) + if table is not None: + found.setdefault(table, None) + kw = _kw(lx) + if kw in _CLAUSE_WORDS: + clause = kw + prev = lx + return list(found) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index b2e7a7a2..52865556 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -54,6 +54,7 @@ from typing import Any, Literal, TypedDict from app.core.artifact_store import canonical_json, content_hash from app.core.artifact_store import load as load_artifact from app.data.names import FILLERS, name_key +from app.data.provenance_types import DOC_PROVENANCE from app.data.tabular import UNSHAPED_NOTE from app.engine.expressions import CellError, cell_value, column_kind, locate_cell from app.engine.issuance import _tolerance, extract_numbers, number_allowance @@ -2600,8 +2601,11 @@ def compose_doc( if c.get("status") == "resolved" and c.get("alias")} kept = {a: e for a, e in catalog.items() if e.get("kind") not in ENTITY_KINDS or a in used} # entity_syntax:这份文档按哪一版实体语法组装,复核照它走(verify_doc)。在文档顶层,参与内容哈希 + # provenance:期 4 的文档标记(P4-SPEC 1.4)。带它的文档才有查询步骤的提示字段、推断来源、裁判摘录的新行; + # 没有它的(期 4 之前组装的)一律按原样展示。写在顶层、参与内容哈希,事后补不上:补了哈希就变,封存链就断 doc: dict[str, Any] = { - "schema": DOC_SCHEMA, "entity_syntax": snaps.syntax, "run_id": run_id, "node_id": node_id, + "schema": DOC_SCHEMA, "entity_syntax": snaps.syntax, "provenance": DOC_PROVENANCE, + "run_id": run_id, "node_id": node_id, "markdown": markdown, "source": source, "catalog": kept, "blocks": out_blocks, } checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps) diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py index ec1f5639..b505d883 100644 --- a/backend/app/engine/judge.py +++ b/backend/app/engine/judge.py @@ -9,7 +9,8 @@ 哪一格和那次查询的 SQL;查询给 SQL 前 300 字和被引用的行(最多 20 行,数据源遮罩的列不给;宽表先给 被引用的列,再补满 12 列);表附上字段清单和类型(最多 60 个,遮罩的列不列,快照不完整要注明), 字段附上类型和所属的表,两样都附上出现过的查询 SQL;知识库片段最多 600 字。快照一律经 loader 取 - (按需裁判传只认封存工件的那个)。规则里写明数字已经由系统核对过 + (按需裁判传只认封存工件的那个)。规则里写明数字已经由系统核对过。带期 4 文档标记的报告,上传表格的 + 查询在 SQL 之后另有来历行(来源、口径、已接受、区域外文字,_provenance_lines) 3. 调用(judge_doc):一份报告合并成一次调用,超过每批的句数就分批;结构化输出失败退回 JSON 解析, 再失败就记未裁判。thinking 关掉,max_tokens 4096 4. 预算:每份报告的句数、金额、时长和全局每日金额,每一项都能是 None(不限)。调用前按目录价估算, @@ -28,6 +29,7 @@ from __future__ import annotations import asyncio import json +import logging import math import re import time @@ -43,6 +45,8 @@ from app.core.artifact_store import canonical_json, content_hash from app.core.artifact_store import load as load_artifact from app.core.errors import describe_exception, not_configured from app.core.events import EventType +# 只用标准库的契约模块:取文档标记的版本号。识别器、清单模块(direct_select、provenance)在函数体内导入(_provenance_lines) +from app.data.provenance_types import DOC_PROVENANCE from app.data.tabular import UNSHAPED_NOTE from app.db.base import SessionLocal from app.db.models import Setting @@ -66,6 +70,8 @@ from app.engine.state import message_text from app.providers import catalog as pricing from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model +logger = logging.getLogger(__name__) + # -------------------------------------------------------------------------- # 常量 # -------------------------------------------------------------------------- @@ -328,7 +334,12 @@ def _shown_columns(columns: list[str], cited: Iterable[str]) -> list[str]: def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iterable[str], whole: bool, - fetch: _Fetch, masked: dict[str, Any] | None) -> str | None: + fetch: _Fetch, masked: dict[str, Any] | None, *, provenance_on: bool = False) -> str | None: + """一条查询的摘录:头、SQL、(期 4)来历行、列、被引用的行。 + + provenance_on:文档带期 4 标记时为真(prepare 算)。这时上传表格的查询快照(有 data_version)在 SQL 之后、「列:」 + 之前多几行来历(_provenance_lines);其余情况一个字都不多,期 4 之前的文档、手工源的摘录和以前逐字相同。 + """ snapshot = fetch(entry.get("artifact")) if not isinstance(snapshot, dict) or not isinstance(snapshot.get("rows"), list): return None @@ -339,6 +350,8 @@ def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iter sql = _clean(str(snapshot.get("sql") or "")) lines = [head, f"SQL:{sql[:SQL_CHARS]}{'…' if len(sql) > SQL_CHARS else ''}"] hidden = _hidden(snapshot, source, masked) + if provenance_on and snapshot.get("data_version"): + lines += _provenance_lines(snapshot, fetch, hidden) columns = [str(c) for c in snapshot.get("columns") or [] if str(c).lower() not in hidden] shown_cols = _shown_columns(columns, cols) # 点名引用的行先给,整份引用的再从头补满,一共不超过 EXCERPT_ROWS 行 @@ -361,6 +374,34 @@ def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iter return "\n".join(lines) +def _provenance_lines(snapshot: dict[str, Any], fetch: _Fetch, hidden: set[str]) -> list[str]: + """上传表格的查询快照的来历行(P4-SPEC 3.2、3.3):provenance.judge_lines 生成,这里只负责取冻结的表结构快照、 + 算 SQL 用到的表(direct_select.tables_in,和执行这条 SQL 的 SQLite 同一种读法,宁可多认),把现成的遮罩交过去。 + + - 两个模块在函数体内导入:judge 被设置接口等地方导入,不能因此拉进整套配方模块;也只有带标记的文档、上传 + 表格的快照才走到这里,期 4 之前的文档一次都不碰它们(测试用桩钉住); + - 工件一律经同一个 fetch 取(按需裁判时是只认封存工件、带标记才扩展到哈希链上清单的那个 loader),取不到、 + 哈希不符的都当没有:judge_lines 据此写「导入清单无法读取…」那一行,不中断摘录; + - 确定:同样的工件永远得到同样的行(摘录进断点指纹)。生成过程中出了意外(工件形状不对)同样落到那一行, + 报告节点的裁判不因一份坏清单整体失败。 + """ + from app.data.provenance import SOURCE_BROKEN, judge_lines + from app.engine.direct_select import tables_in + + schema_id = snapshot.get("schema_artifact") + schema = fetch(schema_id) if schema_id else None + schema = schema if isinstance(schema, dict) else None + frozen = schema.get("tables") if schema is not None else None + tables = {str(name): [str(c.get("name")) for c in (meta.get("columns") or []) if isinstance(c, dict)] + for name, meta in frozen.items() if isinstance(meta, dict)} if isinstance(frozen, dict) else {} + try: + used = tables_in(str(snapshot.get("sql") or ""), tables) if tables else [] + return judge_lines(snapshot, schema, fetch, tables=used, hidden=hidden) + except Exception: # noqa: BLE001 - 坏形状的工件只让这条查询少了来历,不让整份报告判不了 + logger.warning("judge: 生成来历行失败", exc_info=True) + return [SOURCE_BROKEN] + + def _sql_line(label: str, sql: str) -> str: return f"{label}:{sql[:SQL_CHARS]}{'…' if len(sql) > SQL_CHARS else ''}" @@ -557,7 +598,9 @@ def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], who def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _Fetch, - masked: dict[str, Any] | None, catalog: dict[str, Any]) -> str | None: + masked: dict[str, Any] | None, catalog: dict[str, Any], *, provenance_on: bool = False) -> str | None: + """一件证据的摘录。provenance_on 只交给查询摘录(_query_excerpt):指标摘录里「输入取自查询 Qn」的那几行不加 + 来历(P4-SPEC 调整 1),表名、字段名的摘录照旧。""" kind = entry.get("kind") if kind == "metric": return _metric_excerpt(alias, entry, fetch, catalog, masked) @@ -567,7 +610,8 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _ if kind == "query": rows = {r for c in cands for a, r in c.rows if a == alias} cols = [col for c in cands for a, col in c.cols if a == alias] - return _query_excerpt(alias, entry, rows, cols, any(alias in c.whole for c in cands), fetch, masked) + return _query_excerpt(alias, entry, rows, cols, any(alias in c.whole for c in cands), fetch, masked, + provenance_on=provenance_on) if kind == "retrieval": picked = [h for c in cands for a, h in c.hits if a == alias] return _retrieval_excerpt(alias, entry, picked, any(alias in c.whole for c in cands), fetch) @@ -666,14 +710,19 @@ def prepare(doc: dict[str, Any], catalog: dict[str, Any] | None = None, *, units """裁判的输入:候选句和证据摘录。纯函数(快照经 loader 取,缺省读工件库),不调模型。 catalog 缺省用文档自己存的目录;masked = {数据源名: 要遮的列},和快照里记下的 mask_columns 合在一起。 + + 摘录按文档标记分版本(P4-SPEC 1.4、3.3):带 `provenance: DOC_PROVENANCE` 的文档,上传表格的查询摘录多几行 + 来历;没有标记的(期 4 之前组装的)和以前逐字相同,复用键、断点指纹都不变,已判过的句子照旧复用。 """ catalog = catalog if catalog is not None else (doc.get("catalog") or {}) cands = candidates(doc, units) fetch = _Fetch(loader) + provenance_on = doc.get("provenance") == DOC_PROVENANCE excerpts: dict[str, str] = {} for alias in dict.fromkeys(a for c in cands for a in c.aliases): entry = catalog.get(alias) - if isinstance(entry, dict) and (text := _excerpt(alias, entry, cands, fetch, masked, catalog)): + if isinstance(entry, dict) and (text := _excerpt(alias, entry, cands, fetch, masked, catalog, + provenance_on=provenance_on)): excerpts[alias] = text picked = {c.unit for c in cands} skipped: dict[str, str] = {} diff --git a/backend/tests/fixtures/evidence/pre_p4_golden.json b/backend/tests/fixtures/evidence/pre_p4_golden.json new file mode 100644 index 00000000..4ab0b5fa --- /dev/null +++ b/backend/tests/fixtures/evidence/pre_p4_golden.json @@ -0,0 +1,6201 @@ +{ + "_comment": "期 4「期 4 之前不变」的金样(P4-SPEC 6.6),WP-0 在提交 2cdbf9a 的代码上生成(临时 worktree,工作区与该提交一致),生成脚本 /Users/yilunjiang/.claude/jobs/842f6f9a/tmp/excel/p4-golden/gen.py(仓库外,job 删除后就没了;本文件自带全部输入,不依赖它重跑)。只用假名、合成数据,没有调用真实模型。artifacts 是三份报告用到的全部输入工件(查询快照、表结构快照、导入清单),WP-E 逐个 put_json 进临时数据目录、断言返回的 id 等于键,再比输出。reports.:catalog = 文档自己存的目录(doc.catalog),markdown = 写作者的原文(带标记,等于 doc.source);outputs 的四项是这样算的——verify_doc = evidence.verify_doc(doc, catalog, loader=artifact_store.load) 的 ok / stats / violations;excerpts = judge.prepare(doc, catalog, loader=artifact_store.load).excerpts;chain = {片段 id: api.evidence._chain(seg, doc, sealed, 'write')},只取 cite.kind == 'cell' 的片段,sealed = _Sealed(run_id, graph={}, seal=sealed.seal, events=[], queries=sealed.queries, schemas=sealed.schemas),_chain 内部 new 的 _Masks 换成预填 memo 的替身(_memo = {源名: (masks 里的列转小写, True)},不查库);doc = 报告节点存下的文档原样(没有 provenance 键),doc_artifact 是它的 id。生成时另用真接口 GET /evidence/segments/ 对照过 chain 逐字相同。", + "commit": "2cdbf9a", + "script": "/Users/yilunjiang/.claude/jobs/842f6f9a/tmp/excel/p4-golden/gen.py", + "artifacts": { + "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f": { + "column_types": { + "全日客流": "number", + "分区甲": "number", + "日期": "text" + }, + "columns": [ + "日期", + "全日客流", + "分区甲" + ], + "data_version": "b598811b6ed9731645b29dbba7955cc9ec6a8c554ac09514f75c8e9e1698c3cc", + "elapsed_ms": 1, + "row_count": 1, + "rows": [ + [ + "2026-08-05", + 8754, + 6159 + ] + ], + "schema_artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'", + "truncated": false + }, + "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49": { + "import_manifests": [ + "33b7de9b2fb50bf10157f9c3d3bba7061d2f21583869b2581c184e072c78918f" + ], + "import_mode": "recipe", + "schema": null, + "source": "flow_demo", + "synced_at": "2026-10-02T01:33:39.344258+00:00", + "tables": { + "日客流": { + "columns": [ + { + "comment": "格式 YYYY-MM-DD,年份按统计期补全", + "name": "日期", + "nullable": false, + "type": "TEXT" + }, + { + "comment": "单位:人次", + "name": "全日客流", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": "单位:人次", + "name": "分区甲", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": "单位:人次", + "name": "分区乙", + "nullable": true, + "type": "INTEGER" + } + ], + "comment": "粒度:每个日期。全日客流 等于 分区甲 与 分区乙 之和(导入时已逐日核对):求合计只用 全日客流,不要把这几列相加。与 时段客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加", + "is_view": false, + "primary_key": [ + "日期" + ], + "qualified": "日客流", + "schema": null + }, + "时段客流": { + "columns": [ + { + "comment": "格式 YYYY-MM-DD,年份按统计期补全", + "name": "日期", + "nullable": false, + "type": "TEXT" + }, + { + "comment": null, + "name": "时段", + "nullable": false, + "type": "TEXT" + }, + { + "comment": null, + "name": "时段类别", + "nullable": true, + "type": "TEXT" + }, + { + "comment": null, + "name": "起始小时", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": null, + "name": "结束小时", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": "单位:人次。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数", + "name": "客流", + "nullable": true, + "type": "INTEGER" + } + ], + "comment": "粒度:每个日期的每个时段。与 日客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加。时段 按规范写法存储:起始小时-结束小时,不带单位。时段类别 取自原表的分段标题。原表的合计在 时段客流_表内合计,不要与本表相加", + "is_view": false, + "primary_key": [ + "日期", + "时段" + ], + "qualified": "时段客流", + "schema": null + }, + "时段客流_表内合计": { + "columns": [ + { + "comment": "格式 YYYY-MM-DD,年份按统计期补全", + "name": "日期", + "nullable": false, + "type": "TEXT" + }, + { + "comment": null, + "name": "合计项", + "nullable": false, + "type": "TEXT" + }, + { + "comment": null, + "name": "起始小时", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": null, + "name": "结束小时", + "nullable": true, + "type": "INTEGER" + }, + { + "comment": "单位:人次", + "name": "客流", + "nullable": true, + "type": "INTEGER" + } + ], + "comment": "粒度:每个日期的每个合计项。原表写明的合计,已按明细重算核对一致。各合计项覆盖的时段可能互相重叠:不要彼此相加,也不要与 时段客流 相加", + "is_view": false, + "kind": "reported_total", + "primary_key": [ + "日期", + "合计项" + ], + "qualified": "时段客流_表内合计", + "schema": null + } + }, + "total": 3, + "truncated": false + }, + "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a": { + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + }, + "columns": [ + "日期", + "时段", + "客流" + ], + "data_version": "b598811b6ed9731645b29dbba7955cc9ec6a8c554ac09514f75c8e9e1698c3cc", + "elapsed_ms": 0, + "row_count": 1, + "rows": [ + [ + "2026-08-20", + "13-14", + 123 + ] + ], + "schema_artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-08-20' AND \"时段\" = '13-14'", + "truncated": false + }, + "33b7de9b2fb50bf10157f9c3d3bba7061d2f21583869b2581c184e072c78918f": { + "acceptances": { + "overrides": [], + "waivers": [] + }, + "ai": { + "consents": [], + "usage": [] + }, + "build_id": "1830dca142272c0190591aa9dc040f497badd030e40b0a0b6d7da4fa4c4ace60", + "build_reused": false, + "checks": [ + { + "acceptable": false, + "category": "data_quality", + "cells": [ + "客流汇总!B2" + ], + "checked": 1, + "details": [ + "客流汇总!B2:2026-08-01 至 2026-08-31" + ], + "failed": 0, + "id": "C1", + "kind": "context_agree", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "统计期多处一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 1, + "details": [], + "failed": 0, + "id": "C2", + "kind": "filename_period", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "文件名中的日期与统计期一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 93, + "details": [ + "93 格全部一致" + ], + "failed": 0, + "id": "K1", + "kind": "derived_sum", + "params": [ + "2026-08-01", + "夜间", + 18, + 22 + ], + "reasons": {}, + "sql": "SELECT TOTAL(\"客流\"), COUNT(\"客流\"), COUNT(*) FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段类别\" = ? AND \"起始小时\" >= ? AND \"结束小时\" <= ?", + "status": "passed", + "title": "合计按明细重算(分段「夜间合计」)", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 93, + "details": [ + "93 格全部一致" + ], + "failed": 0, + "id": "G1", + "kind": "formula_refs", + "params": [ + "2026-08-01", + "夜间", + 18, + 22 + ], + "reasons": {}, + "sql": "SELECT rowid FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段类别\" = ? AND \"起始小时\" >= ? AND \"结束小时\" <= ?", + "status": "passed", + "title": "合计公式引用(分段「夜间合计」)", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 31, + "details": [ + "31 行全部成立" + ], + "failed": 0, + "id": "R1", + "kind": "relation_sum_eq", + "params": [], + "reasons": {}, + "sql": "SELECT rowid, \"日期\", \"全日客流\", (\"分区甲\" + \"分区乙\") FROM \"日客流\" WHERE \"全日客流\" IS NOT NULL AND \"分区甲\" IS NOT NULL AND \"分区乙\" IS NOT NULL AND \"全日客流\" <> \"分区甲\" + \"分区乙\" ORDER BY rowid", + "status": "passed", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "info", + "cells": [], + "checked": 31, + "details": [ + "各时段之和与全日客流:31 天中 0 天相等" + ], + "failed": 31, + "id": "R2", + "kind": "relation_not_comparable", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT \"日期\", TOTAL(\"客流\") AS __sum FROM \"时段客流\" GROUP BY \"日期\") a JOIN \"日客流\" b ON a.\"日期\" = b.\"日期\" WHERE a.__sum = b.\"全日客流\"", + "status": "info", + "title": "口径不同:「时段客流」与「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "31 行,与格子账一致" + ], + "failed": 0, + "id": "N1", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"日客流\"", + "status": "passed", + "title": "行数:「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "527 行,与格子账一致" + ], + "failed": 0, + "id": "N2", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"时段客流\"", + "status": "passed", + "title": "行数:「时段客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "93 行,与格子账一致" + ], + "failed": 0, + "id": "N3", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"时段客流_表内合计\"", + "status": "passed", + "title": "行数:「时段客流_表内合计」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P1", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"日客流\" GROUP BY \"日期\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P2", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"时段客流\" GROUP BY \"日期\", \"时段\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「时段客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P3", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"时段客流_表内合计\" GROUP BY \"日期\", \"合计项\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「时段客流_表内合计」", + "unverifiable": 0 + } + ], + "confirmations": [ + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "placeholder:·", + "label": "「·」存为空值(表示无数据,本期 31 格)" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "derived:夜间合计", + "label": "第 28–30 行改作核对,原值另存表「时段客流_表内合计」" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "year_from:交叉表", + "label": "「交叉表」的日期年份取自统计期" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "relation:R1", + "label": "每期核对「全日客流 = 分区甲 + 分区乙」(表「日客流」)" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "relation:R2", + "label": "表「时段客流」与表「日客流」口径不同,不互相推算或相加" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "unit:日客流.全日客流", + "label": "「全日客流(人次)」→ 表「日客流」的列「全日客流」,单位 人次" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "unit:日客流.分区甲", + "label": "「分区甲(人次)」→ 表「日客流」的列「分区甲」,单位 人次" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "unit:日客流.分区乙", + "label": "「分区乙(人次)」→ 表「日客流」的列「分区乙」,单位 人次" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "unit:时段客流.客流", + "label": "表「时段客流」的列「客流」,单位 人次" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "unit:时段客流_表内合计.客流", + "label": "表「时段客流_表内合计」的列「客流」,单位 人次" + }, + { + "at": "2026-10-02T01:33:39.336079+00:00", + "id": "mode", + "label": "导入模式:每期替换" + } + ], + "created_at": "2026-10-02T01:33:39.345578+00:00", + "db_sha256": "4de6be98c3e8d1808dc5f8e329448b317a19eb648bcba601cdf28387e04a6115", + "edits": [], + "engine_ver": "recipe/1", + "file": { + "name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "afe99620545ca170a711d3ac7d6db2e358c27f5214ce576b727c6d69683715c1", + "size": 9757 + }, + "format": "agentlab-import-manifest/1", + "import_id": "71b96b3a25e54562b69c5e3919169ef2", + "kind": "first", + "notes": { + "rendered": { + "日客流": { + "columns": { + "全日客流": "单位:人次", + "分区乙": "单位:人次", + "分区甲": "单位:人次", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期。全日客流 等于 分区甲 与 分区乙 之和(导入时已逐日核对):求合计只用 全日客流,不要把这几列相加。与 时段客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加" + }, + "时段客流": { + "columns": { + "客流": "单位:人次。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期的每个时段。与 日客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加。时段 按规范写法存储:起始小时-结束小时,不带单位。时段类别 取自原表的分段标题。原表的合计在 时段客流_表内合计,不要与本表相加" + }, + "时段客流_表内合计": { + "columns": { + "客流": "单位:人次", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期的每个合计项。原表写明的合计,已按明细重算核对一致。各合计项覆盖的时段可能互相重叠:不要彼此相加,也不要与 时段客流 相加" + } + }, + "templates": { + "日客流": "粒度:每个{列:日期}。{列:全日客流} 等于 {列:分区甲} 与 {列:分区乙} 之和(导入时已逐日核对):求合计只用 {列:全日客流},不要把这几列相加。与 {表:时段客流} 口径不同:各{列:时段}之和不等于 {列:全日客流},不能互相推算或相加", + "日客流.全日客流": "单位:{单位:人次}", + "日客流.分区乙": "单位:{单位:人次}", + "日客流.分区甲": "单位:{单位:人次}", + "日客流.日期": "格式 YYYY-MM-DD,年份按统计期补全", + "时段客流": "粒度:每个{列:日期}的每个{列:时段}。与 {表:日客流} 口径不同:各{列:时段}之和不等于 {列:全日客流},不能互相推算或相加。{列:时段} 按规范写法存储:{列:起始小时}-{列:结束小时},不带单位。{列:时段类别} 取自原表的分段标题。原表的合计在 {表:时段客流_表内合计},不要与本表相加", + "时段客流.客流": "单位:{单位:人次}。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数", + "时段客流.日期": "格式 YYYY-MM-DD,年份按统计期补全", + "时段客流_表内合计": "粒度:每个{列:日期}的每个{列:合计项}。原表写明的合计,已按明细重算核对一致。各{列:合计项}覆盖的时段可能互相重叠:不要彼此相加,也不要与 {表:时段客流} 相加", + "时段客流_表内合计.客流": "单位:{单位:人次}", + "时段客流_表内合计.日期": "格式 YYYY-MM-DD,年份按统计期补全" + } + }, + "parser_ver": "parsers/2", + "period": { + "annotated": {}, + "cells": [ + "客流汇总!B2" + ], + "end": "2026-08-31", + "signed_by": null, + "source": "cells", + "start": "2026-08-01", + "texts": { + "客流汇总!B2": "统计时间范围:2026年8月1日至2026年8月31日" + } + }, + "receipt": { + "axes": [ + { + "block": "交叉表", + "count": 31, + "first": "2026-08-01", + "form": "text", + "last": "2026-08-31", + "row": 4 + } + ], + "blank_rows_skipped": 0, + "block_order": { + "客流汇总": [ + "日客流", + "日间", + "夜间", + "夜间合计" + ] + }, + "canonicalized": [], + "context_checks": [ + { + "acceptable": false, + "category": "data_quality", + "cells": [ + "客流汇总!B2" + ], + "checked": 1, + "details": [ + "客流汇总!B2:2026-08-01 至 2026-08-31" + ], + "failed": 0, + "id": "C1", + "kind": "context_agree", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "统计期多处一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 1, + "details": [], + "failed": 0, + "id": "C2", + "kind": "filename_period", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "文件名中的日期与统计期一致", + "unverifiable": 0 + } + ], + "derived": [ + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(C22:C25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 342, + 373, + 404, + 435 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1917 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(D22:D25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 343, + 374, + 405, + 436 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1531 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(E22:E25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 344, + 375, + 406, + 437 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2114 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(F22:F25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 345, + 376, + 407, + 438 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2001 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(G22:G25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 346, + 377, + 408, + 439 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2035 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(H22:H25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 347, + 378, + 409, + 440 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2804 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(I22:I25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 348, + 379, + 410, + 441 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2842 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(J22:J25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 349, + 380, + 411, + 442 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2066 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(K22:K25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 350, + 381, + 412, + 443 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1997 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(L22:L25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 351, + 382, + 413, + 444 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1862 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(M22:M25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 352, + 383, + 414, + 445 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1646 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(N22:N25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 353, + 384, + 415, + 446 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2854 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(O22:O25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 354, + 385, + 416, + 447 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2803 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(P22:P25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 355, + 386, + 417, + 448 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1532 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Q22:Q25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 356, + 387, + 418, + 449 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2651 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(R22:R25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 357, + 388, + 419, + 450 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1608 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(S22:S25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 358, + 389, + 420, + 451 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2239 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(T22:T25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 359, + 390, + 421, + 452 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1636 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(U22:U25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 360, + 391, + 422, + 453 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2270 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(V22:V25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 361, + 392, + 423, + 454 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2566 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(W22:W25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 362, + 393, + 424, + 455 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1261 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(X22:X25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 363, + 394, + 425, + 456 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2255 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Y22:Y25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 364, + 395, + 426, + 457 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2005 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Z22:Z25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 365, + 396, + 427, + 458 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1943 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AA22:AA25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 366, + 397, + 428, + 459 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2113 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AB22:AB25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 367, + 398, + 429, + 460 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1280 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AC22:AC25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 368, + 399, + 430, + 461 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1661 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AD22:AD25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 369, + 400, + 431, + 462 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1722 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AE22:AE25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 370, + 401, + 432, + 463 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1322 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AF22:AF25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 371, + 402, + 433, + 464 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1467 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AG22:AG25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 372, + 403, + 434, + 465 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1867 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(C26:C27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 466, + 497 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1552 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(D26:D27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 467, + 498 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 881 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(E26:E27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 468, + 499 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1000 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(F26:F27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 469, + 500 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 459 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(G26:G27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 470, + 501 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 671 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(H26:H27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 471, + 502 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1172 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(I26:I27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 472, + 503 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1574 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(J26:J27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 473, + 504 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 304 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(K26:K27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 474, + 505 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1150 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(L26:L27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 475, + 506 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 997 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(M26:M27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 476, + 507 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 991 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(N26:N27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 477, + 508 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 415 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(O26:O27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 478, + 509 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 218 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(P26:P27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 479, + 510 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1154 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Q26:Q27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 480, + 511 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1063 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(R26:R27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 481, + 512 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 556 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(S26:S27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 482, + 513 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 437 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(T26:T27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 483, + 514 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 998 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(U26:U27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 484, + 515 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 524 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(V26:V27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 485, + 516 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1212 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(W26:W27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 486, + 517 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 518 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(X26:X27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 487, + 518 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1084 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Y26:Y27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 488, + 519 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1116 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Z26:Z27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 489, + 520 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 347 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AA26:AA27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 490, + 521 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 421 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AB26:AB27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 491, + 522 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1092 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AC26:AC27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 492, + 523 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 719 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AD26:AD27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 493, + 524 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 522 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AE26:AE27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 494, + 525 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1014 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AF26:AF27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 495, + 526 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 914 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AG26:AG27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 496, + 527 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 364 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(C22:C27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 342, + 373, + 404, + 435, + 466, + 497 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3469 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(D22:D27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 343, + 374, + 405, + 436, + 467, + 498 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2412 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(E22:E27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 344, + 375, + 406, + 437, + 468, + 499 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3114 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(F22:F27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 345, + 376, + 407, + 438, + 469, + 500 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2460 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(G22:G27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 346, + 377, + 408, + 439, + 470, + 501 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2706 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(H22:H27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 347, + 378, + 409, + 440, + 471, + 502 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3976 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(I22:I27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 348, + 379, + 410, + 441, + 472, + 503 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 4416 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(J22:J27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 349, + 380, + 411, + 442, + 473, + 504 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2370 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(K22:K27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 350, + 381, + 412, + 443, + 474, + 505 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3147 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(L22:L27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 351, + 382, + 413, + 444, + 475, + 506 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2859 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(M22:M27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 352, + 383, + 414, + 445, + 476, + 507 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2637 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(N22:N27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 353, + 384, + 415, + 446, + 477, + 508 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3269 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(O22:O27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 354, + 385, + 416, + 447, + 478, + 509 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3021 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(P22:P27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 355, + 386, + 417, + 448, + 479, + 510 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2686 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Q22:Q27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 356, + 387, + 418, + 449, + 480, + 511 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3714 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(R22:R27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 357, + 388, + 419, + 450, + 481, + 512 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2164 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(S22:S27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 358, + 389, + 420, + 451, + 482, + 513 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2676 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(T22:T27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 359, + 390, + 421, + 452, + 483, + 514 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2634 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(U22:U27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 360, + 391, + 422, + 453, + 484, + 515 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2794 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(V22:V27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 361, + 392, + 423, + 454, + 485, + 516 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3778 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(W22:W27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 362, + 393, + 424, + 455, + 486, + 517 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1779 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(X22:X27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 363, + 394, + 425, + 456, + 487, + 518 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3339 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Y22:Y27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 364, + 395, + 426, + 457, + 488, + 519 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3121 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Z22:Z27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 365, + 396, + 427, + 458, + 489, + 520 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2290 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AA22:AA27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 366, + 397, + 428, + 459, + 490, + 521 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2534 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AB22:AB27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 367, + 398, + 429, + 460, + 491, + 522 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2372 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AC22:AC27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 368, + 399, + 430, + 461, + 492, + 523 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2380 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AD22:AD27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 369, + 400, + 431, + 462, + 493, + 524 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2244 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AE22:AE27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 370, + 401, + 432, + 463, + 494, + 525 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2336 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AF22:AF27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 371, + 402, + 433, + 464, + 495, + 526 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2381 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AG22:AG27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 372, + 403, + 434, + 465, + 496, + 527 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2231 + } + ], + "derived_form": { + "夜间合计": "formula" + }, + "expected_rows": { + "日客流": 31, + "时段客流": 527, + "时段客流_表内合计": 93 + }, + "formula_cells_accepted": 0, + "full_calc_on_load": false, + "hidden": {}, + "ignored_columns": {}, + "labels": [ + { + "canonical": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ], + "raw": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ], + "rows": [ + 5, + 6, + 7 + ], + "segment": "日客流" + }, + { + "canonical": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ], + "raw": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ], + "rows": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "segment": "日间" + }, + { + "canonical": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ], + "raw": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ], + "rows": [ + 22, + 23, + 24, + 25, + 26, + 27 + ], + "segment": "夜间" + }, + { + "canonical": [ + "18-22时合计", + "22-24时合计", + "18-24时合计" + ], + "raw": [ + "18-22 时合计", + "22-24 时合计", + "18-24 时合计" + ], + "rows": [ + 28, + 29, + 30 + ], + "segment": "夜间合计" + } + ], + "ledger": [ + { + "nonempty_read": 771, + "nonempty_scan": 771, + "roles": { + "col_header": 31, + "context": 1, + "derived_label": 3, + "derived_value": 93, + "outside_text": 1, + "row_label": 20, + "section_title": 2, + "value": 620 + }, + "sheet": "客流汇总", + "unclaimed": 0 + } + ], + "lineage": { + "日客流": { + "全日客流": [ + [ + 1, + "客流汇总", + "C5", + 31, + "right" + ] + ], + "分区乙": [ + [ + 1, + "客流汇总", + "C7", + 31, + "right" + ] + ], + "分区甲": [ + [ + 1, + "客流汇总", + "C6", + 31, + "right" + ] + ] + }, + "时段客流": { + "客流": [ + [ + 1, + "客流汇总", + "C10", + 31, + "right" + ], + [ + 32, + "客流汇总", + "C11", + 31, + "right" + ], + [ + 63, + "客流汇总", + "C12", + 31, + "right" + ], + [ + 94, + "客流汇总", + "C13", + 31, + "right" + ], + [ + 125, + "客流汇总", + "C14", + 31, + "right" + ], + [ + 156, + "客流汇总", + "C15", + 31, + "right" + ], + [ + 187, + "客流汇总", + "C16", + 31, + "right" + ], + [ + 218, + "客流汇总", + "C17", + 31, + "right" + ], + [ + 249, + "客流汇总", + "C18", + 31, + "right" + ], + [ + 280, + "客流汇总", + "C19", + 31, + "right" + ], + [ + 311, + "客流汇总", + "C20", + 31, + "right" + ], + [ + 342, + "客流汇总", + "C22", + 31, + "right" + ], + [ + 373, + "客流汇总", + "C23", + 31, + "right" + ], + [ + 404, + "客流汇总", + "C24", + 31, + "right" + ], + [ + 435, + "客流汇总", + "C25", + 31, + "right" + ], + [ + 466, + "客流汇总", + "C26", + 31, + "right" + ], + [ + 497, + "客流汇总", + "C27", + 31, + "right" + ] + ] + }, + "时段客流_表内合计": { + "客流": [ + [ + 1, + "客流汇总", + "C28", + 31, + "right" + ], + [ + 32, + "客流汇总", + "C29", + 31, + "right" + ], + [ + 63, + "客流汇总", + "C30", + 31, + "right" + ] + ] + } + }, + "ok": true, + "outside_text": [ + { + "cell": "客流汇总!B3", + "kind": "text", + "period_source": false, + "sheet": "客流汇总", + "text": "客流汇总表" + } + ], + "partial": false, + "period": { + "annotated": {}, + "cells": [ + "客流汇总!B2" + ], + "end": "2026-08-31", + "signed_by": null, + "source": "cells", + "start": "2026-08-01", + "texts": { + "客流汇总!B2": "统计时间范围:2026年8月1日至2026年8月31日" + } + }, + "placeholders": { + "·": 31 + }, + "regions": [ + { + "block": null, + "ref": "B2", + "role": "context", + "sheet": "客流汇总" + }, + { + "block": null, + "ref": "B3", + "role": "outside_text", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C4:AG4", + "role": "col_header", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B5:B7", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C5:AG7", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B9", + "role": "section_title", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B10:B20", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C10:AG20", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B21", + "role": "section_title", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B22:B27", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C22:AG27", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B28:B30", + "role": "derived_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C28:AG30", + "role": "derived_value", + "sheet": "客流汇总" + } + ], + "rows_excluded": [], + "sheets": { + "matched": { + "s1": "客流汇总" + }, + "other_visible": [], + "renamed": {}, + "skipped_hidden": [] + }, + "table_hashes": { + "日客流": "2d025419b2e226b885219f44f5a06c67fcaa47bf0b5f0c52c5ca524ca0710f47", + "时段客流": "d1e7c94ee47f0956be775aebafeb1d094893fd01d8414d8e4d5ffa1f4d65ed3a", + "时段客流_表内合计": "a929d1b010f7653fb663ece8a590c591a9ee732ad57bd2a31f5a64fb6fb1e096" + }, + "tables": [ + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": "全日客流(人次)", + "name": "全日客流", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + }, + { + "header": "分区甲(人次)", + "name": "分区甲", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + }, + { + "header": "分区乙(人次)", + "name": "分区乙", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期" + ], + "kind": "data", + "name": "日客流", + "rows": 31, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/日客流" + ] + }, + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "时段", + "role": "dim", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "时段类别", + "role": "const", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "起始小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "结束小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "客流", + "role": "value", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期", + "时段" + ], + "kind": "data", + "name": "时段客流", + "rows": 527, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/日间", + "s1/交叉表/夜间" + ] + }, + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "合计项", + "role": "dim", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "起始小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "结束小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "客流", + "role": "value", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期", + "合计项" + ], + "kind": "reported_total", + "name": "时段客流_表内合计", + "rows": 93, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/夜间合计" + ] + } + ], + "timings": { + "extract": 0.0018, + "match": 0.0005, + "read_locate": 0.009, + "receipt": 0.0, + "total": 0.0142, + "write": 0.0028 + } + }, + "recipe": { + "canonical": { + "recipe_format": "agentlab-recipe/2", + "relations": [ + { + "claims": "F1", + "id": "R1", + "kind": "sum_eq", + "parts": [ + "分区甲", + "分区乙" + ], + "table": "日客流", + "total": "全日客流" + }, + { + "a": { + "table": "时段客流", + "value": "客流" + }, + "b": { + "table": "日客流", + "value": "全日客流" + }, + "by": "日期", + "claims": "F2", + "id": "R2", + "kind": "not_comparable" + } + ], + "sheets": [ + { + "blocks": [ + { + "id": "交叉表", + "layout": "crosstab", + "segments": [ + { + "id": "日客流", + "labels": { + "expect": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ] + }, + "locate": { + "by": "labels" + }, + "measures": { + "全日客流(人次)": "全日客流", + "分区乙(人次)": "分区乙", + "分区甲(人次)": "分区甲" + }, + "role": "measures", + "table": "日客流" + }, + { + "const": { + "时段类别": { + "pick": "日间" + } + }, + "dim": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "name": "时段", + "parser": "hour_range" + }, + "id": "日间", + "labels": { + "expect": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ] + }, + "locate": { + "by": "section_title", + "title": "日间时段客流(人次)" + }, + "role": "dimension", + "table": "时段客流", + "value": "客流" + }, + { + "const": { + "时段类别": { + "pick": "夜间" + } + }, + "dim": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "name": "时段", + "parser": "hour_range" + }, + "id": "夜间", + "labels": { + "expect": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ] + }, + "locate": { + "by": "section_title", + "title": "夜间时段客流(人次)" + }, + "role": "dimension", + "stop_parser": "hour_range_total", + "table": "时段客流", + "value": "客流" + }, + { + "id": "夜间合计", + "keep_as": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "dim": "合计项", + "table": "时段客流_表内合计", + "value": "客流" + }, + "labels": { + "expect": [ + "18-22 时合计", + "22-24 时合计", + "18-24 时合计" + ] + }, + "locate": { + "by": "after", + "segment": "夜间" + }, + "role": "derived", + "verify": { + "against_table": "时段客流", + "value": "客流" + } + } + ], + "values": { + "placeholders": [ + { + "text": "·" + } + ] + } + } + ], + "context": [ + { + "prefer_prefix": "统计时间范围" + } + ], + "id": "s1", + "match": { + "name": "客流汇总" + } + } + ], + "tables": [ + { + "grain": [ + "日期" + ], + "name": "日客流", + "units": { + "全日客流": "人次", + "分区乙": "人次", + "分区甲": "人次" + } + }, + { + "grain": [ + "日期", + "时段" + ], + "name": "时段客流", + "units": { + "客流": "人次" + } + }, + { + "grain": [ + "日期", + "合计项" + ], + "kind": "reported_total", + "name": "时段客流_表内合计", + "units": { + "客流": "人次" + } + } + ] + }, + "id": "b880020ef3bd4d8aa24147b076c5e914", + "origin": "manual", + "sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4" + }, + "seq": 1, + "signed_by": { + "name": null, + "verified": false + }, + "snapshot_id": "b598811b6ed9731645b29dbba7955cc9ec6a8c554ac09514f75c8e9e1698c3cc", + "source_id": "b1f1c3075b9d4078a80ff1ab82286084", + "staging_id": "bf999c8a428241bb911cb5b9ddac886d", + "table_hashes": { + "日客流": "2d025419b2e226b885219f44f5a06c67fcaa47bf0b5f0c52c5ca524ca0710f47", + "时段客流": "d1e7c94ee47f0956be775aebafeb1d094893fd01d8414d8e4d5ffa1f4d65ed3a", + "时段客流_表内合计": "a929d1b010f7653fb663ece8a590c591a9ee732ad57bd2a31f5a64fb6fb1e096" + }, + "trial_db_sha256": "4de6be98c3e8d1808dc5f8e329448b317a19eb648bcba601cdf28387e04a6115" + }, + "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09": { + "column_types": { + "地区": "text", + "订单数": "number", + "金额": "number" + }, + "columns": [ + "地区", + "金额", + "订单数" + ], + "data_version": "45cf34e6c2b7279b008bd9b412c01d656174f32132f2adb1b6786c8f3bb14d2c", + "elapsed_ms": 1, + "row_count": 1, + "rows": [ + [ + "分区甲", + 31847.5, + 412 + ] + ], + "schema_artifact": "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042", + "source": "table_demo", + "sql": "SELECT \"地区\", \"金额\", \"订单数\" FROM \"月报\" WHERE \"地区\" = '分区甲'", + "truncated": false + }, + "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042": { + "schema": null, + "source": "table_demo", + "synced_at": "2026-10-02T01:33:39.386473+00:00", + "tables": { + "月报": { + "columns": [ + { + "comment": null, + "name": "地区", + "nullable": true, + "type": "TEXT" + }, + { + "comment": null, + "name": "金额", + "nullable": true, + "type": "REAL" + }, + { + "comment": null, + "name": "订单数", + "nullable": true, + "type": "INTEGER" + } + ], + "comment": "本表按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和", + "is_view": false, + "primary_key": [], + "qualified": "月报", + "schema": null + } + }, + "total": 1, + "truncated": false + }, + "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c": { + "column_types": { + "地区": "text", + "订单数": "number" + }, + "columns": [ + "地区", + "订单数" + ], + "elapsed_ms": 1, + "row_count": 1, + "rows": [ + [ + "分区乙", + 466 + ] + ], + "source": "manual_demo", + "sql": "SELECT \"地区\", \"订单数\" FROM \"订单汇总\" WHERE \"地区\" = '分区乙'", + "truncated": false + } + }, + "reports": { + "recipe": { + "kind": "recipe", + "source": "flow_demo", + "run_id": "48e40062765b426b92deee03058b9437", + "node_id": "write", + "doc_artifact": "daecc69b34d8309723c82487a5324d8994384f6cc0d4224cfbea5ff9dc48d7f8", + "artifacts": [ + "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a", + "33b7de9b2fb50bf10157f9c3d3bba7061d2f21583869b2581c184e072c78918f" + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "columns": [ + "日期", + "全日客流", + "分区甲" + ], + "eid": "ev:query:828cde2471078041", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "a538298bd0a870e64e811abbf0118ca9be0a78e4786c2fb305e21f6e3a6d6dae" + }, + "Q2": { + "alias": "Q2", + "artifact": "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a", + "columns": [ + "日期", + "时段", + "客流" + ], + "eid": "ev:query:c682c08b0a21e943", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "fetch2", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "31ca1e8d7114a1d0a51409dfd357805811e6540004438afb11a720fc1a8be2e9" + }, + "t:日客流": { + "alias": "t:日客流", + "artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "eid": "ev:table:e2a0f482244a2118", + "kind": "table", + "label": "表 日客流", + "locator": { + "table": "日客流" + }, + "name": "日客流", + "queries": [ + "Q1" + ], + "source": "flow_demo", + "sources": [ + { + "artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "kind": "schema", + "source": "flow_demo" + }, + { + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "kind": "sql", + "source": "flow_demo" + } + ] + } + }, + "markdown": "## 客流概况\n\n2026-08-05 的全日客流为 [[v:Q1.r0.全日客流]] 人次,其中分区甲为 [[v:Q1.r0.分区甲]] 人次。\n\n2026-08-20 的 [[v:Q2.r0.时段]] 时段客流为 [[v:Q2.r0.客流]] 人次。\n\n表 [[t:日客流]] 按日期记录全日客流与各分区客流。", + "sealed": { + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 38, + "legacy": false + }, + "queries": { + "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f": { + "node_id": "fetch1", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "via": "a538298bd0a870e64e811abbf0118ca9be0a78e4786c2fb305e21f6e3a6d6dae", + "exec": 1 + }, + "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a": { + "node_id": "fetch2", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "via": "31ca1e8d7114a1d0a51409dfd357805811e6540004438afb11a720fc1a8be2e9", + "exec": 1 + } + }, + "schemas": { + "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49": { + "node_id": "fetch2", + "source": "flow_demo" + } + } + }, + "masks": { + "flow_demo": [] + }, + "outputs": { + "verify_doc": { + "ok": true, + "stats": { + "units": 4, + "segments": 18, + "claims": 3, + "connective": 0, + "headings": 1, + "numbers": 3, + "numbers_cited": 3, + "values": 1, + "entities": 1, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "excerpts": { + "Q1": "【Q1】查询结果(db_query__flow_demo · 数据源 flow_demo · 共 1 行)\nSQL:SELECT \"日期\", \"全日客流\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'\n列:全日客流 | 分区甲 | 日期\nr0:8,754 | 6,159 | 2026-08-05", + "Q2": "【Q2】查询结果(db_query__flow_demo · 数据源 flow_demo · 共 1 行)\nSQL:SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-08-20' AND \"时段\" = '13-14'\n列:时段 | 客流 | 日期\nr0:13-14 | 123 | 2026-08-20", + "t:日客流": "【t:日客流】表 日客流(数据源 flow_demo)\n字段清单(名称 类型,共 4 个):日期 TEXT | 全日客流 INTEGER | 分区甲 INTEGER | 分区乙 INTEGER\n出现在查询 Q1:SELECT \"日期\", \"全日客流\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'" + }, + "chain": { + "s4": [ + { + "step": "query", + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "node_id": "fetch1", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'", + "columns": [ + "日期", + "全日客流", + "分区甲" + ], + "rows": [ + [ + "2026-08-05", + 8754, + 6159 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "分区甲": "number", + "日期": "text" + } + } + ], + "s6": [ + { + "step": "query", + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "node_id": "fetch1", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'", + "columns": [ + "日期", + "全日客流", + "分区甲" + ], + "rows": [ + [ + "2026-08-05", + 8754, + 6159 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "分区甲" + ], + "cells": [ + [ + 0, + "分区甲" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "分区甲": "number", + "日期": "text" + } + } + ], + "s10": [ + { + "step": "query", + "alias": "Q2", + "artifact": "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a", + "node_id": "fetch2", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-08-20' AND \"时段\" = '13-14'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-08-20", + "13-14", + 123 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "时段" + ], + "cells": [ + [ + 0, + "时段" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + } + } + ], + "s12": [ + { + "step": "query", + "alias": "Q2", + "artifact": "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a", + "node_id": "fetch2", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-08-20' AND \"时段\" = '13-14'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-08-20", + "13-14", + 123 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "客流" + ], + "cells": [ + [ + 0, + "客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + } + } + ] + }, + "doc": { + "blocks": [ + { + "id": "b0", + "level": 2, + "type": "heading", + "units": [ + { + "cites": [], + "id": "u0", + "kind": "heading", + "see": [], + "segments": [ + { + "id": "s0", + "kind": "structural", + "span": [ + 0, + 3 + ], + "state": "neutral", + "text": "## " + }, + { + "id": "s1", + "kind": "text", + "span": [ + 3, + 7 + ], + "state": "neutral", + "text": "客流概况" + } + ], + "span": [ + 3, + 7 + ] + } + ] + }, + { + "id": "b1", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q1" + ], + "id": "u1", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s2", + "kind": "structural", + "span": [ + 7, + 9 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s3", + "kind": "text", + "span": [ + 9, + 27 + ], + "state": "neutral", + "text": "2026-08-05 的全日客流为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:7835a15d1246761f", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q1.r0.全日客流", + "rendered": "8,754", + "role": "value", + "status": "resolved", + "value": 8754 + }, + "id": "s4", + "kind": "number", + "ref": "v:Q1.r0.全日客流", + "span": [ + 27, + 32 + ], + "state": "deterministic", + "text": "8,754" + }, + { + "id": "s5", + "kind": "text", + "span": [ + 32, + 43 + ], + "state": "neutral", + "text": " 人次,其中分区甲为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:f0547fa49ffda391", + "kind": "cell", + "locator": { + "column": "分区甲", + "row": 0 + }, + "ref": "Q1.r0.分区甲", + "rendered": "6,159", + "role": "value", + "status": "resolved", + "value": 6159 + }, + "id": "s6", + "kind": "number", + "ref": "v:Q1.r0.分区甲", + "span": [ + 43, + 48 + ], + "state": "deterministic", + "text": "6,159" + }, + { + "id": "s7", + "kind": "text", + "span": [ + 48, + 52 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 9, + 52 + ] + } + ] + }, + { + "id": "b2", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q2" + ], + "id": "u2", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s8", + "kind": "structural", + "span": [ + 52, + 54 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s9", + "kind": "text", + "span": [ + 54, + 67 + ], + "state": "neutral", + "text": "2026-08-20 的 " + }, + { + "cite": { + "alias": "Q2", + "eid": "ev:cell:1271f45d3b794af3", + "kind": "cell", + "locator": { + "column": "时段", + "row": 0 + }, + "ref": "Q2.r0.时段", + "rendered": "13-14", + "role": "value", + "status": "resolved", + "value": "13-14" + }, + "id": "s10", + "kind": "value", + "ref": "v:Q2.r0.时段", + "span": [ + 67, + 72 + ], + "state": "deterministic", + "text": "13-14" + }, + { + "id": "s11", + "kind": "text", + "span": [ + 72, + 79 + ], + "state": "neutral", + "text": " 时段客流为 " + }, + { + "cite": { + "alias": "Q2", + "eid": "ev:cell:a9f3102648124a4d", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q2.r0.客流", + "rendered": "123", + "role": "value", + "status": "resolved", + "value": 123 + }, + "id": "s12", + "kind": "number", + "ref": "v:Q2.r0.客流", + "span": [ + 79, + 82 + ], + "state": "deterministic", + "text": "123" + }, + { + "id": "s13", + "kind": "text", + "span": [ + 82, + 86 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 54, + 86 + ] + } + ] + }, + { + "id": "b3", + "type": "paragraph", + "units": [ + { + "cites": [ + "t:日客流" + ], + "id": "u3", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s14", + "kind": "structural", + "span": [ + 86, + 88 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s15", + "kind": "text", + "span": [ + 88, + 90 + ], + "state": "neutral", + "text": "表 " + }, + { + "cite": { + "alias": "t:日客流", + "eid": "ev:table:e2a0f482244a2118", + "kind": "table", + "locator": { + "table": "日客流" + }, + "ref": "日客流", + "rendered": "日客流", + "role": "entity", + "status": "resolved" + }, + "id": "s16", + "kind": "entity", + "ref": "t:日客流", + "span": [ + 90, + 93 + ], + "state": "deterministic", + "text": "日客流" + }, + { + "id": "s17", + "kind": "text", + "span": [ + 93, + 110 + ], + "state": "neutral", + "text": " 按日期记录全日客流与各分区客流。" + } + ], + "span": [ + 88, + 110 + ] + } + ] + } + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "columns": [ + "日期", + "全日客流", + "分区甲" + ], + "eid": "ev:query:828cde2471078041", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "a538298bd0a870e64e811abbf0118ca9be0a78e4786c2fb305e21f6e3a6d6dae" + }, + "Q2": { + "alias": "Q2", + "artifact": "e4d1049eb5e02945efd12054dd114e93812958c7377dd2bd8f0650962b82f04a", + "columns": [ + "日期", + "时段", + "客流" + ], + "eid": "ev:query:c682c08b0a21e943", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "fetch2", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "31ca1e8d7114a1d0a51409dfd357805811e6540004438afb11a720fc1a8be2e9" + }, + "t:日客流": { + "alias": "t:日客流", + "artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "eid": "ev:table:e2a0f482244a2118", + "kind": "table", + "label": "表 日客流", + "locator": { + "table": "日客流" + }, + "name": "日客流", + "queries": [ + "Q1" + ], + "source": "flow_demo", + "sources": [ + { + "artifact": "3b2c4a926082e8919d2ad7fa3aed3f63592f54bd1bd063ad091e1f02b340ac49", + "kind": "schema", + "source": "flow_demo" + }, + { + "alias": "Q1", + "artifact": "875a12edba167e8ea7dc25edf8456abdb36dc69f7604e0e48fb94dcc61257b8f", + "kind": "sql", + "source": "flow_demo" + } + ] + } + }, + "entity_syntax": 2, + "markdown": "## 客流概况\n\n2026-08-05 的全日客流为 8,754 人次,其中分区甲为 6,159 人次。\n\n2026-08-20 的 13-14 时段客流为 123 人次。\n\n表 日客流 按日期记录全日客流与各分区客流。", + "node_id": "write", + "run_id": "48e40062765b426b92deee03058b9437", + "schema": "agentlab.report/1", + "source": "## 客流概况\n\n2026-08-05 的全日客流为 [[v:Q1.r0.全日客流]] 人次,其中分区甲为 [[v:Q1.r0.分区甲]] 人次。\n\n2026-08-20 的 [[v:Q2.r0.时段]] 时段客流为 [[v:Q2.r0.客流]] 人次。\n\n表 [[t:日客流]] 按日期记录全日客流与各分区客流。", + "stats": { + "claims": 3, + "connective": 0, + "entities": 1, + "headings": 1, + "numbers": 3, + "numbers_cited": 3, + "quotes": 0, + "see": 0, + "segments": 18, + "uncited_claims": 0, + "uncited_numbers": 0, + "units": 4, + "unknown_entities": 0, + "unresolved": 0, + "unverified_entities": 0, + "values": 1, + "violations": 0 + }, + "violations": [] + } + } + }, + "simple": { + "kind": "simple_upload", + "source": "table_demo", + "run_id": "b7c7940d7b5046bab5bf5b88834c874f", + "node_id": "write", + "doc_artifact": "c9d92d8262491911566379f85f27ae284c65a52398d4e109ed0ae68ab148e1b5", + "artifacts": [ + "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042" + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "columns": [ + "地区", + "金额", + "订单数" + ], + "eid": "ev:query:c658ecdfb465e4db", + "exec": 1, + "kind": "query", + "label": "db_query__table_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "table_demo", + "tool": "db_query__table_demo", + "truncated": false, + "via": "599e407a808706c2566c1cd4cdf6f674a68d9e435541fcbfba817f31cb2cc878" + }, + "t:月报": { + "alias": "t:月报", + "artifact": "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042", + "eid": "ev:table:1743983bf4241d6a", + "kind": "table", + "label": "表 月报", + "locator": { + "table": "月报" + }, + "name": "月报", + "queries": [ + "Q1" + ], + "source": "table_demo", + "sources": [ + { + "artifact": "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042", + "kind": "schema", + "source": "table_demo" + }, + { + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "kind": "sql", + "source": "table_demo" + } + ] + } + }, + "markdown": "## 月报\n\n分区甲本月的金额为 [[v:Q1.r0.金额]],订单数为 [[v:Q1.r0.订单数]] 单。\n\n表 [[t:月报]] 按地区汇总。", + "sealed": { + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 23, + "legacy": false + }, + "queries": { + "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09": { + "node_id": "fetch1", + "tool": "db_query__table_demo", + "source": "table_demo", + "via": "599e407a808706c2566c1cd4cdf6f674a68d9e435541fcbfba817f31cb2cc878", + "exec": 1 + } + }, + "schemas": { + "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042": { + "node_id": "fetch1", + "source": "table_demo" + } + } + }, + "masks": { + "table_demo": [] + }, + "outputs": { + "verify_doc": { + "ok": true, + "stats": { + "units": 3, + "segments": 12, + "claims": 2, + "connective": 0, + "headings": 1, + "numbers": 2, + "numbers_cited": 2, + "values": 0, + "entities": 1, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "excerpts": { + "Q1": "【Q1】查询结果(db_query__table_demo · 数据源 table_demo · 共 1 行)\nSQL:SELECT \"地区\", \"金额\", \"订单数\" FROM \"月报\" WHERE \"地区\" = '分区甲'\n列:金额 | 订单数 | 地区\nr0:31,847.5 | 412 | 分区甲", + "t:月报": "【t:月报】表 月报(数据源 table_demo)\n字段清单(名称 类型,共 3 个):地区 TEXT | 金额 REAL | 订单数 INTEGER\n表的说明:本表按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和\n出现在查询 Q1:SELECT \"地区\", \"金额\", \"订单数\" FROM \"月报\" WHERE \"地区\" = '分区甲'" + }, + "chain": { + "s4": [ + { + "step": "query", + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "node_id": "fetch1", + "tool": "db_query__table_demo", + "source": "table_demo", + "sql": "SELECT \"地区\", \"金额\", \"订单数\" FROM \"月报\" WHERE \"地区\" = '分区甲'", + "columns": [ + "地区", + "金额", + "订单数" + ], + "rows": [ + [ + "分区甲", + 31847.5, + 412 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "金额" + ], + "cells": [ + [ + 0, + "金额" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "订单数": "number", + "金额": "number" + } + } + ], + "s6": [ + { + "step": "query", + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "node_id": "fetch1", + "tool": "db_query__table_demo", + "source": "table_demo", + "sql": "SELECT \"地区\", \"金额\", \"订单数\" FROM \"月报\" WHERE \"地区\" = '分区甲'", + "columns": [ + "地区", + "金额", + "订单数" + ], + "rows": [ + [ + "分区甲", + 31847.5, + 412 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "订单数" + ], + "cells": [ + [ + 0, + "订单数" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "订单数": "number", + "金额": "number" + } + } + ] + }, + "doc": { + "blocks": [ + { + "id": "b0", + "level": 2, + "type": "heading", + "units": [ + { + "cites": [], + "id": "u0", + "kind": "heading", + "see": [], + "segments": [ + { + "id": "s0", + "kind": "structural", + "span": [ + 0, + 3 + ], + "state": "neutral", + "text": "## " + }, + { + "id": "s1", + "kind": "text", + "span": [ + 3, + 5 + ], + "state": "neutral", + "text": "月报" + } + ], + "span": [ + 3, + 5 + ] + } + ] + }, + { + "id": "b1", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q1" + ], + "id": "u1", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s2", + "kind": "structural", + "span": [ + 5, + 7 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s3", + "kind": "text", + "span": [ + 7, + 17 + ], + "state": "neutral", + "text": "分区甲本月的金额为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:d3c282462967377e", + "kind": "cell", + "locator": { + "column": "金额", + "row": 0 + }, + "ref": "Q1.r0.金额", + "rendered": "31,847.5", + "role": "value", + "status": "resolved", + "value": 31847.5 + }, + "id": "s4", + "kind": "number", + "ref": "v:Q1.r0.金额", + "span": [ + 17, + 25 + ], + "state": "deterministic", + "text": "31,847.5" + }, + { + "id": "s5", + "kind": "text", + "span": [ + 25, + 31 + ], + "state": "neutral", + "text": ",订单数为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:58b013f9713a82da", + "kind": "cell", + "locator": { + "column": "订单数", + "row": 0 + }, + "ref": "Q1.r0.订单数", + "rendered": "412", + "role": "value", + "status": "resolved", + "value": 412 + }, + "id": "s6", + "kind": "number", + "ref": "v:Q1.r0.订单数", + "span": [ + 31, + 34 + ], + "state": "deterministic", + "text": "412" + }, + { + "id": "s7", + "kind": "text", + "span": [ + 34, + 37 + ], + "state": "neutral", + "text": " 单。" + } + ], + "span": [ + 7, + 37 + ] + } + ] + }, + { + "id": "b2", + "type": "paragraph", + "units": [ + { + "cites": [ + "t:月报" + ], + "id": "u2", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s8", + "kind": "structural", + "span": [ + 37, + 39 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s9", + "kind": "text", + "span": [ + 39, + 41 + ], + "state": "neutral", + "text": "表 " + }, + { + "cite": { + "alias": "t:月报", + "eid": "ev:table:1743983bf4241d6a", + "kind": "table", + "locator": { + "table": "月报" + }, + "ref": "月报", + "rendered": "月报", + "role": "entity", + "status": "resolved" + }, + "id": "s10", + "kind": "entity", + "ref": "t:月报", + "span": [ + 41, + 43 + ], + "state": "deterministic", + "text": "月报" + }, + { + "id": "s11", + "kind": "text", + "span": [ + 43, + 50 + ], + "state": "neutral", + "text": " 按地区汇总。" + } + ], + "span": [ + 39, + 50 + ] + } + ] + } + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "columns": [ + "地区", + "金额", + "订单数" + ], + "eid": "ev:query:c658ecdfb465e4db", + "exec": 1, + "kind": "query", + "label": "db_query__table_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "table_demo", + "tool": "db_query__table_demo", + "truncated": false, + "via": "599e407a808706c2566c1cd4cdf6f674a68d9e435541fcbfba817f31cb2cc878" + }, + "t:月报": { + "alias": "t:月报", + "artifact": "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042", + "eid": "ev:table:1743983bf4241d6a", + "kind": "table", + "label": "表 月报", + "locator": { + "table": "月报" + }, + "name": "月报", + "queries": [ + "Q1" + ], + "source": "table_demo", + "sources": [ + { + "artifact": "cf9be5e350739c0791101d30b954884f1c795cce770257c1054db18c3b882042", + "kind": "schema", + "source": "table_demo" + }, + { + "alias": "Q1", + "artifact": "bbbc9293e455ac84fd29be53697b12e86b0cb6328550923714e70a71a0babd09", + "kind": "sql", + "source": "table_demo" + } + ] + } + }, + "entity_syntax": 2, + "markdown": "## 月报\n\n分区甲本月的金额为 31,847.5,订单数为 412 单。\n\n表 月报 按地区汇总。", + "node_id": "write", + "run_id": "b7c7940d7b5046bab5bf5b88834c874f", + "schema": "agentlab.report/1", + "source": "## 月报\n\n分区甲本月的金额为 [[v:Q1.r0.金额]],订单数为 [[v:Q1.r0.订单数]] 单。\n\n表 [[t:月报]] 按地区汇总。", + "stats": { + "claims": 2, + "connective": 0, + "entities": 1, + "headings": 1, + "numbers": 2, + "numbers_cited": 2, + "quotes": 0, + "see": 0, + "segments": 12, + "uncited_claims": 0, + "uncited_numbers": 0, + "units": 3, + "unknown_entities": 0, + "unresolved": 0, + "unverified_entities": 0, + "values": 0, + "violations": 0 + }, + "violations": [] + } + } + }, + "manual": { + "kind": "manual", + "source": "manual_demo", + "run_id": "56673721255a4b72bc772dde96a4615a", + "node_id": "write", + "doc_artifact": "ff337331d01a15a712ca14c9610ca4dfa9c2f7e5e6746314b136778729987c52", + "artifacts": [ + "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c" + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c", + "columns": [ + "地区", + "订单数" + ], + "eid": "ev:query:0e9d9c0ad1e37f53", + "exec": 1, + "kind": "query", + "label": "db_query__manual_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "manual_demo", + "tool": "db_query__manual_demo", + "truncated": false, + "via": "80daa7961fa4f2474270fd494abc70a9cfb71faf90bd03a4590d00d93517e556" + } + }, + "markdown": "## 订单\n\n分区乙的订单数为 [[v:Q1.r0.订单数]] 单。", + "sealed": { + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 19, + "legacy": false + }, + "queries": { + "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c": { + "node_id": "fetch1", + "tool": "db_query__manual_demo", + "source": "manual_demo", + "via": "80daa7961fa4f2474270fd494abc70a9cfb71faf90bd03a4590d00d93517e556", + "exec": 1 + } + }, + "schemas": {} + }, + "masks": { + "manual_demo": [] + }, + "outputs": { + "verify_doc": { + "ok": true, + "stats": { + "units": 2, + "segments": 6, + "claims": 1, + "connective": 0, + "headings": 1, + "numbers": 1, + "numbers_cited": 1, + "values": 0, + "uncited_numbers": 0, + "unresolved": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "excerpts": { + "Q1": "【Q1】查询结果(db_query__manual_demo · 数据源 manual_demo · 共 1 行)\nSQL:SELECT \"地区\", \"订单数\" FROM \"订单汇总\" WHERE \"地区\" = '分区乙'\n列:订单数 | 地区\nr0:466 | 分区乙" + }, + "chain": { + "s4": [ + { + "step": "query", + "alias": "Q1", + "artifact": "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c", + "node_id": "fetch1", + "tool": "db_query__manual_demo", + "source": "manual_demo", + "sql": "SELECT \"地区\", \"订单数\" FROM \"订单汇总\" WHERE \"地区\" = '分区乙'", + "columns": [ + "地区", + "订单数" + ], + "rows": [ + [ + "分区乙", + 466 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "订单数" + ], + "cells": [ + [ + 0, + "订单数" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "订单数": "number" + } + } + ] + }, + "doc": { + "blocks": [ + { + "id": "b0", + "level": 2, + "type": "heading", + "units": [ + { + "cites": [], + "id": "u0", + "kind": "heading", + "see": [], + "segments": [ + { + "id": "s0", + "kind": "structural", + "span": [ + 0, + 3 + ], + "state": "neutral", + "text": "## " + }, + { + "id": "s1", + "kind": "text", + "span": [ + 3, + 5 + ], + "state": "neutral", + "text": "订单" + } + ], + "span": [ + 3, + 5 + ] + } + ] + }, + { + "id": "b1", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q1" + ], + "id": "u1", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s2", + "kind": "structural", + "span": [ + 5, + 7 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s3", + "kind": "text", + "span": [ + 7, + 16 + ], + "state": "neutral", + "text": "分区乙的订单数为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:1d8d75a52659605e", + "kind": "cell", + "locator": { + "column": "订单数", + "row": 0 + }, + "ref": "Q1.r0.订单数", + "rendered": "466", + "role": "value", + "status": "resolved", + "value": 466 + }, + "id": "s4", + "kind": "number", + "ref": "v:Q1.r0.订单数", + "span": [ + 16, + 19 + ], + "state": "deterministic", + "text": "466" + }, + { + "id": "s5", + "kind": "text", + "span": [ + 19, + 22 + ], + "state": "neutral", + "text": " 单。" + } + ], + "span": [ + 7, + 22 + ] + } + ] + } + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "3dc25b2a0e9f1c85ebcaedec3702e70c4c0c3d5100be39d9f41728ba63e1d13c", + "columns": [ + "地区", + "订单数" + ], + "eid": "ev:query:0e9d9c0ad1e37f53", + "exec": 1, + "kind": "query", + "label": "db_query__manual_demo · 1 行", + "locator": {}, + "node_id": "fetch1", + "rows": 1, + "source": "manual_demo", + "tool": "db_query__manual_demo", + "truncated": false, + "via": "80daa7961fa4f2474270fd494abc70a9cfb71faf90bd03a4590d00d93517e556" + } + }, + "entity_syntax": 2, + "markdown": "## 订单\n\n分区乙的订单数为 466 单。", + "node_id": "write", + "run_id": "56673721255a4b72bc772dde96a4615a", + "schema": "agentlab.report/1", + "source": "## 订单\n\n分区乙的订单数为 [[v:Q1.r0.订单数]] 单。", + "stats": { + "claims": 1, + "connective": 0, + "headings": 1, + "numbers": 1, + "numbers_cited": 1, + "see": 0, + "segments": 6, + "uncited_claims": 0, + "uncited_numbers": 0, + "units": 2, + "unresolved": 0, + "values": 0, + "violations": 0 + }, + "violations": [] + } + } + } + } +} diff --git a/backend/tests/test_direct_select.py b/backend/tests/test_direct_select.py new file mode 100644 index 00000000..fb17b09d --- /dev/null +++ b/backend/tests/test_direct_select.py @@ -0,0 +1,862 @@ +"""期 4 的 SQL 判据(P4-SPEC 2.4、6.3、7.2,WP-A):engine/direct_select 的 recognize、tables_in。 + +判据决定一个结果格能不能下钻到原表的格子。放过一条不是「一张表的直接选取」的 SQL,结果列装的可能是另一列的值, +值恰好相等时界面就会指着错误的格子说「来自这里」——所以这里大半的用例是拒绝的写法,尤其是评审实测过的 +B7(`ESCAPE '\\'`、`AS [t']` 后面藏的 UNION ALL)。最后一组拿真的 SQLite 执行随机拼出的 SQL,核对「认可的 +每一列,值确实都来自它说的那一列」,不靠逐条想到的用例。 + +夹具一律假名(分区甲、分区乙、日客流、时段客流),数字随机。 +""" +from __future__ import annotations + +import ast +import itertools +import random +import sqlite3 +from pathlib import Path + +import pytest + +from app.data import guard +from app.data import provenance_types as P +from app.data.provenance_types import DirectSelect, SelectRefusal +from app.engine import direct_select as D +from app.engine.direct_select import recognize, tables_in + +TABLES: dict[str, list[str]] = { + "日客流": ["日期", "全日客流", "分区甲", "分区乙"], + "时段客流": ["日期", "时段", "时段类别", "客流"], + "时段客流_表内合计": ["日期", "合计项", "客流"], + # P4-SPEC 附录 B2:定义名是 Key,SQLite 对 `abc.key` 返回定义名 + "abc": ["Key", "v"], +} +DAILY = TABLES["日客流"] + + +def ok(sql: str, result: list[str], *, tables: dict[str, list[str]] = TABLES) -> DirectSelect: + out = recognize(sql, result_columns=result, tables=tables) + assert isinstance(out, DirectSelect), f"应认可:{sql!r} → {out!r}" + return out + + +def no(sql: str, code: str, detail: str, *, result: list[str] | None = None, + tables: dict[str, list[str]] = TABLES) -> SelectRefusal: + out = recognize(sql, result_columns=result if result is not None else ["日期", "全日客流"], tables=tables) + assert isinstance(out, SelectRefusal), f"应拒绝:{sql!r} → {out!r}" + # 每个拒绝分支的细分都得在契约的 DETAILS 里:漏了映射,接口一侧的 contract_problems 会报 + assert out.detail in P.DETAILS[out.code], out + assert (out.code, out.detail) == (code, detail), f"{sql!r} → {out!r}" + return out + + +def stored(sql: str) -> str: + """查询快照里存的就是守卫的返回值(engine.run_query):用例照这个形态喂给判据。""" + return guard.check(sql, readonly=True, dialect="sqlite") + + +# --------------------------------------------------------------------------- +# 原型的 24 例(P4-SPEC 2.4、附录 B4):期望照原型,改成 code 和 detail +# --------------------------------------------------------------------------- + +PROTOTYPE_REFUSED = [ + ('SELECT "日期", "分区甲" AS "分区乙" FROM "日客流"', "alias", "alias"), + ('SELECT "日期" "分区乙" FROM "日客流"', "alias", "alias"), + ('SELECT a."日期", b."全日客流" FROM "日客流" a JOIN "日客流" b ON a."日期" = b."日期"', "multi_table", "join"), + ('SELECT a."日期", b."全日客流" FROM "日客流" a, "日客流" b WHERE a."日期" = b."日期"', "multi_table", + "comma_join"), + ('WITH x AS (SELECT * FROM "日客流") SELECT "日期", "全日客流" FROM x', "multi_table", "cte"), + ('SELECT "日期", "全日客流" FROM (SELECT * FROM "日客流")', "multi_table", "subquery"), + ('SELECT "日期", "全日客流" FROM "日客流" UNION SELECT "日期", "分区甲" FROM "日客流"', "multi_table", "compound"), + ('SELECT "日期", SUM("全日客流") OVER () FROM "日客流"', "expression", "window"), + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IN (SELECT "日期" FROM "时段客流")', "multi_table", + "subquery"), + ('SELECT SUM("全日客流") AS "合计", COUNT(*) FROM "日客流"', "expression", "expression"), + ('SELECT "日期", "全日客流" + 0 FROM "日客流"', "expression", "expression"), + ("SELECT '日期', \"全日客流\" FROM \"日客流\"", "expression", "string_literal"), + ('SELECT DISTINCT "日期", "全日客流" FROM "日客流"', "expression", "distinct"), + # 表名后面的 INDEXED 不是别名,再往后不是 WHERE / ORDER / LIMIT + ('SELECT "日期", "全日客流" FROM "日客流" INDEXED BY x', "unparsed", "tail_shape"), + ('SELECT "日期", "全日客流" COLLATE NOCASE FROM "日客流"', "expression", "collate"), + ('SELECT "日期", "全日客流" FROM json_each(\'[]\')', "unparsed", "table_shape"), + ('SELECT "日期", "全日客流" FROM main."日客流"', "unparsed", "qualified_table"), + ('SELECT x."日期", x."全日客流" FROM "日客流" t', "unparsed", "qualifier"), +] + +PROTOTYPE_ACCEPTED = [ + ('SELECT "日期", "全日客流" FROM "日客流" ORDER BY "全日客流" DESC LIMIT 1', ["日期", "全日客流"], + ("日客流", None, ["日期", "全日客流"])), + ("SELECT 日期, 全日客流 FROM 日客流 WHERE 日期 = '2026-08-05';", ["日期", "全日客流"], + ("日客流", None, ["日期", "全日客流"])), + ('SELECT t."日期", t."全日客流" FROM "日客流" AS t WHERE substr(t."日期", 1, 7) = \'2026-08\'', + ["日期", "全日客流"], ("日客流", "t", ["日期", "全日客流"])), + ('SELECT * FROM "日客流" WHERE "日期" = \'2026-08-05\'', DAILY, ("日客流", None, DAILY)), + ('-- FROM 别的表\nSELECT "日期", "客流" FROM "时段客流" /* JOIN x */ WHERE "时段" = \'8-9\'', ["日期", "客流"], + ("时段客流", None, ["日期", "客流"])), + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'x UNION SELECT\'', ["日期", "全日客流"], + ("日客流", None, ["日期", "全日客流"])), +] + + +def test_the_prototype_has_24_cases(): + assert len(PROTOTYPE_REFUSED) + len(PROTOTYPE_ACCEPTED) == 24 + + +@pytest.mark.parametrize(("sql", "code", "detail"), PROTOTYPE_REFUSED) +def test_prototype_refusals(sql, code, detail): + no(sql, code, detail) + + +@pytest.mark.parametrize(("sql", "result", "expect"), PROTOTYPE_ACCEPTED) +def test_prototype_acceptances(sql, result, expect): + out = ok(sql, result) + assert (out.table, out.alias, out.columns) == expect + + +@pytest.mark.parametrize(("sql", "result", "expect"), PROTOTYPE_ACCEPTED) +def test_prototype_acceptances_in_the_stored_form(sql, result, expect): + """守卫去掉注释和尾分号之后(存进查询快照的形态)结论相同。""" + out = ok(stored(sql), result) + assert (out.table, out.alias, out.columns) == expect + + +# --------------------------------------------------------------------------- +# B7:多方言扫描看不见、SQLite 照样执行的集合运算(评审甲的 bs.py、br.py) +# --------------------------------------------------------------------------- + +B7_COMPOUND = [ + # bs.py 的 3 条:ESCAPE '\' 在多方言扫描里是「转义了的引号」,后面整段被当成字符串 + """SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE '2026-08%' ESCAPE '\\' UNION ALL """ + """SELECT "日期", "分区甲" FROM "日客流" WHERE "日期" LIKE '2026-09%'""", + """SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE '%\\_%' ESCAPE '\\' OR 1 UNION ALL """ + """SELECT "日期", "分区甲" FROM "日客流" WHERE '1' = '1'""", + """SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = '\\' UNION ALL """ + """SELECT "日期", "分区甲" FROM "日客流" WHERE '1'='1'""", + # br.py 的第 1 条:方括号里含引号,多方言扫描不当标识符,[t'] … [u'] 被当成一段 + """SELECT "日期", "全日客流" FROM "日客流" AS [t'] WHERE "日期" > '2026-09' UNION ALL """ + """SELECT "日期", "分区甲" FROM "日客流" AS [u']""", + # br.py 的第 2 条 + """SELECT "日期", "全日客流" FROM "日客流" AS t ORDER BY [x'] UNION ALL """ + """SELECT "日期","分区甲" FROM "日客流" ORDER BY [y']""", +] + + +@pytest.mark.parametrize("sql", B7_COMPOUND) +def test_b7_compound_hidden_behind_backslash_or_bracket_is_refused(sql): + no(stored(sql), "multi_table", "compound") + + +def test_b7_join_hidden_behind_escape_backslash_is_refused(): + no(stored("""SELECT a."日期", a."全日客流" LIKE '%' ESCAPE '\\', a."分区甲" FROM "日客流" a """ + """JOIN "日客流" b ON a."日期" = b."日期\""""), "multi_table", "join") + no(stored("""SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE '%' ESCAPE '\\' """ + """CROSS JOIN "时段客流\""""), "multi_table", "join") + + +def test_b7_comma_join_after_escape_backslash_is_refused(): + # br.py 的第 3 条 + no(stored("""SELECT a."日期", b."全日客流" FROM "日客流" a, "日客流" b WHERE a."日期" = b."日期" """ + """AND a."日期" LIKE '%' ESCAPE '\\' """), "multi_table", "comma_join") + + +def test_b7_would_slip_through_the_multi_dialect_scan(): + """反证:同一串 SQL 在多方言扫描(evidence._sql_scan 的读法)里看不见 UNION,判据换回那种读法就会放过它。 + 这条钉住「判据必须用 SQLite 读法」的理由,换读法的变异由上面两条抓。""" + from app.engine.evidence import _SQL, _sql_scan + + code, _ = _sql_scan(stored(B7_COMPOUND[0]), _SQL[2]) + assert "UNION" not in code.upper() + seen = " ".join(t.text for t in guard._tokens(stored(B7_COMPOUND[0]), guard._SQLITE) if t.kind == "code") + assert "UNION" in seen.upper() + + +# --------------------------------------------------------------------------- +# 守卫读法的三个性质(P4-SPEC 2.4):守卫以后改读法时这里先失败 +# --------------------------------------------------------------------------- + + +def _kinds(sql: str) -> list[tuple[str, str]]: + return [(t.kind, t.text) for t in guard._tokens(sql, guard._SQLITE)] + + +def test_sqlite_reading_backslash_does_not_escape_in_strings(): + assert _kinds("'a\\' x") == [("string", "'a\\'"), ("code", " x")] + + +def test_sqlite_reading_brackets_are_identifiers(): + assert _kinds("[t'] x") == [("ident", "[t']"), ("code", " x")] + + +def test_sqlite_reading_brackets_end_at_the_first_closing_bracket(): + assert _kinds("[a]]b]") == [("ident", "[a]"), ("code", "]b]")] + assert _kinds('[a""b]') == [("ident", '[a""b]')] + + +def test_bracket_names_are_taken_verbatim_and_doubled_quotes_collapse_elsewhere(): + tables = {'a""b': ["x"], 'c"d': ["y"], "e`f": ["z"]} + assert ok('SELECT x FROM [a""b]', ["x"], tables=tables).table == 'a""b' + assert ok('SELECT y FROM "c""d"', ["y"], tables=tables).table == 'c"d' + assert ok("SELECT z FROM `e``f`", ["z"], tables=tables).table == "e`f" + + +# --------------------------------------------------------------------------- +# 字面量关键字、重名、位置对应 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("word", ["CURRENT_DATE", "current_time", "Current_Timestamp", "TRUE", "false", "NULL"]) +def test_literal_keywords_are_refused(word): + no(f'SELECT {word}, "全日客流" FROM "日客流"', "expression", "literal_keyword") + no(f'SELECT "日期", {word} FROM "日客流"', "expression", "literal_keyword") + + +def test_literal_keywords_are_refused_even_when_the_table_has_such_a_column(): + # TRUE / FALSE 有同名列时才指那一列:读法取决于表结构,一律拒 + tables = {"t": ["true", "x"]} + no("SELECT TRUE, x FROM t", "expression", "literal_keyword", result=["true", "x"], tables=tables) + # 加了引号就是名字 + assert ok('SELECT "true", x FROM t', ["true", "x"], tables=tables).columns == ["true", "x"] + + +def test_two_items_naming_the_same_column_are_ambiguous(): + no('SELECT "日期", "日期" FROM "日客流"', "unparsed", "ambiguous_column", result=["日期", "日期"]) + no("SELECT Key, key FROM abc", "unparsed", "ambiguous_column", result=["Key", "key"]) + no('SELECT t."日期", "日期" FROM "日客流" t', "unparsed", "ambiguous_column", result=["日期", "日期"]) + + +def test_result_columns_follow_the_items_by_position(): + """第 i 个结果列就是第 i 个选取项(P4-SPEC 2.4)。SQLite 对 `abc.key` 返回定义名 Key(附录 B2)。""" + out = ok("SELECT abc.key, ABC.V FROM abc", ["Key", "v"]) + assert out.columns == ["Key", "v"] + out = ok('SELECT "分区乙", "日期", "分区甲" FROM "日客流"', ["分区乙", "日期", "分区甲"]) + assert out.columns == ["分区乙", "日期", "分区甲"] + + +def test_result_columns_that_do_not_line_up_with_the_items_are_refused(): + """名字只作一致性核对:结果列的名字和对应位置的列对不上,或者个数不等,说明结果不是这条 SQL 的。 + 按名字对应(变异)会把第一条认成 ['分区甲', '日期']。""" + no('SELECT "日期", "分区甲" FROM "日客流"', "unparsed", "unknown_column", result=["分区甲", "日期"]) + no('SELECT "日期" FROM "日客流"', "unparsed", "unknown_column", result=["日期", "全日客流"]) + no('SELECT "日期", "全日客流" FROM "日客流"', "unparsed", "unknown_column", result=["日期"]) + + +def test_star_must_match_the_table_columns_in_order(): + assert ok('SELECT * FROM "日客流"', DAILY).columns == DAILY + # 结果列的大小写不同也认(name_key),返回的是定义名 + assert ok("SELECT * FROM abc", ["key", "V"]).columns == ["Key", "v"] + no('SELECT * FROM "日客流"', "unparsed", "star_mismatch", result=["全日客流", "日期", "分区甲", "分区乙"]) + no('SELECT * FROM "日客流"', "unparsed", "star_mismatch", result=DAILY[:3]) + no('SELECT * FROM "日客流"', "unparsed", "star_mismatch", result=[*DAILY, "多一列"]) + + +def test_star_is_only_allowed_alone(): + no('SELECT *, "日期" FROM "日客流"', "unparsed", "star_mismatch", result=[*DAILY, "日期"]) + no('SELECT "日期", * FROM "日客流"', "unparsed", "star_mismatch", result=["日期", *DAILY]) + no('SELECT t.* FROM "日客流" t', "unparsed", "star_mismatch", result=DAILY) + + +# --------------------------------------------------------------------------- +# 名字:引号、中文、大小写、空白 +# --------------------------------------------------------------------------- + + +def test_unquoted_chinese_names(): + out = ok("SELECT 日期, 分区甲 FROM 日客流 WHERE 分区甲 > 3 ORDER BY 日期", ["日期", "分区甲"]) + assert (out.table, out.columns) == ("日客流", ["日期", "分区甲"]) + + +def test_backticks_and_brackets(): + out = ok("SELECT `日期`, [全日客流] FROM [日客流] AS `t` WHERE [t].`日期` = '2026-08-01'", ["日期", "全日客流"]) + assert (out.table, out.alias, out.columns) == ("日客流", "t", ["日期", "全日客流"]) + + +def test_names_are_case_insensitive_only_for_ascii(): + out = ok("SeLeCt ABC.KEY, v FrOm Abc", ["Key", "v"]) + assert (out.table, out.columns) == ("abc", ["Key", "v"]) + # 别名同样只对 ASCII 不区分大小写;返回的别名是原写法 + out = ok('SELECT "T".日期 FROM 日客流 AS t', ["日期"]) + assert out.alias == "t" + cyr = {"Дата": ["Сумма", "Итог"]} + no("SELECT Сумма FROM дата", "unparsed", "unknown_table", result=["Сумма"], tables=cyr) + no("SELECT сумма FROM Дата", "unparsed", "unknown_column", result=["сумма"], tables=cyr) + assert ok("SELECT Сумма, Итог FROM Дата", ["Сумма", "Итог"], tables=cyr).columns == ["Сумма", "Итог"] + + +def test_fullwidth_names_are_not_folded(): + # name_key 不做 NFKC:全角 ABC 和 abc 是两张表 + no("SELECT Key FROM abc", "unparsed", "unknown_table", result=["Key"]) + + +def test_newlines_tabs_and_form_feeds_are_whitespace(): + sql = 'SELECT\n\t"日期",\r\n\t"全日客流"\fFROM\t"日客流"\nWHERE "日期" = \'2026-08-01\'\n' + assert ok(sql, ["日期", "全日客流"]).columns == ["日期", "全日客流"] + + +def test_fullwidth_space_is_part_of_a_name(): + """除记号开头的 U+FEFF 之外,SQLite 把码位 ≥ 0x80 的字符一律算名字(2 版实体语法同样如此,evidence._SqlSyntax): + `日客流\u3000t` 是一张叫这个名字的表,不是表名加别名;`日客流\u3000WHERE` 也不是表名加 WHERE。""" + no("SELECT 日期, 全日客流 FROM 日客流\u3000t", "unparsed", "unknown_table") + no("SELECT 日期, 全日客流 FROM 日客流\u3000WHERE", "unparsed", "unknown_table") + # 「日客流\u3000WHERE」是表名,「日期」成了它的别名,后面的「=」不是尾部的开头 + no("SELECT 日期, 全日客流 FROM 日客流\u3000WHERE 日期 = 'x'", "unparsed", "tail_shape") + tables = {"日客流": ["日期\u3000x", "全日客流"]} + assert ok("SELECT 日期\u3000x FROM 日客流", ["日期\u3000x"], tables=tables).columns == ["日期\u3000x"] + # 不换行空格同理:「日期\u00a0FROM」是一个名字,「日客流」成了它的别名,整条语句没有 FROM + no("SELECT 日期\u00a0FROM 日客流", "alias", "alias", result=["日客流"]) + + +def test_vertical_tab_reads_as_whitespace(): + """竖制表符出现在记号开头时 SQLite 报非法记号,紧跟其他空白时 SQLite 当空白(CC_SPACE 的循环用 sqlite3Isspace, + 含 \\v)。判据一律当空白:前一种执行不了、不会留下查询快照;后一种读法相同,藏在它后面的 UNION ALL 照样看得见。 + 当成标点时,紧跟在它后面的单引号表名 tables_in 会漏(见 tables_in 一节)。""" + no(stored('SELECT "日期", "全日客流" FROM "日客流" WHERE 1 \x0bUNION ALL SELECT "日期", "分区甲" FROM "日客流"'), + "multi_table", "compound") + no(stored('SELECT "日期", "全日客流" FROM "日客流" WHERE 1 \x0bGROUP BY "日期"'), "expression", "expression") + out = ok(stored('SELECT \x0b"日期", \x0b"全日客流" FROM \x0b"日客流" \x0bWHERE 1'), ["日期", "全日客流"]) + assert (out.table, out.alias, out.columns) == ("日客流", None, ["日期", "全日客流"]) + conn = sqlite3.connect(":memory:") + with pytest.raises(sqlite3.Error): + conn.execute("SELECT\x0b1") + assert conn.execute("SELECT 1 \x0bUNION ALL SELECT 2").fetchall() == [(1,), (2,)] + + +BOM = "\ufeff" + +#: 评审实测(bom.py、attacks.py):守卫照样放行,SQLite 照样执行,前两条的全日客流一列里混着分区甲的值 +BOM_HIDDEN = [ + (f'SELECT "日期", "全日客流" FROM "日客流" WHERE 1 {BOM}UNION ALL {BOM}SELECT "日期", "分区甲" {BOM}FROM "日客流"', + "multi_table", "compound"), + (f'SELECT "日期", "全日客流" FROM "日客流" WHERE 1 {BOM}UNION {BOM}SELECT "日期", "分区甲" {BOM}FROM "日客流"', + "multi_table", "compound"), + (f'SELECT "日期", "全日客流" FROM "日客流" WHERE 1 {BOM}GROUP BY "日期"', "expression", "expression"), +] + + +@pytest.mark.parametrize(("sql", "code", "detail"), BOM_HIDDEN) +def test_bom_at_a_token_start_is_whitespace_so_the_keyword_behind_it_counts(sql, code, detail): + """U+FEFF 出现在记号开头时 SQLite 当空白(tokenize.c 的 CC_BOM)。照「码位 ≥ 0x80 一律算名字」读,紧跟在它后面的 + UNION 就成了一个普通的词,判据把这几条认成直接选取。""" + no(stored(sql), code, detail) + + +def test_bom_between_tokens_reads_like_a_space(): + """记号开头指:紧跟在空白、标点、带引号的名字、字符串后面。紧跟在不加引号的词后面就进了那个词(`SELECT\ufeff` + 是一个名字,SQLite 报语法错误),见下一条。""" + sql = stored(f'SELECT {BOM}"日期",{BOM}"全日客流" {BOM}FROM {BOM}"日客流"{BOM} WHERE 1') + out = ok(sql, ["日期", "全日客流"]) + assert (out.table, out.alias, out.columns) == ("日客流", None, ["日期", "全日客流"]) + conn = sqlite3.connect(":memory:") + conn.execute('CREATE TABLE "日客流" ("日期", "全日客流")') + assert [d[0] for d in conn.execute(sql).description] == ["日期", "全日客流"] + with pytest.raises(sqlite3.Error): + conn.execute(f'SELECT{BOM} "日期" FROM "日客流"') + # 紧跟在带引号的名字、标点后面,单独一个 U+FEFF 也是记号开头 + no(stored(f'SELECT "日期", "全日客流" FROM "日客流"{BOM}UNION ALL SELECT "日期", "分区甲" FROM "日客流"'), + "multi_table", "compound") + no(stored(f'SELECT "日期",{BOM}"分区甲"{BOM}AS "全日客流" FROM "日客流"'), "alias", "alias") + + +def test_bom_inside_a_name_is_part_of_the_name(): + """名字中间(含末尾)的 U+FEFF 是名字的一部分,同 SQLite:`日客流\ufeff` 是另一张表,`UNION\ufeff` 不是关键字。""" + tables = {"日客流": [f"日{BOM}期", "全日客流"]} + assert ok(f"SELECT 日{BOM}期 FROM 日客流", [f"日{BOM}期"], tables=tables).columns == [f"日{BOM}期"] + no(f"SELECT 全日客流 FROM 日客流{BOM}", "unparsed", "unknown_table", result=["全日客流"], tables=tables) + # 「日期\ufeffFROM」是一个名字,「日客流」成了它的别名,整条语句没有 FROM + no(f"SELECT 日期{BOM}FROM 日客流", "alias", "alias", result=["日客流"]) + conn = sqlite3.connect(":memory:") + conn.execute(f'CREATE TABLE "日客流" ("日{BOM}期", "全日客流")') + conn.execute("INSERT INTO 日客流 VALUES (1, 2)") + assert conn.execute(f"SELECT 日{BOM}期 FROM 日客流").fetchall() == [(1,)] + with pytest.raises(sqlite3.Error): + conn.execute(f"SELECT 全日客流 FROM 日客流{BOM}") + + +# --------------------------------------------------------------------------- +# 语句个数、注释、字符串 +# --------------------------------------------------------------------------- + + +def test_trailing_semicolons_are_allowed(): + assert ok('SELECT "日期" FROM "日客流";', ["日期"]).columns == ["日期"] + assert ok('SELECT "日期" FROM "日客流" ; ; ', ["日期"]).columns == ["日期"] + + +def test_two_statements_are_refused(): + no('SELECT "日期" FROM "日客流"; SELECT "日期" FROM "时段客流"', "unparsed", "multi_statement", result=["日期"]) + no('SELECT "日期" FROM "日客流"; DROP TABLE x', "unparsed", "multi_statement", result=["日期"]) + + +def test_keywords_inside_comments_and_strings_do_not_count(): + sql = ('SELECT "日期", "全日客流" /* JOIN "时段客流" */ FROM "日客流" -- UNION ALL SELECT\n' + "WHERE \"日期\" <> 'UNION SELECT x FROM y JOIN z' AND \"日期\" <> 'GROUP BY'") + assert ok(sql, ["日期", "全日客流"]).columns == ["日期", "全日客流"] + + +def test_keywords_inside_quoted_names_do_not_count(): + tables = {"union": ["select", "join"]} + out = ok('SELECT "select", [join] FROM "union"', ["select", "join"], tables=tables) + assert (out.table, out.columns) == ("union", ["select", "join"]) + + +def test_comments_split_words_like_sqlite_does(): + # SQLite 把注释当空白:SEL/**/ECT 是两个词,不是 SELECT + no('SEL/**/ECT "日期" FROM "日客流"', "unparsed", "not_select", result=["日期"]) + + +def test_unterminated_quotes_are_refused(): + no('SELECT "日期" FROM "日客流', "unparsed", "not_select") + # 反例:`"日期 FROM "` 是一个闭合的名字,后面的「日客流」是它的别名 + no('SELECT "日期 FROM "日客流', "alias", "alias") + no("SELECT 日期 FROM 日客流 WHERE 日期 = 'x", "unparsed", "not_select") + no("SELECT [日期 FROM 日客流", "unparsed", "not_select") + + +def test_empty_and_non_select_statements_are_refused(): + for sql in ("", " ", ";", "VALUES (1)", "PRAGMA table_info(日客流)", "EXPLAIN SELECT 日期 FROM 日客流", + "(SELECT 日期 FROM 日客流)"): + no(sql, "unparsed", "not_select") + + +# --------------------------------------------------------------------------- +# 选取项、FROM、尾部的其余写法 +# --------------------------------------------------------------------------- + + +def test_where_subquery_and_values_are_refused(): + no('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IN (SELECT "日期" FROM "日客流")', "multi_table", + "subquery") + no('SELECT "日期", "全日客流" FROM "日客流" WHERE EXISTS (SELECT 1)', "multi_table", "subquery") + no('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IN (VALUES (\'a\'))', "multi_table", "subquery") + no('SELECT "日期", (SELECT 1) FROM "日客流"', "multi_table", "subquery") + + +def test_join_family_words_are_refused_wherever_they_appear(): + for word in ("JOIN", "NATURAL JOIN", "CROSS JOIN", "INNER JOIN", "LEFT JOIN", "LEFT OUTER JOIN", "RIGHT JOIN", + "FULL JOIN"): + no(f'SELECT a."日期", a."全日客流" FROM "日客流" a {word} "时段客流" b', "multi_table", "join") + + +def test_compound_and_cte_words_are_refused(): + for word in ("UNION", "UNION ALL", "INTERSECT", "EXCEPT"): + no(f'SELECT "日期", "全日客流" FROM "日客流" {word} SELECT "日期", "分区甲" FROM "日客流"', "multi_table", + "compound") + no('WITH RECURSIVE x(n) AS (SELECT 1) SELECT "日期", "全日客流" FROM "日客流"', "multi_table", "cte") + no('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IN (WITH x AS (SELECT 1) SELECT * FROM x)', + "multi_table", "cte") + + +def test_window_clause_is_refused(): + no('SELECT "日期", "全日客流" FROM "日客流" WINDOW w AS (ORDER BY "日期")', "expression", "window") + + +def test_aggregation_in_the_tail_is_refused(): + no('SELECT "日期", "全日客流" FROM "日客流" GROUP BY "日期"', "expression", "expression") + no('SELECT "日期", "全日客流" FROM "日客流" WHERE 1 GROUP BY "日期" HAVING 1', "expression", "expression") + + +def test_is_distinct_from_in_the_tail_is_outside_this_grammar(): + no('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IS DISTINCT FROM NULL', "unparsed", "tail_shape") + + +@pytest.mark.parametrize(("item", "detail"), [ + ('CASE WHEN 1 THEN "全日客流" END', "expression"), + ('CAST("全日客流" AS TEXT)', "expression"), + ('"全日客流" IS NULL', "expression"), + ('"全日客流" BETWEEN 1 AND 2', "expression"), + ('-"全日客流"', "expression"), + ("+全日客流", "expression"), + ("(全日客流)", "expression"), + ("全日客流 || ''", "expression"), + ("x'00'", "expression"), + ("1", "expression"), + ("1.5", "expression"), + ('abs("全日客流")', "expression"), + ('"abs"("全日客流")', "expression"), + ("'全日客流'", "string_literal"), + ('"全日客流" COLLATE BINARY', "collate"), +]) +def test_items_that_are_not_bare_columns(item, detail): + no(f'SELECT "日期", {item} FROM "日客流"', "expression", detail) + + +@pytest.mark.parametrize("item", [ + '"全日客流" AS "分区甲"', '"全日客流" AS x', '"全日客流" "分区甲"', "全日客流 分区甲", "\"全日客流\" '分区甲'", + '"全日客流" [分区甲]', '"全日客流" `分区甲`', "t.全日客流 x", +]) +def test_renamed_items_are_aliases(item): + no(f'SELECT "日期", {item} FROM "日客流" t', "alias", "alias") + + +def test_qualifier_must_name_this_table(): + assert ok('SELECT "日客流"."日期", 日客流.全日客流 FROM "日客流"', ["日期", "全日客流"]).alias is None + no('SELECT "时段客流"."日期", "全日客流" FROM "日客流"', "unparsed", "qualifier") + no('SELECT a."日期", "全日客流" FROM "日客流" AS b', "unparsed", "qualifier") + # 起了别名之后原表名不能再当限定名(SQLite 报 no such column),只认别名 + no('SELECT 日客流."日期", "全日客流" FROM "日客流" AS t', "unparsed", "qualifier") + # 带库名的限定名 + no('SELECT main."日客流"."日期", "全日客流" FROM "日客流"', "unparsed", "qualifier") + + +def test_table_shapes(): + no('SELECT "日期", "全日客流" FROM temp."日客流"', "unparsed", "qualified_table") + no('SELECT "日期", "全日客流" FROM pragma_table_info(\'日客流\')', "unparsed", "table_shape") + # FROM (日客流) 在 SQLite 里能执行(括起来的表列表),一律不认 + no('SELECT "日期", "全日客流" FROM ("日客流")', "multi_table", "subquery") + # 字符串当表名、当表别名:SQLite 的历史兼容,这一版文法不认 + no("SELECT \"日期\", \"全日客流\" FROM '日客流'", "unparsed", "table_shape") + no("SELECT \"日期\", \"全日客流\" FROM \"日客流\" AS 't'", "unparsed", "table_shape") + no('SELECT "日期", "全日客流"', "unparsed", "table_shape") + no('SELECT "日期", "全日客流" FROM', "unparsed", "table_shape") + no('SELECT "日期", "全日客流" FROM WHERE', "unparsed", "table_shape") + + +def test_tail_shapes(): + no('SELECT "日期", "全日客流" FROM "日客流" NOT INDEXED', "unparsed", "tail_shape") + no('SELECT "日期", "全日客流" FROM "日客流" t u', "unparsed", "tail_shape") + no("SELECT \"日期\", \"全日客流\" FROM \"日客流\" 't'", "unparsed", "tail_shape") + no('SELECT "日期", "全日客流" FROM "日客流" t, "时段客流" u', "multi_table", "comma_join") + no('SELECT "日期", "全日客流" FROM "日客流", "时段客流"', "multi_table", "comma_join") + + +def test_accepted_tails(): + for tail in ("", " WHERE 1", " ORDER BY 2 DESC, 1", " LIMIT 3 OFFSET 1", " LIMIT 1, 2", + " WHERE \"日期\" BETWEEN '2026-08-01' AND '2026-08-31' ORDER BY \"日期\" COLLATE NOCASE LIMIT 5", + " WHERE \"日期\" LIKE '2026\\_%' ESCAPE '\\' AND \"分区甲\" IN (1, 2, 3)", + " WHERE CAST(\"分区甲\" AS TEXT) GLOB '1*' OR \"分区乙\" IS NULL", + " WHERE (CASE WHEN \"分区甲\" > 1 THEN 1 ELSE 0 END) = 1"): + out = ok(f'SELECT "日期", "全日客流" FROM "日客流"{tail}', ["日期", "全日客流"]) + assert out.columns == ["日期", "全日客流"], tail + + +def test_select_all_is_allowed(): + assert ok('SELECT ALL "日期" FROM "日客流"', ["日期"]).columns == ["日期"] + assert ok('SELECT ALL * FROM "日客流"', DAILY).columns == DAILY + + +def test_unknown_table_and_column(): + no('SELECT "日期" FROM "不存在"', "unparsed", "unknown_table", result=["日期"]) + no('SELECT "不存在" FROM "日客流"', "unparsed", "unknown_column", result=["不存在"]) + # rowid 不是冻结表结构里的列 + no('SELECT rowid, "日期" FROM "日客流"', "unparsed", "unknown_column", result=["rowid", "日期"]) + + +def test_colliding_names_in_the_schema_are_treated_as_unknown(): + # 冻结表结构里不该出现:说不清是哪一个,按找不到处理 + no("SELECT x FROM T", "unparsed", "unknown_table", result=["x"], tables={"t": ["x"], "T": ["x"]}) + no("SELECT x FROM t", "unparsed", "unknown_column", result=["x"], tables={"t": ["x", "X"]}) + + +def test_forbidden_words_are_the_spec_table(): + assert D.FORBIDDEN == { + "select", "from", "join", "union", "intersect", "except", "with", "over", "window", "group", "having", + "values", "natural", "cross", "inner", "left", "right", "full", "outer", "distinct", "recursive"} + + +# --------------------------------------------------------------------------- +# tables_in(P4-SPEC 2.4) +# --------------------------------------------------------------------------- + + +def test_tables_in_sees_tables_hidden_behind_escape_backslash(): + sql = stored("""SELECT "日期", "客流" FROM "时段客流" WHERE "时段" LIKE '%' ESCAPE '\\' UNION ALL """ + """SELECT "日期", "客流" FROM "时段客流_表内合计\"""") + assert tables_in(sql, TABLES) == ["时段客流", "时段客流_表内合计"] + # 2 版 sql_tables 用多方言读法,这里漏掉了合计表:这正是 tables_in 存在的理由 + from app.engine.evidence import sql_tables + assert "时段客流_表内合计" not in sql_tables(sql) + + +def test_tables_in_bracket_backtick_and_unquoted_chinese_names(): + assert tables_in("SELECT * FROM [时段客流] JOIN `日客流` USING (日期) JOIN 时段客流_表内合计", TABLES) == [ + "时段客流", "日客流", "时段客流_表内合计"] + assert tables_in("SELECT * FROM [t'] UNION ALL SELECT * FROM 日客流", {**TABLES, "t'": ["x"]}) == [ + "t'", "日客流"] + + +def test_tables_in_ignores_names_only_in_strings_and_comments(): + assert tables_in("SELECT * FROM 日客流 WHERE 分区 = '时段客流' -- 时段客流_表内合计", TABLES) == ["日客流"] + assert tables_in("SELECT '时段客流', \"日期\" FROM 日客流 /* abc */", TABLES) == ["日客流"] + + +def test_tables_in_counts_strings_where_sqlite_reads_them_as_table_names(): + # `FROM '时段客流'` 在 SQLite 里能执行(历史兼容):读到的就是这张表 + assert tables_in("SELECT 日期 FROM 日客流 UNION ALL SELECT 日期 FROM '时段客流'", TABLES) == [ + "日客流", "时段客流"] + assert tables_in("SELECT a.日期 FROM 日客流 AS a, '时段客流' b", TABLES) == ["日客流", "时段客流"] + assert tables_in("SELECT 日期 FROM main.'时段客流_表内合计'", TABLES) == ["时段客流_表内合计"] + assert tables_in("SELECT 1 FROM 日客流 a JOIN '时段客流' b ON 1", TABLES) == ["日客流", "时段客流"] + + +def test_tables_in_counts_a_string_right_after_in(): + """SQLite 文法 `expr IN nm` 的 nm 可以是字符串:`日期 IN '单列表'` 读的是那张表(评审 attacks.py 的 in table string)。 + 括号里的字符串是值列表,不算。""" + tables = {**TABLES, "单列": ["日期"]} + assert tables_in("SELECT 日期, 全日客流 FROM 日客流 WHERE 日期 IN '单列'", tables) == ["日客流", "单列"] + assert tables_in("SELECT 日期 FROM 日客流 WHERE 日期 NOT IN '单列'", tables) == ["日客流", "单列"] + assert tables_in("SELECT 日期 FROM 日客流 WHERE 日期 IN ('单列', '时段客流')", tables) == ["日客流"] + + +def test_tables_in_sees_tables_behind_bom_and_vertical_tab(): + """「紧跟」按 SQLite 的分词算:记号开头的 U+FEFF、紧跟其他空白的竖制表符都是空白,不是记号(评审 bom.py、vt.py)。 + 读错了,紧跟在后面的合计表就漏掉,裁判拿不到「不要相加」的口径行。""" + head = 'SELECT "日期", "全日客流" FROM "日客流" UNION ALL SELECT "日期", "客流" ' + expect = ["日客流", "时段客流_表内合计"] + assert tables_in(stored(f"{head}{BOM}FROM {BOM}时段客流_表内合计"), TABLES) == expect + assert tables_in(stored(f"{head}FROM {BOM}'时段客流_表内合计'"), TABLES) == expect + assert tables_in(stored(f"{head}FROM \x0b'时段客流_表内合计'"), TABLES) == expect + assert tables_in(stored(f"{head}FROM main.{BOM}'时段客流_表内合计'"), TABLES) == expect + tables = {**TABLES, "单列": ["日期"]} + assert tables_in(stored(f"SELECT 日期 FROM 日客流 WHERE 日期 IN {BOM}'单列'"), tables) == ["日客流", "单列"] + # 名字中间的 U+FEFF 是名字的一部分:「时段客流_表内合计\ufeff」不是那张表,SQLite 也报找不到 + assert tables_in(f"{head}FROM 时段客流_表内合计{BOM}", TABLES) == ["日客流"] + + +def test_tables_in_is_ordered_deduplicated_and_returns_schema_names(): + assert tables_in("select * from ABC, 日客流, abc, \"Abc\"", TABLES) == ["abc", "日客流"] + assert tables_in("SELECT 1", TABLES) == [] + assert tables_in("", TABLES) == [] + + +def test_tables_in_errs_on_the_side_of_more(): + # 列名恰好和某张表同名时多出这张表:后果只是多一行说明 + tables = {"日客流": ["日期", "时段客流"], "时段客流": ["日期"]} + assert tables_in('SELECT "时段客流" FROM "日客流"', tables) == ["时段客流", "日客流"] + # 引号没闭合也照样认能切出来的部分 + assert tables_in('SELECT * FROM 日客流 WHERE x = "时段客流', TABLES) == ["日客流", "时段客流"] + + +def test_tables_in_keeps_fullwidth_space_inside_the_name(): + assert tables_in("SELECT * FROM 日客流\u3000WHERE 1", TABLES) == [] + assert tables_in("SELECT * FROM 日客流 WHERE 1", TABLES) == ["日客流"] + + +# --------------------------------------------------------------------------- +# 模块边界、不抛异常 +# --------------------------------------------------------------------------- + + +def test_module_imports_only_guard_names_and_the_contract(): + """P4-SPEC 7.2:判据是纯函数,只许导入守卫、names 和 provenance_types(加标准库),不依赖证据层、接口层。""" + tree = ast.parse(Path(D.__file__).read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported |= {a.name for a in node.names} + elif isinstance(node, ast.ImportFrom): + imported.add(node.module or "") + assert imported <= {"__future__", "collections.abc", "dataclasses", "app.data.guard", "app.data.names", + "app.data.provenance_types"}, imported + + +_PIECES = [ + "SELECT", "FROM", "WHERE", "ORDER", "BY", "LIMIT", "AS", "JOIN", "UNION", "ALL", "DISTINCT", "*", ",", ".", "(", + ")", ";", "'", '"', "[", "]", "`", "\\", "--", "/*", "*/", "日期", "全日客流", '"日客流"', "日客流", "t", "x'0'", + "1", "NULL", "\u3000", "\n", " ", "COLLATE", "OVER", "abc", "KEY", "=", "'a''b'", '"a""b"', "[t']", "ESCAPE", +] + + +def test_recognize_never_raises_and_every_refusal_is_mapped(): + rng = random.Random(20261002) + for _ in range(3000): + sql = " ".join(rng.choice(_PIECES) for _ in range(rng.randint(0, 14))) + if rng.random() < 0.5: + sql = "SELECT " + sql + result = rng.choice([["日期"], ["日期", "全日客流"], DAILY, [], ["Key", "v"]]) + out = recognize(sql, result_columns=result, tables=TABLES) + assert isinstance(out, (DirectSelect, SelectRefusal)), sql + if isinstance(out, SelectRefusal): + assert out.detail in P.DETAILS[out.code], (sql, out) + else: + assert out.table in TABLES and all(c in TABLES[out.table] for c in out.columns), (sql, out) + assert isinstance(tables_in(sql, TABLES), list) + + +# --------------------------------------------------------------------------- +# 拿真的 SQLite 核对:认可的每一列,值都来自它说的那一列 +# --------------------------------------------------------------------------- + +_DB_TABLES = { + "日客流": ["日期", "全日客流", "分区甲", "分区乙"], + "时段客流": ["日期", "时段", "客流"], + "abc": ["Key", "v"], +} + + +def _database(rng: random.Random, tables: dict[str, list[str]] | None = None + ) -> tuple[sqlite3.Connection, dict[object, tuple[str, str]]]: + """每一格的值全库唯一:结果里的任何一个值都能反查出它来自哪张表的哪一列。DQS 关掉,同 engine。""" + conn = sqlite3.connect(":memory:") + if hasattr(conn, "setconfig"): + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DML, False) + conn.setconfig(sqlite3.SQLITE_DBCONFIG_DQS_DDL, False) + origin: dict[object, tuple[str, str]] = {} + values = iter(rng.sample(range(100_000, 999_999), 200)) + for table, cols in (tables or _DB_TABLES).items(): + quoted = ", ".join(f'"{c}"' for c in cols) + conn.execute(f'CREATE TABLE "{table}" ({quoted})') + for day in range(1, 6): + row = [] + for c in cols: + v: object = f"2026-08-{day:02d}#{table}" if c == "日期" else next(values) + origin[v] = (table, c) + row.append(v) + conn.execute(f'INSERT INTO "{table}" VALUES ({", ".join("?" * len(cols))})', row) + return conn, origin + + +_SELECTS = [ + '"日期", "全日客流"', '"日期", "分区甲"', "日期, 分区乙", 't."日期", t."全日客流"', "*", '"全日客流"', + '"日期", "分区甲" AS "全日客流"', '"日期" "全日客流"', '"分区甲" "全日客流", "日期"', '"日期", "全日客流" + 0', + '"日期", 分区甲 AS 全日客流', 'T."日期", "全日客流"', '"全日客流", "日期"', "[日期], `全日客流`", + '"日期", (SELECT "分区甲")', '"日期", "全日客流" COLLATE NOCASE', '"日期", coalesce("全日客流", 0)', +] +_FROMS = [ + '"日客流"', '"日客流" t', '"日客流" AS t', "日客流 AS [t']", '"日客流" AS "T"', '"日客流" AS t, "日客流" u', + '"日客流" t JOIN "日客流" u USING ("日期")', '(SELECT "日期", "分区甲" AS "全日客流", "分区乙", "分区甲" FROM "日客流")', + '"日客流" NOT INDEXED', "'日客流'", +] +_TAILS = [ + "", " WHERE 1", " ORDER BY 1 DESC", " LIMIT 2", " WHERE \"日期\" LIKE '%' ESCAPE '\\'", + " WHERE \"日期\" = '\\'", " ORDER BY [x']", " WHERE '\\' = '\\'", + """ UNION ALL SELECT "日期", "分区甲" FROM "日客流\"""", + """ WHERE "日期" LIKE '%' ESCAPE '\\' UNION ALL SELECT "日期", "分区甲" FROM "日客流\"""", + """ AS [u'] UNION ALL SELECT "日期", "分区乙" FROM "日客流" AS [v']""", + """ UNION SELECT "日期", "分区甲" FROM "日客流\"""", + """ EXCEPT SELECT "日期", "分区甲" FROM "日客流" WHERE 0""", + """ WHERE "日期" IN (SELECT "日期" FROM "时段客流")""", + " GROUP BY \"日期\"", " /* UNION ALL SELECT 1 */", " -- JOIN x", +] + + +def _tables_read(conn: sqlite3.Connection, stmt: str) -> set[str]: + """SQLite 的授权回调报出的、这条语句读到的表:tables_in 必须一张不漏(宁可多认)。""" + read: set[str] = set() + + def authorize(action: int, table: str | None, *_: object) -> int: + if action == sqlite3.SQLITE_READ and table and not table.startswith("sqlite_"): + read.add(table) + return sqlite3.SQLITE_OK + + conn.set_authorizer(authorize) + try: + conn.execute(stmt).fetchall() + finally: + conn.set_authorizer(None) + return read + + +def _check_accepted_values(out: DirectSelect, rows: list[tuple], origin: dict[object, tuple[str, str]], + stmt: str) -> None: + for row in rows: + for i, value in enumerate(row): + assert origin.get(value) == (out.table, out.columns[i]), ( + f"{stmt!r}:第 {i} 列认作 {out.table}.{out.columns[i]},值 {value!r} 实际来自 {origin.get(value)}") + + +def test_every_accepted_column_really_comes_from_the_column_it_names(): + """随机拼 SQL,经守卫、在 SQLite 上真的执行;判据认可的,结果第 i 列的每个值都必须来自 DirectSelect 说的那张表的 + 第 i 列。B7 那类写法(集合运算拼进另一列的值)一旦被放过,这里就会报出值的真实来处。授权回调报出的表, + tables_in 也必须一张不漏。""" + rng = random.Random(4242) + conn, origin = _database(rng) + executed = accepted = 0 + combos = list(itertools.product(_SELECTS, _FROMS, _TAILS)) + rng.shuffle(combos) + for select, source, tail in combos: + sql = f"SELECT {select} FROM {source}{tail}" + try: + stmt = stored(sql) + cur = conn.execute(stmt) + except (guard.SqlRejected, sqlite3.Error): + continue + executed += 1 + columns = [d[0] for d in cur.description] + rows = cur.fetchall() + read = _tables_read(conn, stmt) + assert read <= set(tables_in(stmt, _DB_TABLES)), (stmt, read) + out = recognize(stmt, result_columns=columns, tables=_DB_TABLES) + if isinstance(out, SelectRefusal): + assert out.detail in P.DETAILS[out.code], (stmt, out) + continue + accepted += 1 + assert len(out.columns) == len(columns), stmt + _check_accepted_values(out, rows, origin, stmt) + # 拼出来的大多数都能执行,其中有认可的、也有被拒的:这条测试确实在比对 + assert executed > 500 and 50 < accepted < executed, (executed, accepted) + + +def test_b7_writings_really_return_another_columns_values_in_sqlite(): + """反证上一条的比对有牙齿:B7 的写法在 SQLite 里确实把分区甲的值放进了全日客流那一列。""" + rng = random.Random(7) + conn, origin = _database(rng) + for sql in B7_COMPOUND[:1] + B7_COMPOUND[3:4]: + rows = conn.execute(stored(sql).replace("'2026-08%'", "'%'").replace("'2026-09%'", "'%'") + .replace("> '2026-09'", "> ''")).fetchall() + assert {origin[r[1]] for r in rows} == {("日客流", "全日客流"), ("日客流", "分区甲")}, sql + + +#: 记号之间的间隔:把模板里的 {g} 换成它。紧跟在关键字、数字后面的、紧跟在带引号的名字和标点后面的都有, +#: 记号开头和空白后面两种位置都覆盖到 +_GAP_TEMPLATES = [ + 'SELECT "日期", "全日客流" FROM "日客流" WHERE "分区甲" > 0{g}UNION ALL{g}SELECT "日期", "分区甲"{g}FROM "日客流"', + 'SELECT "日期", "全日客流" FROM "日客流" WHERE "分区甲" > 0{g}UNION{g}SELECT "日期", "分区甲"{g}FROM "日客流"', + # 限定名:间隔要是被 SQLite 读成了表别名,「"日客流"."日期"」就找不到列,不会被误当成空白 + 'SELECT "日客流"."日期", "全日客流" FROM "日客流"{g}UNION ALL SELECT "日期", "分区乙" FROM "日客流"', + 'SELECT "日期", "全日客流" FROM "日客流" WHERE "分区甲" > 0{g}GROUP BY "日期"', + 'SELECT "日期", "全日客流" FROM "日客流" UNION ALL SELECT "日期", "客流" FROM{g}时段客流', + "SELECT \"日期\", \"全日客流\" FROM \"日客流\" UNION ALL SELECT \"日期\", \"客流\" FROM{g}'时段客流'", + "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"全日客流\" NOT IN{g}'单列'", + "SELECT{g}\"日期\",{g}\"全日客流\"{g}FROM{g}\"日客流\"{g}WHERE{g}\"日期\" <> '2026-08-01#日客流'", +] +#: 候选字符:ASCII 控制字符和空格、str.isspace 认而 SQLite 不认的、U+FEFF 及其邻居 +_GAP_CHARS = [chr(c) for c in (*range(0x01, 0x21), 0x7F, 0x85, 0xA0, 0x1680, 0x200B, 0x2028, 0x2029, 0x3000, 0xFEFF, + 0xFFFE)] +#: SQLite 当空白的:记号开头只有这六个;竖制表符只在紧跟其他空白时才是 +_SQLITE_SPACE = "\t\n\x0c\r \ufeff" + + +def test_gaps_that_sqlite_reads_as_whitespace_read_the_same_here(): + """拿真的 SQLite 核对分词:把记号之间的空格换成各种字符,SQLite 能执行的每一条—— + - 认可的,值都来自它说的那一列;授权回调报出的表 tables_in 一张不漏; + - 结果、列名、字节码都和用空格的那条相同(SQLite 把它当空白读)时,判据的结论和 tables_in 也必须相同。 + 最后核对「SQLite 当空白的间隔」恰好是 U+FEFF、五个 ASCII 空白和空白后面的竖制表符:SQLite 升级改了读法, + 这里先失败。评审的 U+FEFF、竖制表符两处都是这条能抓到的。""" + tables = {**_DB_TABLES, "单列": ["日期"]} + conn, origin = _database(random.Random(5), tables) + gaps = sorted({*_GAP_CHARS, *(" " + c for c in _GAP_CHARS), *(c + " " for c in _GAP_CHARS)}) + + def run(stmt: str) -> tuple[list[str], list[tuple], list[tuple]]: + cur = conn.execute(stmt) + columns, rows = [d[0] for d in cur.description], cur.fetchall() + # EXPLAIN 的最后一列是注释,会带出原文片段,只比操作码和五个操作数 + program = [tuple(r[1:7]) for r in conn.execute("EXPLAIN " + stmt).fetchall()] + return columns, rows, program + + whitespace: set[str] = set() + executed_with: dict[str, int] = {} + for g in gaps: + executed = same = 0 + for template in _GAP_TEMPLATES: + base_stmt = stored(template.format(g=" ")) + try: + stmt = stored(template.format(g=g)) + columns, rows, program = run(stmt) + except (guard.SqlRejected, sqlite3.Error): + continue + executed += 1 + out = recognize(stmt, result_columns=columns, tables=tables) + if isinstance(out, DirectSelect): + _check_accepted_values(out, rows, origin, stmt) + found = tables_in(stmt, tables) + assert _tables_read(conn, stmt) <= set(found), (g, stmt, found) + if (columns, rows, program) == run(base_stmt): + same += 1 + base = recognize(base_stmt, result_columns=columns, tables=tables) + assert out == base, f"{g!r}:{stmt!r} → {out!r},用空格时 → {base!r}" + assert found == tables_in(base_stmt, tables), (g, stmt) + executed_with[g] = executed + if executed and same == executed: + whitespace.add(g) + expected = {c for c in _SQLITE_SPACE} | {" " + c for c in _SQLITE_SPACE + "\x0b"} | {c + " " for c in _SQLITE_SPACE} + assert whitespace == expected, (sorted(map(repr, whitespace - expected)), sorted(map(repr, expected - whitespace))) + # 有牙齿:空白后面的 U+FEFF、竖制表符在每个模板里都执行了(含藏 UNION ALL、GROUP BY 的那几条) + assert executed_with[" " + BOM] == executed_with[" \x0b"] == len(_GAP_TEMPLATES) diff --git a/backend/tests/test_judge_provenance.py b/backend/tests/test_judge_provenance.py new file mode 100644 index 00000000..8ee6ba83 --- /dev/null +++ b/backend/tests/test_judge_provenance.py @@ -0,0 +1,284 @@ +"""裁判摘录的期 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) diff --git a/backend/tests/test_provenance.py b/backend/tests/test_provenance.py new file mode 100644 index 00000000..86c7b13b --- /dev/null +++ b/backend/tests/test_provenance.py @@ -0,0 +1,1225 @@ +"""证据下钻的清单与格子(WP-B,P4-SPEC 2.2、2.5、2.7、3.2、6.3)。 + +清单用真管线造(模块级夹具跑一遍):经接口(AsyncClient + ASGITransport)导入几份合成的表格,从数据库取出冻结的 +表结构和导入清单,作为纯函数的输入。之后的用例只用内存里的工件和一个按 id 取的 loader,不再走管线: +- rep:8 月 D00 每期替换(S1),再上传 9 月 D26 写理由接受 R1(S2,单期、seq 2); +- acc:8 月 D00 按期累积(S1,单期),再上传 9 月 D24w(全角时段标签,S2 是并集); +- rm:8 月加 9 月按期累积之后移除 8 月,只剩 9 月一期(清单里的 snapshot_id 是当初的并集,不是现在这个); +- lst:lab.c01 列表(带合计行,另存成表); +- d28:9 月 D28(表下有含数字的说明 B32),首次导入; +- c04:lab.c04 列表(两行表头,上层横向合并、左两列纵向合并,合并的数据按左上格填充)。 + +坐标一律和 openpyxl 读到的合成夹具对照:被引用的格保存的值等于快照库里那一格的值。夹具全部合成、假名, +不调用任何模型。 +""" +from __future__ import annotations + +import asyncio +import copy +import hashlib +import io +import json +import sqlite3 +import uuid +from contextlib import closing +from dataclasses import dataclass, field +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient +from openpyxl import load_workbook + +from app.core import artifact_store +from app.core.config import settings +from app.data import provenance as P +from app.data import provenance_types as T +from app.db.base import SessionLocal +from app.db.base import engine as db_engine +from app.db.models import SourceSnapshot +from app.main import app +from app.tools.datasource import _FROZEN_SCHEMA_KEYS +from tests.fixtures.xlsx import lab +from tests.fixtures.xlsx.drift import AUG, SEP +from tests.fixtures.xlsx.flow import SHEET, flow_workbook + +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +API = "/api/datasources" +TABLES = ("日客流", "时段客流", "时段客流_表内合计") +REASON = "合成理由:分区合计口径调整" +DAY_TITLE = "日间时段客流(人次)" + + +# ========================================================================== +# 模块级夹具:真管线跑一遍 +# ========================================================================== + + +@dataclass +class Snap: + id: str + name: str + source_id: str + db_path: str + db_sha256: str + #: 冻结的表结构快照(同 datasource._store_schema 存进 schema_snapshot 的内容) + schema: dict[str, Any] + + +@dataclass +class World: + #: 工件 id → 内容:导入清单、快照清单(测试里的 loader 只认它) + store: dict[str, Any] + snaps: dict[str, Snap] + #: 名字 → [每一期的原件字节](按统计期) + raws: dict[str, list[bytes]] + imports: dict[str, list[str]] = field(default_factory=dict) + + +def _answers(questions: list[dict[str, Any]], mode: str, prefer: str | None = None) -> dict[str, dict[str, Any]]: + """prefer:选项的 effects 里把某个字段设成这个值的优先选(lab.c04 合并单元格的问题选 fill)。""" + out = {} + for q in questions: + values = [o["value"] for o in q["options"]] + effects = q.get("effects") or {} + wanted = [v for v in values if prefer and prefer in json.dumps(effects.get(v, []), ensure_ascii=False)] + if wanted: + out[q["id"]] = {"value": wanted[0]} + elif 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]: + 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 + + +async def _commit(c: AsyncClient, st: dict[str, Any], acceptances: list[dict[str, str]] | None = None) -> dict: + body: dict[str, Any] = {"trial_id": st["trial"]["trial_id"], + "confirmations": [i["id"] for i in st["trial"]["confirm_items"]]} + if acceptances: + 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() + + +async def _first(c: AsyncClient, raw: bytes, fn: str, mode: str, *, flow: bool = True, + prefer: str | None = None) -> dict[str, Any]: + name = f"prov_{uuid.uuid4().hex[:8]}" + 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(st["questions"], mode, prefer)}) + assert r.status_code == 200, r.text + st = r.json() + if flow: + 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 {"name": name, "source_id": out["source"]["id"], "snapshot": out["snapshot_id"], + "import": out["import_id"]} + + +async def _add(c: AsyncClient, source_id: str, raw: bytes, fn: str, + acceptances: list[dict[str, str]] | None = None) -> dict[str, Any]: + r = await c.post(f"{API}/{source_id}/reupload", files={"file": (fn, raw, XLSX)}) + assert r.status_code == 201, r.text + st = r.json() + assert st["trial"]["status"] in ("passed", "needs_decision"), st["trial"]["problems"] + return await _commit(c, st, acceptances) + + +async def _snap(name: str, snapshot_id: str, store: dict[str, Any]) -> Snap: + async with SessionLocal() as s: + row = await s.get(SourceSnapshot, snapshot_id) + cache = row.schema_cache + schema = {"source": name, "tables": cache["tables"], **{k: cache[k] for k in _FROZEN_SCHEMA_KEYS if k in cache}} + for mid in cache.get("import_manifests") or []: + store[mid] = artifact_store.load(mid) + if cache.get("snapshot_manifest"): + store[cache["snapshot_manifest"]] = artifact_store.load(cache["snapshot_manifest"]) + return Snap(snapshot_id, name, row.source_id, row.db_path, row.db_sha256, json.loads(json.dumps(schema))) + + +async def _build() -> World: + store: dict[str, Any] = {} + snaps: dict[str, Snap] = {} + raws: dict[str, list[bytes]] = {} + imports: dict[str, list[str]] = {} + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + aug, aug_fn = flow_workbook(AUG, 31, seed=401) + rep = await _first(c, aug, aug_fn, "replace") + snaps["rep1"] = await _snap(rep["name"], rep["snapshot"], store) + d26, d26_fn = flow_workbook(SEP, 30, seed=402, variant="D26") + out = await _add(c, rep["source_id"], d26, d26_fn, acceptances=[{"check_id": "R1", "reason": REASON}]) + snaps["rep2"] = await _snap(rep["name"], out["snapshot_id"], store) + raws["rep1"], raws["rep2"] = [aug], [d26] + + acc = await _first(c, aug, aug_fn, "accumulate") + snaps["acc1"] = await _snap(acc["name"], acc["snapshot"], store) + sepw, sepw_fn = flow_workbook(SEP, 30, seed=403, variant="D24w") + out = await _add(c, acc["source_id"], sepw, sepw_fn) + snaps["acc2"] = await _snap(acc["name"], out["snapshot_id"], store) + raws["acc1"], raws["acc2"] = [aug], [aug, sepw] + imports["acc2"] = [acc["import"], out["import_id"]] + + rm = await _first(c, aug, aug_fn, "accumulate") + sep, sep_fn = flow_workbook(SEP, 30, seed=404) + out = await _add(c, rm["source_id"], sep, sep_fn) + r = await c.post(f"{API}/{rm['source_id']}/imports/{rm['import']}/remove", + json={"confirm": True, "expected_current_snapshot_id": out["snapshot_id"], "reason": REASON}) + assert r.status_code == 200, r.text + snaps["rm_union"] = await _snap(rm["name"], out["snapshot_id"], store) + snaps["rm"] = await _snap(rm["name"], r.json()["snapshot_id"], store) + raws["rm"] = [sep] + + c01, c01_fn = lab.c01() + lst = await _first(c, c01, c01_fn, "replace", flow=False) + snaps["lst"] = await _snap(lst["name"], lst["snapshot"], store) + raws["lst"] = [c01] + + d28, d28_fn = flow_workbook(SEP, 30, seed=405, variant="D28") + one = await _first(c, d28, d28_fn, "replace") + snaps["d28"] = await _snap(one["name"], one["snapshot"], store) + raws["d28"] = [d28] + + c04, c04_fn = lab.c04() + mrg = await _first(c, c04, c04_fn, "replace", flow=False, prefer="fill") + snaps["c04"] = await _snap(mrg["name"], mrg["snapshot"], store) + raws["c04"] = [c04] + # 这个事件循环里建的连接留在连接池里,换了事件循环的用例取到会出错:交还之前全部关掉 + await db_engine.dispose() + return World(store, snaps, raws, imports) + + +@pytest.fixture(scope="module") +def world(tmp_path_factory) -> World: + from app.data import recipe_ai + from app.data.recipe_types import AiAvailability + + async def no_model(session): + return None, "", AiAvailability(False, reason="测试不接模型") + + data = tmp_path_factory.mktemp("provenance") / "data" + (data / "uploads").mkdir(parents=True) + mp = pytest.MonkeyPatch() + mp.setattr(settings, "data_dir", data) + mp.setattr(recipe_ai, "resolve_draft_model", no_model) + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(_build()) + finally: + loop.close() + mp.undo() + + +# ========================================================================== +# 小工具 +# ========================================================================== + + +def loader(store: dict[str, Any], *, broken: frozenset[str] = frozenset(), gone: frozenset[str] = frozenset()): + """语义同 artifact_store.load:取不到返回 None,哈希不符抛 ValueError。每次给一份拷贝,用例改了不串。""" + def load(aid: str) -> Any: + if aid in broken: + raise ValueError(f"工件 {aid[:12]}… 内容与哈希不符") + if aid in gone or aid not in store: + return None + return copy.deepcopy(store[aid]) + return load + + +def query_of(snap: Snap, columns: list[str] | None = None, rows: list[list[Any]] | None = None) -> dict[str, Any]: + """查询快照里推断要用的几项(source、data_version、columns)。""" + return {"source": snap.name, "data_version": snap.id, "schema_artifact": "schema", "sql": "SELECT 1", + "columns": columns or [], "rows": rows or []} + + +def chain_of(world: World, key: str, store: dict[str, Any] | None = None) -> T.Chain: + snap = world.snaps[key] + got = P.resolve_chain(query_of(snap), snap.schema, loader(store or world.store)) + assert isinstance(got, T.Chain), got + return got + + +def db_row(snap: Snap, table: str, where: dict[str, Any], column: str) -> tuple[int, Any]: + """按主键在快照库上取 (rowid, 值):只读、immutable,不改文件。""" + cond = " AND ".join(f'"{k}" = ?' for k in where) + with closing(sqlite3.connect(f"file:{snap.db_path}?mode=ro&immutable=1", uri=True)) as conn: + got = conn.execute(f'SELECT rowid, "{column}" FROM "{table}" WHERE {cond}', list(where.values())).fetchall() + assert len(got) == 1, (table, where, got) + return got[0] + + +def xl(raw: bytes, sheet: str, ref: str) -> Any: + return load_workbook(io.BytesIO(raw), data_only=True)[sheet][ref].value + + +def located(world: World, key: str, table: str, where: dict[str, Any], column: str, + store: dict[str, Any] | None = None) -> tuple[T.Located, Any]: + snap = world.snaps[key] + rowid, value = db_row(snap, table, where, column) + got = P.locate(chain_of(world, key, store), table=table, column=column, rowid=rowid) + assert isinstance(got, T.Located), got + return got, value + + +def froms(cs: T.CellSource) -> list[tuple[str, str | None, str, str | None, str | None]]: + return [(f.role, f.column, f.cell, f.text, f.locate_title) for f in cs.from_] + + +def edited(world: World, key: str, fn) -> tuple[dict[str, Any], str]: + """把某个快照的(唯一一份)导入清单拷贝一份、交给 fn 改,换一个新 id 放进新的 store;返回 (store, 新 id)。 + loader 是测试自己的,不复验哈希,所以 id 随便取;表结构快照也同步改成列这份新清单。""" + snap = world.snaps[key] + store = copy.deepcopy(world.store) + mid = snap.schema["import_manifests"][-1] + doc = copy.deepcopy(store[mid]) + fn(doc) + new = "edited" + mid[:20] + store[new] = doc + return store, new + + +def with_manifest(world: World, key: str, fn) -> tuple[T.Chain, dict[str, Any]]: + """单期快照的清单被 fn 改过之后的链(和 store)。""" + store, new = edited(world, key, fn) + snap = world.snaps[key] + schema = {**copy.deepcopy(snap.schema), "import_manifests": [new]} + got = P.resolve_chain(query_of(snap), schema, loader(store)) + assert isinstance(got, T.Chain), got + return got, store + + +def recipe_blocks(doc: dict[str, Any]) -> list[dict[str, Any]]: + return [b for s in doc["recipe"]["canonical"]["sheets"] for b in s["blocks"]] + + +# ========================================================================== +# resolve_chain(2.2) +# ========================================================================== + + +def test_single_period_chain_takes_the_manifest_hash_and_seq(world): + snap = world.snaps["rep1"] + chain = chain_of(world, "rep1") + assert (chain.source, chain.snapshot_id, chain.mode, chain.union) == (snap.name, snap.id, "replace", False) + assert chain.expected_db_sha256 == snap.db_sha256 and chain.source_id == snap.source_id + assert chain.snapshot_manifest is None and len(chain.parts) == 1 + part = chain.parts[0] + assert (part.seq, part.manifest_id, part.union_rows) == (1, snap.schema["import_manifests"][0], None) + assert part.manifest["import_id"] == part.import_id + # 每期替换的第二期只有一项,seq 是导入记录的 2,不是在 parts 里的位置 + second = chain_of(world, "rep2") + assert second.parts[0].seq == 2 and second.expected_db_sha256 == world.snaps["rep2"].db_sha256 + # 按期累积的第一期:单期,mode 取配方的 accumulate + first = chain_of(world, "acc1") + assert (first.union, first.mode, len(first.parts)) == (False, "accumulate", 1) + + +def test_union_chain_maps_parts_to_their_manifests(world): + snap = world.snaps["acc2"] + chain = chain_of(world, "acc2") + sm = world.store[snap.schema["snapshot_manifest"]] + assert (chain.union, chain.mode, chain.snapshot_id) == (True, "accumulate", snap.id) + assert chain.expected_db_sha256 == sm["union"]["db_sha256"] == snap.db_sha256 + assert chain.snapshot_manifest == sm and chain.source_id == snap.source_id + assert [p.manifest_id for p in chain.parts] == snap.schema["import_manifests"] + assert [p.import_id for p in chain.parts] == world.imports["acc2"] and [p.seq for p in chain.parts] == [1, 2] + for p, sp in zip(chain.parts, sm["parts"]): + for table, rows in sp["rows"].items(): + assert p.union_rows[table] == [*rows["union"], *rows["part"]] + assert chain.parts[1].union_rows["日客流"] == [32, 61, 1, 30] + + +def test_single_period_left_after_removal_does_not_compare_snapshot_ids(world): + """移除 8 月之后只剩 9 月:冻结的 import_manifests 沿用 9 月原来的清单,清单里的 snapshot_id 是当初的并集。 + 单期不比快照 id(改成比,这一例要失败),期望的库哈希取这份清单的 db_sha256。""" + snap = world.snaps["rm"] + assert "snapshot_manifest" not in snap.schema and len(snap.schema["import_manifests"]) == 1 + manifest = world.store[snap.schema["import_manifests"][0]] + assert manifest["snapshot_id"] == world.snaps["rm_union"].id != snap.id + chain = chain_of(world, "rm") + assert (chain.union, chain.snapshot_id, chain.parts[0].seq) == (False, snap.id, 2) + assert chain.expected_db_sha256 == manifest["db_sha256"] == snap.db_sha256 + + +def test_chain_problems(world): + rep, acc = world.snaps["rep1"], world.snaps["acc2"] + load = loader(world.store) + # 单期 import_manifests 不是恰好 1 份:链本身对不上 + for mids in ([], [rep.schema["import_manifests"][0]] * 2): + got = P.resolve_chain(query_of(rep), {**rep.schema, "import_manifests": mids}, load) + assert (got.code, got.detail) == ("chain_mismatch", "import_manifests_count") + # 并集:快照清单的 snapshot_id 不等于查询快照的 data_version + got = P.resolve_chain({**query_of(acc), "data_version": world.snaps["acc1"].id}, acc.schema, load) + assert (got.code, got.detail) == ("chain_mismatch", "snapshot_id") + # 并集:parts 与 import_manifests 逐个相等(顺序也算) + swapped = {**acc.schema, "import_manifests": list(reversed(acc.schema["import_manifests"]))} + got = P.resolve_chain(query_of(acc), swapped, load) + assert (got.code, got.detail) == ("chain_mismatch", "parts_vs_import_manifests") + dropped = {**acc.schema, "import_manifests": acc.schema["import_manifests"][:1]} + assert P.resolve_chain(query_of(acc), dropped, load).code == "chain_mismatch" + # 取回抛 ValueError(哈希不符)、返回 None(取不回):manifest_unreadable,不往外抛 + for kw in ({"broken": frozenset(rep.schema["import_manifests"])}, {"gone": frozenset(rep.schema["import_manifests"])}): + got = P.resolve_chain(query_of(rep), rep.schema, loader(world.store, **kw)) + assert got.code == "manifest_unreadable", got + sm_id = acc.schema["snapshot_manifest"] + assert P.resolve_chain(query_of(acc), acc.schema, loader(world.store, broken=frozenset({sm_id}))).code \ + == "manifest_unreadable" + part2 = acc.schema["import_manifests"][1] + assert P.resolve_chain(query_of(acc), acc.schema, loader(world.store, gone=frozenset({part2}))).code \ + == "manifest_unreadable" + + def boom(aid): + raise OSError("磁盘错误") + assert P.resolve_chain(query_of(rep), rep.schema, boom).code == "manifest_unreadable" + + +def test_union_chain_cross_checks_each_part(world): + acc = world.snaps["acc2"] + sm_id = acc.schema["snapshot_manifest"] + + def chain_with(fn): + store = copy.deepcopy(world.store) + fn(store[sm_id]) + return P.resolve_chain(query_of(acc), acc.schema, loader(store)) + + def other_import(sm): + sm["parts"][1]["import_id"] = "别的导入" + assert chain_with(other_import).detail == "part_identity" + + def other_source(sm): + sm["source_id"] = "别的数据源" + assert chain_with(other_source).detail == "source_id" + + def bad_rows(sm): + sm["parts"][0]["rows"]["日客流"] = {"union": [1, 31]} + assert chain_with(bad_rows).detail == "part_rows" + + +# ========================================================================== +# locate(2.5):五种形态逐个对坐标 +# ========================================================================== + + +def test_wide_table_measure_cell(world): + got, value = located(world, "rep1", "日客流", {"日期": "2026-08-05"}, "全日客流") + cs = got.cell + assert (cs.sheet, cs.cell, cs.column_role, cs.kind, cs.part_seq) == (SHEET, "G5", "measure", "data", 1) + assert cs.part_rowid == cs.rowid == got.part_rowid == 5 + assert xl(world.raws["rep1"][0], SHEET, "G5") == value + assert froms(cs) == [("axis_header", "日期", "G4", None, None), + ("row_label", None, "B5", "全日客流(人次)", None)] + assert (cs.header, cs.unit) == ("全日客流(人次)", "人次") + assert cs.year == T.YearSource(source="period", cells=[f"{SHEET}!B2"], signed_by=None, mixed=False) + assert cs.canonical is None and cs.merged_fill is False and not cs.pk and cs.recheck is None + assert xl(world.raws["rep1"][0], SHEET, "B5") == "全日客流(人次)" + + +def test_anchor_is_the_cited_column_itself(world): + """被引用的列有溯源段就取它自己的格(不是同一行第一个有溯源段的列)。""" + raw = world.raws["rep1"][0] + for column, ref in (("分区甲", "AG6"), ("分区乙", "AG7")): + got, value = located(world, "rep1", "日客流", {"日期": "2026-08-31"}, column) + assert got.cell.cell == ref and xl(raw, SHEET, ref) == value + assert froms(got.cell)[1] == ("row_label", None, f"B{ref[2:]}", xl(raw, SHEET, f"B{ref[2:]}"), None) + + +def test_long_table_value_cell_from_section_title(world): + got, value = located(world, "rep1", "时段客流", {"日期": "2026-08-20", "时段": "13-14"}, "客流") + cs = got.cell + raw = world.raws["rep1"][0] + assert (cs.cell, cs.column_role) == ("V16", "value") and xl(raw, SHEET, "V16") == value + assert froms(cs) == [("axis_header", "日期", "V4", None, None), + ("row_label", "时段", "B16", "13-14", None), + ("section_title", "时段类别", "B9", None, DAY_TITLE)] + assert xl(raw, SHEET, "B9") == DAY_TITLE and cs.canonical is None + + +def test_reported_total_cell_gets_the_total_label_and_canonical(world): + got, value = located(world, "rep1", "时段客流_表内合计", {"日期": "2026-08-10", "合计项": "18-22时合计"}, "客流") + cs = got.cell + raw = world.raws["rep1"][0] + assert (cs.cell, cs.kind) == ("L28", "reported_total") and xl(raw, SHEET, "L28") == value + # derived_label → total_label;原文取 DerivedItem.label_raw,规范写法(库里合计项的值)取 label + assert froms(cs) == [("axis_header", "日期", "L4", None, None), + ("total_label", "合计项", "B28", "18-22 时合计", None)] + assert xl(raw, SHEET, "B28") == "18-22 时合计" + assert cs.canonical == T.CanonicalView(raw="18-22 时合计", canonical="18-22时合计") + + +def test_list_cell_gets_its_column_header(world): + got, value = located(world, "lst", "月报", {"地区": lab.ZONES[2]}, "金额") + cs = got.cell + raw = world.raws["lst"][0] + assert (cs.sheet, cs.cell, cs.column_role) == ("月报", "F8", "measure") and xl(raw, "月报", "F8") == value + assert froms(cs) == [("col_header", None, "F5", "金额", None)] and xl(raw, "月报", "F5") == "金额" + assert cs.year is None and cs.canonical is None and cs.merged_fill is False + # 列表的文本列同样有溯源段:被引用的就是它自己那一格 + got, value = located(world, "lst", "月报", {"地区": lab.ZONES[2]}, "地区") + assert (got.cell.cell, got.cell.column_role) == ("C8", "text") and xl(raw, "月报", "C8") == value + + +def test_list_total_row(world): + got, value = located(world, "lst", "月报_表内合计", {"合计项": "合计"}, "金额") + cs = got.cell + raw = world.raws["lst"][0] + assert (cs.cell, cs.kind) == ("F9", "reported_total") and xl(raw, "月报", "F9") == value + assert froms(cs) == [("col_header", None, "F5", "金额", None), ("total_label", "合计项", "C9", "合计", None)] + # 合计项列本身:取合计标签那一格 + got, value = located(world, "lst", "月报_表内合计", {"合计项": "合计"}, "合计项") + assert got.cell.cell == "C9" and xl(raw, "月报", "C9") == value + assert froms(got.cell) == [] + + +def test_union_cell_lands_in_the_second_period(world): + snap = world.snaps["acc2"] + got, value = located(world, "acc2", "时段客流", {"日期": "2026-09-12", "时段": "8-9"}, "客流") + cs = got.cell + sm = world.store[snap.schema["snapshot_manifest"]] + start = sm["parts"][1]["rows"]["时段客流"]["union"][0] + assert (cs.part_seq, got.part.seq) == (2, 2) and cs.part_rowid == cs.rowid - start + 1 + assert cs.cell == "N11" and xl(world.raws["acc2"][1], SHEET, "N11") == value + assert froms(cs) == [("axis_header", "日期", "N4", None, None), + ("row_label", "时段", "B11", "8-9", None), + ("section_title", "时段类别", "B9", None, DAY_TITLE)] + assert cs.canonical == T.CanonicalView(raw="8-9", canonical="8-9") + # 8 月那一期的行在第 1 期 + got, value = located(world, "acc2", "时段客流", {"日期": "2026-08-07", "时段": "19-20"}, "客流") + assert (got.cell.part_seq, got.cell.cell) == (1, "I23") and got.part_rowid == got.cell.rowid + assert xl(world.raws["acc2"][0], SHEET, "I23") == value + + +def test_key_columns_use_the_axis_row_the_row_label_or_the_section_title(world): + raw = world.raws["acc2"][1] + where = {"日期": "2026-09-12", "时段": "8-9"} + got, value = located(world, "acc2", "时段客流", where, "时段") # dim:行标签格 + assert (got.cell.cell, got.cell.column_role) == ("B11", "dim") and value == "8-9" + assert xl(raw, SHEET, "B11") == "8-9" + assert froms(got.cell) == [("axis_header", "日期", "N4", None, None), + ("section_title", "时段类别", "B9", None, DAY_TITLE)] + assert got.cell.canonical == T.CanonicalView(raw="8-9", canonical="8-9") + got, _ = located(world, "acc2", "时段客流", where, "日期") # axis:轴行 + assert got.cell.cell == "N4" and [f.role for f in got.cell.from_] == ["row_label", "section_title"] + got, _ = located(world, "acc2", "时段客流", where, "时段类别") # const:分段标题 + assert got.cell.cell == "B9" and [f.role for f in got.cell.from_] == ["axis_header", "row_label"] + got, _ = located(world, "acc2", "时段客流", where, "起始小时") # derive:由行标签解析 + assert got.cell.cell == "B11" and got.cell.canonical is None + got, _ = located(world, "rep1", "日客流", {"日期": "2026-08-05"}, "日期") # 宽表的日期:只有表头格 + assert got.cell.cell == "G4" and got.cell.from_ == [] + + +def test_measure_column_without_lineage_does_not_fall_back(world): + """measure / value / text 没有溯源段一律 no_lineage,不回退到同一行别的列(回退取到的是另一列的格子)。""" + def drop(doc): + del doc["receipt"]["lineage"]["日客流"]["全日客流"] + chain, store = with_manifest(world, "rep1", drop) + rowid, _ = db_row(world.snaps["rep1"], "日客流", {"日期": "2026-08-05"}, "全日客流") + got = P.locate(chain, table="日客流", column="全日客流", rowid=rowid) + assert got == T.LocateProblem("no_lineage", "no_lineage_for_row") + # 键列照常回退到第一个有溯源段的列(分区甲) + got = P.locate(chain, table="日客流", column="日期", rowid=rowid) + assert isinstance(got, T.Located) and got.cell.cell == "G4" + + +def test_const_column_needs_a_section_title(world): + def by_labels(doc): + for b in recipe_blocks(doc): + for seg in b.get("segments", []): + if seg["id"] == "日间": + seg["locate"] = {"by": "labels"} + chain, _ = with_manifest(world, "rep1", by_labels) + rowid, _ = db_row(world.snaps["rep1"], "时段客流", {"日期": "2026-08-20", "时段": "13-14"}, "客流") + for column in ("时段类别", "客流"): + assert P.locate(chain, table="时段客流", column=column, rowid=rowid) == \ + T.LocateProblem("no_lineage", "section_title") + + def no_titles(doc): + doc["receipt"]["regions"] = [m for m in doc["receipt"]["regions"] if m["role"] != "section_title"] + chain, _ = with_manifest(world, "rep1", no_titles) + assert P.locate(chain, table="时段客流", column="时段类别", rowid=rowid).code == "no_lineage" + + +def test_section_title_is_the_nearest_one_above_the_segment(world): + """夜间段的分段标题是 B21,不是更上面的 B9。""" + got, _ = located(world, "rep1", "时段客流", {"日期": "2026-08-07", "时段": "19-20"}, "时段类别") + assert got.cell.cell == "B21" + + +def test_phase2_regions_without_block_keys(world): + """期 2 生成的清单 regions 没有 block 键:同一工作表上配方恰好只有一块时认它;两块时不猜。""" + def strip(doc): + for m in doc["receipt"]["regions"]: + m.pop("block", None) + chain, _ = with_manifest(world, "rep1", strip) + rowid, _ = db_row(world.snaps["rep1"], "时段客流", {"日期": "2026-08-20", "时段": "13-14"}, "客流") + got = P.locate(chain, table="时段客流", column="客流", rowid=rowid) + assert isinstance(got, T.Located) and got.cell.cell == "V16" + assert [f.cell for f in got.cell.from_] == ["V4", "B16", "B9"] + + def two_blocks(doc): + strip(doc) + sheet = doc["recipe"]["canonical"]["sheets"][0] + extra = {"id": "另一块", "layout": "list", "table": "别的表", + "columns": [{"header": "甲", "name": "甲", "type": "TEXT"}]} + sheet["blocks"].append(extra) + doc["recipe"]["canonical"]["tables"].append({"name": "别的表"}) + chain, _ = with_manifest(world, "rep1", two_blocks) + assert P.locate(chain, table="时段客流", column="客流", rowid=rowid) == \ + T.LocateProblem("no_lineage", "anchor_block") + + +def test_anchor_outside_every_value_region_or_two_axes(world): + def no_values(doc): + doc["receipt"]["regions"] = [m for m in doc["receipt"]["regions"] if m["role"] != "value"] + chain, _ = with_manifest(world, "rep1", no_values) + rowid, _ = db_row(world.snaps["rep1"], "日客流", {"日期": "2026-08-05"}, "全日客流") + assert P.locate(chain, table="日客流", column="全日客流", rowid=rowid).detail == "anchor_block" + + def two_axes(doc): + doc["receipt"]["axes"].append(dict(doc["receipt"]["axes"][0])) + chain, _ = with_manifest(world, "rep1", two_axes) + assert P.locate(chain, table="日客流", column="全日客流", rowid=rowid).detail == "axis" + + +@pytest.mark.parametrize("form, expect", [("text", (False, True)), ("mixed", (True, True)), ("date", None)]) +def test_year_rule_by_axis_form(world, form, expect): + def set_form(doc): + doc["receipt"]["axes"][0]["form"] = form + chain, _ = with_manifest(world, "rep1", set_form) + rowid, _ = db_row(world.snaps["rep1"], "日客流", {"日期": "2026-08-05"}, "全日客流") + year = P.locate(chain, table="日客流", column="全日客流", rowid=rowid).cell.year + if expect is None: + assert year is None # 日期格自带年份 + else: + assert year.mixed is expect[0] and year.source == "period" and year.cells == [f"{SHEET}!B2"] + + +def test_year_from_a_human_period_and_without_year_from(world): + def human(doc): + doc["period"] = {"start": "2026-08-01", "end": "2026-08-31", "source": "human", "cells": [], + "signed_by": "录入员甲"} + chain, _ = with_manifest(world, "rep1", human) + rowid, _ = db_row(world.snaps["rep1"], "日客流", {"日期": "2026-08-05"}, "全日客流") + year = P.locate(chain, table="日客流", column="全日客流", rowid=rowid).cell.year + assert year == T.YearSource(source="human", cells=[], signed_by="录入员甲", mixed=False) + + def no_year_from(doc): + recipe_blocks(doc)[0]["axis"] = {"year_from": None} + chain, _ = with_manifest(world, "rep1", no_year_from) + assert P.locate(chain, table="日客流", column="全日客流", rowid=rowid).cell.year is None + + +def test_merged_fill_follows_the_list_block_recipe(world): + def fill(doc): + recipe_blocks(doc)[0]["merged_data"] = "fill" + chain, _ = with_manifest(world, "lst", fill) + rowid, _ = db_row(world.snaps["lst"], "月报", {"地区": lab.ZONES[0]}, "地区") + assert P.locate(chain, table="月报", column="地区", rowid=rowid).cell.merged_fill is True + + +def test_multi_row_header_lists_only_cells_that_hold_text(world): + """多行表头(lab.c04):回执的列表头区域是 A3:E3、C4:F4——段把中间的空格一并覆盖,D3 是合并区 C3:D3 里的空格, + F3 在合并区 E3:F3 里、不在任何段内。from 只列段的端点(必定有字),段中间的格不列:逐行逐列扫一遍,from 里的 + 每一格在原表里都写着字;被引用的格保存的值等于库里的值,合并区里的空格只在 merged_fill 时出现。""" + snap = world.snaps["c04"] + raw = world.raws["c04"][0] + chain = chain_of(world, "c04") + receipt = chain.parts[0].manifest["receipt"] + assert [(m["role"], m["ref"]) for m in receipt["regions"] if m["role"] == "col_header"] == [ + ("col_header", "A3:E3"), ("col_header", "C4:F4")] + table = "分地区销售" + columns = [c["name"] for c in receipt["tables"][0]["columns"]] + with closing(sqlite3.connect(f"file:{snap.db_path}?mode=ro&immutable=1", uri=True)) as conn: + rows = conn.execute(f'SELECT rowid, * FROM "{table}" ORDER BY rowid').fetchall() + assert len(rows) == 6 + seen: dict[str, set[tuple[str, ...]]] = {c: set() for c in columns} + book = load_workbook(io.BytesIO(raw), data_only=True) + for rowid, *values in rows: + for column, value in zip(columns, values): + got = P.locate(chain, table=table, column=column, rowid=rowid) + assert isinstance(got, T.Located), (column, rowid, got) + cs = got.cell + assert cs.merged_fill is True and cs.year is None + original = book[cs.sheet][cs.cell].value + assert original == value or (original is None and column == "地区"), (column, cs.cell, original, value) + for f in cs.from_: + assert f.role == "col_header" and f.text is None, f + assert book[f.sheet][f.cell].value not in (None, ""), (column, f.cell) + seen[column].add(tuple(f.cell for f in cs.from_)) + # 每一列各行给的表头格相同;纵向合并的「地区」取上层 A3,「产品」B3、上半年的金额 D4 落在段中间,不列 + assert {c: sorted(v) for c, v in seen.items()} == { + "地区": [("A3",)], "产品": [()], "c_2026年上半年_销量": [("C4",)], "c_2026年上半年_金额": [()], + "c_2026年下半年_销量": [("E3",)], "c_2026年下半年_金额": [("F4",)]} + + +def test_union_rowid_out_of_every_period_is_a_chain_mismatch(world): + chain = chain_of(world, "acc2") + last = chain.parts[-1].union_rows["日客流"][1] + for rowid in (last + 1, 10 ** 6): + assert P.locate(chain, table="日客流", column="全日客流", rowid=rowid) == \ + T.LocateProblem("chain_mismatch", "rowid_out_of_parts") + assert P.locate(chain, table="不存在的表", column="全日客流", rowid=1).code == "chain_mismatch" + # 偏移:第 2 期的第一行 = 并集的第 32 行 + got = P.locate(chain, table="日客流", column="全日客流", rowid=32) + assert (got.cell.part_seq, got.part_rowid, got.cell.cell) == (2, 1, "C5") + + +def test_rows_unknown_to_the_receipt(world): + chain = chain_of(world, "rep1") + assert P.locate(chain, table="日客流", column="全日客流", rowid=999).detail == "no_lineage_for_row" + assert P.locate(chain, table="日客流", column="不存在", rowid=1).detail == "column_not_in_receipt" + assert P.locate(chain, table="日客流", column="全日客流", rowid=0).detail == "rowid" + + +# ========================================================================== +# version_view(2.8.1 的 version) +# ========================================================================== + + +def test_version_view_lists_every_period_from_the_manifests(world): + snap = world.snaps["acc2"] + view = P.version_view(chain_of(world, "acc2"), tables=["时段客流", "日客流", "时段客流", "不存在的表", + "时段客流_表内合计"]) + assert (view.source, view.snapshot_id, view.mode, view.union, view.manifest_view) == ( + snap.name, snap.id, "accumulate", True, True) + assert view.tables == [T.TableRef(name="时段客流", kind="data"), T.TableRef(name="日客流", kind="data"), + T.TableRef(name="时段客流_表内合计", kind="reported_total")] + assert [p.seq for p in view.parts] == [1, 2] and [p.manifest for p in view.parts] == snap.schema["import_manifests"] + for part, mid, raw in zip(view.parts, snap.schema["import_manifests"], world.raws["acc2"]): + m = world.store[mid] + assert (part.import_id, part.file_name, part.raw_sha256) == (m["import_id"], m["file"]["name"], + m["file"]["raw_sha256"]) + assert part.raw_sha256 == hashlib.sha256(raw).hexdigest() + assert (part.recipe_sha256, part.committed_at, part.signed_by) == (m["recipe"]["sha256"], m["created_at"], None) + assert part.period == T.PeriodView(start=m["period"]["start"], end=m["period"]["end"], source="cells", + cells=[f"{SHEET}!B2"], signed_by=None) + # 当前状态留给接口一侧补 + assert (part.recipe_seq, part.raw_state, part.purged, part.revoked, part.has_row) == (None,) * 4 + (False,) + assert [p.region for p in view.parts] == [f"{SHEET}!B4:AG30", f"{SHEET}!B4:AF30"] + assert [p.excluded_rows for p in view.parts] == [0, 0] + # 名字只对 ASCII 不区分大小写(name_key),中文逐字比 + assert P.version_view(chain_of(world, "lst"), tables=["月报"]).tables == [T.TableRef(name="月报", kind="data")] + + +def test_version_view_acceptances_and_old_receipts(world): + view = P.version_view(chain_of(world, "rep2"), tables=["日客流"]) + m = world.store[world.snaps["rep2"].schema["import_manifests"][0]] + acc = m["acceptances"]["overrides"][0] + assert view.parts[0].acceptances == [T.AcceptanceView( + check_id="R1", title="「日客流」:全日客流 = 分区甲 + 分区乙", kind="override", reason=REASON, signed_by=None, + at=acc["at"])] + assert view.mode == "replace" and view.parts[0].seq == 2 + + def old(doc): + # 期 3 之前的回执没有 rows_excluded(未记录,不是 0);期 2 的 regions 没有 block 键(按块内角色认) + del doc["receipt"]["rows_excluded"] + for mk in doc["receipt"]["regions"]: + mk.pop("block", None) + doc["acceptances"]["waivers"] = [{"check_id": "K1", "reason": "合成理由:公式未缓存", "signed_by": "录入员甲"}] + doc["period"] = {"start": "2026-09-01", "end": "2026-09-30", "source": "human", "cells": [], + "signed_by": "录入员甲"} + chain, _ = with_manifest(world, "rep2", old) + part = P.version_view(chain, tables=[]).parts[0] + assert part.excluded_rows is None and part.region == f"{SHEET}!B4:AF30" + assert [(a.check_id, a.kind, a.title, a.signed_by, a.at) for a in part.acceptances] == [ + ("R1", "override", "「日客流」:全日客流 = 分区甲 + 分区乙", None, acc["at"]), + ("K1", "waiver", "合计按明细重算(分段「夜间合计」)", "录入员甲", None)] + assert part.period == T.PeriodView(start="2026-09-01", end="2026-09-30", source="human", cells=[], + signed_by="录入员甲") + + +def test_region_and_excluded_rows_helpers(): + receipt = {"regions": [ + {"sheet": "甲", "role": "value", "ref": "C5:D7", "block": "b1"}, + {"sheet": "甲", "role": "outside_text", "ref": "B40", "block": None}, + {"sheet": "甲", "role": "row_label", "ref": "B5:B9", "block": "b1"}, + {"sheet": "乙", "role": "col_header", "ref": "A1:E1", "block": "b2"}, + ], "rows_excluded": [{"rows": [[3, 4], [9, 9]]}, {"rows": [[20, 22]]}]} + assert P.region_of(receipt) == "甲!B5:D9、乙!A1:E1" + assert P.excluded_rows_of(receipt) == 6 + assert P.region_of({"regions": [{"sheet": "甲", "role": "context", "ref": "B2", "block": None}]}) is None + + +# ========================================================================== +# related_checks(2.7) +# ========================================================================== + + +def plans(world: World, key: str, table: str, where: dict[str, Any], column: str, + store: dict[str, Any] | None = None) -> list[T.RelatedCheckPlan]: + got, _ = located(world, key, table, where, column, store) + return P.related_checks(chain_of(world, key, store), got, table=table, column=column) + + +def test_relation_checks_for_the_cited_column(world): + got = plans(world, "rep1", "日客流", {"日期": "2026-08-05"}, "全日客流") + assert [(p.check.id, p.check.kind, p.check.part_status) for p in got] == [ + ("R1", "relation_sum_eq", "passed"), ("R2", "relation_not_comparable", "info"), + ("C1", "context_agree", "passed"), ("C2", "filename_period", "passed")] + assert got[0].rule == T.SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], + types={"全日客流": "INTEGER", "分区甲": "INTEGER", "分区乙": "INTEGER"}) + assert all(p.rule is None for p in got[1:]) + assert all(p.check.row_status is None and p.check.cell_status is None and p.check.acceptance is None for p in got) + # 被引用的是 parts 之一同样列 R1;日期不是 total 也不是 parts + assert [p.check.id for p in plans(world, "rep1", "日客流", {"日期": "2026-08-05"}, "分区甲")][0] == "R1" + assert [p.check.id for p in plans(world, "rep1", "日客流", {"日期": "2026-08-05"}, "日期")] == ["R2", "C1", "C2"] + # 长表只有口径不同 R2(a 是时段客流) + assert [p.check.id for p in plans(world, "rep1", "时段客流", {"日期": "2026-08-20", "时段": "13-14"}, "客流")] \ + == ["R2", "C1", "C2"] + + +def test_accepted_mismatch_carries_its_reason(world): + got = plans(world, "rep2", "日客流", {"日期": "2026-09-10"}, "全日客流") + r1 = got[0].check + assert (r1.id, r1.part_status, r1.row_status) == ("R1", "mismatch", None) + assert r1.acceptance is not None and (r1.acceptance.reason, r1.acceptance.kind) == (REASON, "override") + assert got[0].rule is not None and got[1].check.acceptance is None + + +def test_total_cell_lists_k_and_g_of_its_segment(world): + got = plans(world, "rep1", "时段客流_表内合计", {"日期": "2026-08-10", "合计项": "18-22时合计"}, "客流") + assert [(p.check.id, p.check.cell_status, p.rule) for p in got] == [ + ("K1", "ok", None), ("G1", "ok", None), ("C1", None, None), ("C2", None, None)] + # 合计项列不是合计格:不列 K、G + got = plans(world, "rep1", "时段客流_表内合计", {"日期": "2026-08-10", "合计项": "18-22时合计"}, "合计项") + assert [p.check.id for p in got] == ["C1", "C2"] + # 列表的合计行是 T;列表没有统计期,不列 C + got = plans(world, "lst", "月报_表内合计", {"合计项": "合计"}, "金额") + assert [(p.check.id, p.check.kind, p.check.cell_status) for p in got] == [("T1", "column_sum", "ok")] + assert plans(world, "lst", "月报", {"地区": lab.ZONES[0]}, "金额") == [] + + +def test_g_on_a_literal_total_is_not_formula_and_k_details(world): + where = {"日期": "2026-08-10", "合计项": "18-22时合计"} + + def literal(doc): + for d in doc["receipt"]["derived"]: + if d["cell"] == f"{SHEET}!L28": + d["is_formula"], d["formula"] = False, None + k1 = next(c for c in doc["checks"] if c["id"] == "K1") + k1.update(status="unverifiable", unverifiable=2, cells=[f"{SHEET}!L28", f"{SHEET}!M28"], + details=["2 格无法核对:文件未经计算保存"]) + store, new = edited(world, "rep1", literal) + snap = world.snaps["rep1"] + schema = {**copy.deepcopy(snap.schema), "import_manifests": [new]} + chain = P.resolve_chain(query_of(snap), schema, loader(store)) + rowid, _ = db_row(snap, "时段客流_表内合计", where, "客流") + loc = P.locate(chain, table="时段客流_表内合计", column="客流", rowid=rowid) + got = {p.check.id: p.check for p in P.related_checks(chain, loc, table="时段客流_表内合计", column="客流")} + # 段里别的格还是公式,plan_checks 照样编 G1;这一格写死,不能说「这一格一致」 + assert got["G1"].cell_status == "not_formula" and got["G1"].part_status == "passed" + assert (got["K1"].cell_status, got["K1"].part_status) == ("unverifiable", "unverifiable") + assert got["K1"].detail == "2 格无法核对:文件未经计算保存" and got["G1"].detail is None + + +@pytest.mark.parametrize("check, expect", [ + ({"status": "passed"}, "ok"), + ({"status": "unverifiable", "unverifiable": 2, "cells": ["甲!L28", "甲!M28"]}, "unverifiable"), + ({"status": "unverifiable", "unverifiable": 2, "cells": ["甲!M28", "甲!N28"]}, "ok"), + ({"status": "unverifiable", "unverifiable": 25, "cells": [f"甲!X{i}" for i in range(20)]}, "unknown"), + ({"status": "mismatch", "failed": 1, "unverifiable": 1, "cells": ["甲!L28", "甲!M28"]}, None), + ({"status": "mismatch", "failed": 1, "unverifiable": 1, "cells": ["甲!M28", "甲!L28"]}, "unverifiable"), + ({"status": "mismatch", "failed": 1, "unverifiable": 0, "cells": ["甲!M28"]}, "ok"), + ({"status": "info"}, None), +]) +def test_cell_status_branches(check, expect): + assert P.cell_status(check, "甲!L28", is_formula=True, formula_check=False) == expect + assert P.cell_status(check, "甲!L28", is_formula=True, formula_check=True) == expect + assert P.cell_status(check, "甲!L28", is_formula=False, formula_check=True) == "not_formula" + assert P.cell_status(check, "甲!L28", is_formula=False, formula_check=False) == expect + + +def test_context_checks_only_when_the_year_comes_from_the_period(world): + def human(doc): + doc["period"] = {"start": "2026-08-01", "end": "2026-08-31", "source": "human", "cells": [], + "signed_by": "录入员甲"} + doc["checks"] = [c for c in doc["checks"] if c["id"] != "C1"] # 人工录入没有 C1 + chain, _ = with_manifest(world, "rep1", human) + rowid, _ = db_row(world.snaps["rep1"], "日客流", {"日期": "2026-08-05"}, "全日客流") + loc = P.locate(chain, table="日客流", column="全日客流", rowid=rowid) + assert [p.check.id for p in P.related_checks(chain, loc, table="日客流", column="全日客流")] == ["R1", "R2", "C2"] + + def date_cells(doc): + doc["receipt"]["axes"][0]["form"] = "date" + chain, _ = with_manifest(world, "rep1", date_cells) + loc = P.locate(chain, table="日客流", column="全日客流", rowid=rowid) + assert [p.check.id for p in P.related_checks(chain, loc, table="日客流", column="全日客流")] == ["R1", "R2"] + + +# ========================================================================== +# chain_artifacts(2.8.3) +# ========================================================================== + + +def test_chain_artifacts_lists_the_chain_and_never_raises(world): + acc, rep = world.snaps["acc2"], world.snaps["rep1"] + sm_id = acc.schema["snapshot_manifest"] + assert P.chain_artifacts(acc.schema, loader(world.store)) == {sm_id, *acc.schema["import_manifests"]} + assert P.chain_artifacts(rep.schema, loader(world.store)) == set(rep.schema["import_manifests"]) + # 只列在快照清单里的各期清单:快照清单取得回才算 + only_sm = {"snapshot_manifest": sm_id, "import_manifests": []} + assert P.chain_artifacts(only_sm, loader(world.store)) == {sm_id, *acc.schema["import_manifests"]} + assert P.chain_artifacts(only_sm, loader(world.store, broken=frozenset({sm_id}))) == set() + assert P.chain_artifacts(only_sm, loader(world.store, gone=frozenset({sm_id}))) == set() + + def boom(aid): + raise RuntimeError("取回出错") + assert P.chain_artifacts(only_sm, boom) == set() + assert P.chain_artifacts(None, boom) == set() # type: ignore[arg-type] + assert P.chain_artifacts({"import_manifests": "不是列表"}, boom) == set() + assert P.chain_artifacts({}, loader(world.store)) == set() + + class Odd(dict): + def get(self, key, default=None): + raise KeyError(key) + assert P.chain_artifacts(Odd(), loader(world.store)) == set() + + +# ========================================================================== +# judge_lines(3.2) +# ========================================================================== + +QUAL_ACCEPT = "已接受(用户填写的理由,未经系统核对):" +QUAL_OUTSIDE = ("区域外文字(原表区域外的文字原文,未经系统核对;其中的数字不是查询结果,不能用来支持或否定报告中的" + "数字):") +CUT = "…另有部分来历信息未列出,见证据面板" + + +def lines_of(world: World, key: str, columns: list[str], tables: list[str], *, hidden: set[str] = frozenset(), + store: dict[str, Any] | None = None, schema: dict[str, Any] | None = None, + query: dict[str, Any] | None = None) -> list[str]: + snap = world.snaps[key] + return P.judge_lines(query or query_of(snap, columns), schema or snap.schema, loader(store or world.store), + tables=tables, hidden=set(hidden)) + + +def part_line(world: World, key: str, k: int) -> str: + """第 k 份清单(按 import_manifests 的顺序)应有的分期描述,按 3.2 的写法逐字拼。""" + m = world.store[world.snaps[key].schema["import_manifests"][k]] + p = m["period"] + return (f"第 {m['seq']} 次导入:统计期 {p['start']} 至 {p['end']}(取自 {SHEET}!B2)," + f"文件「{m['file']['name']}」(sha256 {m['file']['raw_sha256'][:12]}),区域 {P.region_of(m['receipt'])}") + + +def test_judge_lines_for_a_union(world): + snap = world.snaps["acc2"] + tables = snap.schema["tables"] + got = lines_of(world, "acc2", ["日期", "全日客流"], ["日客流"]) + assert got == [ + "来源:上传的表格,按期累积,共 2 期(2026-08-01 至 2026-09-30)", + " " + part_line(world, "acc2", 0), + " " + part_line(world, "acc2", 1), + f"口径:表 日客流 的说明:{tables['日客流']['comment']}", + "口径:列 日期 的说明:格式 YYYY-MM-DD,年份按统计期补全", + "口径:列 全日客流 的说明:单位:人次", + QUAL_OUTSIDE, + " 第 2 次导入(统计期 2026-09-01 至 2026-09-30)客流汇总!B3:「客流汇总表」", + " 第 1 次导入(统计期 2026-08-01 至 2026-08-31)客流汇总!B3:「客流汇总表」", + ] + assert part_line(world, "acc2", 0).endswith("区域 客流汇总!B4:AG30") + # 同样的工件调两次逐字相同(摘录进断点指纹) + assert lines_of(world, "acc2", ["日期", "全日客流"], ["日客流"]) == got + + +def test_judge_lines_single_period_with_an_acceptance(world): + got = lines_of(world, "rep2", ["日期", "全日客流"], ["日客流"]) + assert got[0] == "来源:上传的表格,每期替换," + part_line(world, "rep2", 0) + i = got.index(QUAL_ACCEPT) + assert got[i + 1] == (" 第 2 次导入(统计期 2026-09-01 至 2026-09-30)的核对「「日客流」:全日客流 = 分区甲 + 分区乙」" + f"不成立,理由:{REASON}(未署名)") + assert got.index(QUAL_ACCEPT) < got.index(QUAL_OUTSIDE) + + +def test_judge_lines_outside_text_is_sent_in_full_with_its_period(world): + got = lines_of(world, "d28", ["日期", "全日客流"], ["日客流"]) + i = got.index(QUAL_OUTSIDE) + # 含数字的排在前面,全文照录,每行写所属的导入和统计期 + assert got[i + 1:] == [ + " 第 1 次导入(统计期 2026-09-01 至 2026-09-30)客流汇总!B32:「注:9月15日闸机故障,当日客流为估算值」", + " 第 1 次导入(统计期 2026-09-01 至 2026-09-30)客流汇总!B3:「客流汇总表」", + ] + m = world.store[world.snaps["d28"].schema["import_manifests"][0]] + assert {o["cell"]: o["hidden"] for o in m["receipt"]["outside_text"]} == { + f"{SHEET}!B3": False, f"{SHEET}!B32": False} + + +@pytest.mark.parametrize("hidden_value", [True, None, "missing"]) +def test_outside_text_not_known_to_be_visible_is_only_counted(world, hidden_value): + """hidden 为 True、为 None、或者老清单没有这个键:不送全文,只计入「另有 n 处」(改成 not hidden 让 None 也送, + 这一例要失败)。""" + def mark(doc): + for o in doc["receipt"]["outside_text"]: + if o["cell"].endswith("!B32"): + if hidden_value == "missing": + del o["hidden"] + else: + o["hidden"] = hidden_value + store, new = edited(world, "d28", mark) + schema = {**copy.deepcopy(world.snaps["d28"].schema), "import_manifests": [new]} + got = lines_of(world, "d28", ["日期", "全日客流"], ["日客流"], store=store, schema=schema) + assert not any("闸机故障" in x for x in got) + i = got.index(QUAL_OUTSIDE) + assert got[i + 1:] == [" 第 1 次导入(统计期 2026-09-01 至 2026-09-30)客流汇总!B3:「客流汇总表」", + " 另有 1 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏),见证据面板"] + + def all_hidden(doc): + for o in doc["receipt"]["outside_text"]: + o.pop("hidden", None) + store, new = edited(world, "d28", all_hidden) + schema = {**copy.deepcopy(world.snaps["d28"].schema), "import_manifests": [new]} + got = lines_of(world, "d28", ["日期", "全日客流"], ["日客流"], store=store, schema=schema) + assert QUAL_OUTSIDE not in got + assert got[-1] == "另有 2 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏),见证据面板" + + +def test_masks_drop_reasons_outside_text_and_masked_column_notes(world): + got = lines_of(world, "rep2", ["日期", "全日客流"], ["日客流"], hidden={"全日客流"}) + text = "\n".join(got) + assert REASON not in text and "口径:列 全日客流" not in text and "口径:列 日期" in text + assert (" 第 2 次导入(统计期 2026-09-01 至 2026-09-30)的核对「「日客流」:全日客流 = 分区甲 + 分区乙」不成立" + "(数据源设置了遮罩,理由未列出)") in got + assert QUAL_OUTSIDE not in got and got[-1] == "区域外另有 1 处文字(数据源设置了遮罩,未列出),见证据面板" + got = lines_of(world, "d28", ["日期", "全日客流"], ["日客流"], hidden={"分区甲"}) + assert not any("闸机故障" in x for x in got) + assert got[-1] == "区域外另有 2 处文字(数据源设置了遮罩,未列出),见证据面板" + # 遮罩列按小写比(同 judge._hidden 的口径) + upper = lines_of(world, "lst", ["地区", "金额"], ["月报"], hidden={"金额"}) + assert QUAL_OUTSIDE not in upper + + +def test_judge_lines_scope(world): + snap = world.snaps["rep1"] + load = loader(world.store) + # 手工源(没有 data_version)、没有表结构快照:一行都不加 + assert P.judge_lines({**query_of(snap), "data_version": None}, snap.schema, load, tables=["日客流"], + hidden=set()) == [] + assert P.judge_lines(query_of(snap), None, load, tables=["日客流"], hidden=set()) == [] + # 简单导入(不是按配方导入):只有口径 + simple = {k: v for k, v in snap.schema.items() if k not in ("import_mode", "import_manifests")} + got = P.judge_lines(query_of(snap, ["日期"]), simple, load, tables=["日客流"], hidden=set()) + assert got and all(x.startswith("口径:") for x in got) + # 未规整的表:说明就是 UNSHAPED_NOTE + from app.data.tabular import UNSHAPED_NOTE + + raw = copy.deepcopy(simple) + raw["tables"]["日客流"]["comment"] = UNSHAPED_NOTE + assert P.judge_lines(query_of(snap), raw, load, tables=["日客流"], hidden=set()) == [ + f"口径:表 日客流 的说明:{UNSHAPED_NOTE}"] + # 链解析失败:写一行说明,口径照写,不中断 + def boom(aid): + raise ValueError("哈希不符") + got = P.judge_lines(query_of(snap, ["日期"]), snap.schema, boom, tables=["日客流"], hidden=set()) + assert got[0] == "来源:导入清单无法读取或与登记不一致,未列出来历" + assert got[1].startswith("口径:表 日客流") and not any(x.startswith(("已接受", "区域外")) for x in got) + # SQL 里没有的表不写说明 + assert not any(x.startswith("口径:表 时段客流") for x in got) + + +# ---- 上限与截断:在真清单上拼出多期、长文字的变体(loader 是测试自己的,id 随便取) + + +def synthetic(world: World, n: int, *, accepts: int = 0, outside: int = 0, outside_len: int = 20, + comment_len: int | None = None, columns: int = 0, column_len: int = 10 + ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """以 acc2 为模板拼一个 n 期的并集:(query, schema, store)。每期 accepts 条接受、outside 格可见的区域外文字 + (单数格含数字);comment_len 给了就把表的说明换成这么长(0 = 没有说明);columns 给了就换成这么多列、每列都有 + 说明。""" + acc = world.snaps["acc2"] + store = copy.deepcopy(world.store) + sm = copy.deepcopy(store[acc.schema["snapshot_manifest"]]) + template = store[acc.schema["import_manifests"][0]] + mids, parts = [], [] + for k in range(1, n + 1): + m = copy.deepcopy(template) + m.update(import_id=f"导入{k}", seq=k, period={"start": f"2026-{k:02d}-01", "end": f"2026-{k:02d}-28", + "source": "cells", "cells": [f"{SHEET}!B2"]}) + m["file"] = {**m["file"], "name": f"月报_{k:02d}.xlsx"} + m["acceptances"] = {"overrides": [{"check_id": "R1", "reason": f"第{k}期理由{j}", "signed_by": None} + for j in range(accepts)], "waivers": []} + m["receipt"]["outside_text"] = [ + {"sheet": SHEET, "cell": f"{SHEET}!B{40 + j}", "text": f"第{k}期注{j}" + "字" * outside_len, + "kind": "text_digits" if j % 2 else "text", "period_source": False, "hidden": False} + for j in range(outside)] + mid = f"清单{k}" + store[mid] = m + mids.append(mid) + parts.append({**sm["parts"][0], "import_id": f"导入{k}", "seq": k, "manifest_artifact": mid}) + sm.update(snapshot_id="合成并集", parts=parts) + store["合成快照清单"] = sm + schema = {**copy.deepcopy(acc.schema), "import_manifests": mids, "snapshot_manifest": "合成快照清单"} + names = [f"列{j}" for j in range(columns)] + meta = schema["tables"]["日客流"] + if comment_len is not None: + meta["comment"] = "说" * comment_len + if columns: + meta["columns"] = [{"name": c, "type": "INTEGER", "comment": f"{c}的说明" + "明" * column_len} for c in names] + query = {"source": acc.name, "data_version": "合成并集", "columns": names or ["日期", "全日客流"], "rows": []} + return query, schema, store + + +def test_judge_lines_caps(world): + def run(**kw): + query, schema, store = synthetic(world, comment_len=0, **kw) + got = P.judge_lines(query, schema, loader(store), tables=["日客流"], hidden=set()) + assert CUT not in got, len("\n".join(got)) + return got, _sections(got) + # 分期行最多 PROV_PARTS 期,取最近的;总述行写全部期数和起止 + got, sec = run(n=5) + assert got[0] == "来源:上传的表格,按期累积,共 5 期(2026-01-01 至 2026-05-28)" + assert [x.split(":")[0] for x in sec["parts"]] == [" 第 3 次导入", " 第 4 次导入", " 第 5 次导入"] + assert got[len(sec["parts"]) + 1] == " 另有 2 期,见证据面板" + # 列的说明最多 PROV_COLUMN_NOTES 行,按结果列的顺序 + got, sec = run(n=1, columns=8) + assert [x.split(" 的说明")[0] for x in sec["columns"]] == [f"口径:列 列{j}" for j in range(T.PROV_COLUMN_NOTES)] + # 已接受最多 PROV_ACCEPT 条(取最近几期的),按期次排序 + got, sec = run(n=3, accepts=2) + assert [x.split("(")[0] for x in sec["accepts"]] == [" 第 2 次导入"] * 2 + [" 第 3 次导入"] * 2 + assert got[-1] == " 另有 2 条,见证据面板" + # 区域外文字最多 PROV_OUTSIDE 格:先含数字的(B41),再按期次从新到旧 + got, sec = run(n=3, outside=2) + assert [(x.split("(")[0], x.split(")")[1].split(":")[0]) for x in sec["outside"]] == [ + (" 第 3 次导入", f"{SHEET}!B41"), (" 第 2 次导入", f"{SHEET}!B41"), (" 第 1 次导入", f"{SHEET}!B41"), + (" 第 3 次导入", f"{SHEET}!B40")] + assert got[-1] == " 另有 2 处区域外文字未列出(超出条数上限),见证据面板" + + +def test_outside_text_longer_than_the_cap_is_cut(world): + query, schema, store = synthetic(world, 1, outside=1, outside_len=300) + got = P.judge_lines(query, schema, loader(store), tables=["日客流"], hidden=set()) + line = next(x for x in got if f"{SHEET}!B40" in x) + assert line.endswith("…」(已截断)") + body = line.split(":「", 1)[1].removesuffix("…」(已截断)") + assert len(body) == T.PROV_OUTSIDE_CHARS and body.startswith("第1期注0字") + query, schema, store = synthetic(world, 1, outside=1, outside_len=T.PROV_OUTSIDE_CHARS - 5) + got = P.judge_lines(query, schema, loader(store), tables=["日客流"], hidden=set()) + assert not any("已截断" in x for x in got) + + +def test_judge_lines_wording_for_human_periods_signatures_and_waivers(world): + """3.2 写死的几处措辞逐字钉住:统计期人工录入(有署名、没署名)、清单里没有统计期、接受理由的署名一律标 + 「未认证」(0.1)、waivers 写「无法核对」。""" + query, schema, store = synthetic(world, 3, outside=1, comment_len=0) + m1, m2, m3 = (store[f"清单{k}"] for k in (1, 2, 3)) + m1["period"] = {"start": "2026-01-01", "end": "2026-01-28", "source": "human", "cells": [], + "signed_by": "录入员甲"} + m2["period"] = {"start": "2026-02-01", "end": "2026-02-28", "source": "human", "cells": [], "signed_by": None} + del m3["period"] + m3["receipt"].pop("period", None) + m1["acceptances"] = {"overrides": [{"check_id": "R1", "reason": "合成理由甲", "signed_by": "录入员甲"}], + "waivers": []} + waived = next(c for c in m3["checks"] if c["id"] != "R1") + m3["acceptances"] = {"overrides": [], "waivers": [ + {"check_id": waived["id"], "reason": "合成理由乙", "signed_by": "录入员乙"}, + {"check_id": waived["id"], "reason": "合成理由丙", "signed_by": None}]} + got = P.judge_lines(query, schema, loader(store), tables=["日客流"], hidden=set()) + + def tail(m: dict[str, Any]) -> str: + return (f"文件「{m['file']['name']}」(sha256 {m['file']['raw_sha256'][:12]})," + f"区域 {P.region_of(m['receipt'])}") + r1 = "「日客流」:全日客流 = 分区甲 + 分区乙" + assert got == [ + "来源:上传的表格,按期累积,共 3 期(2026-01-01 至 2026-02-28)", + f" 第 1 次导入:统计期 2026-01-01 至 2026-01-28(人工录入,署名「录入员甲」,未认证),{tail(m1)}", + f" 第 2 次导入:统计期 2026-02-01 至 2026-02-28(人工录入,未署名),{tail(m2)}", + f" 第 3 次导入:统计期未记录,{tail(m3)}", + "口径:列 日期 的说明:格式 YYYY-MM-DD,年份按统计期补全", + "口径:列 全日客流 的说明:单位:人次", + QUAL_ACCEPT, + f" 第 1 次导入(统计期 2026-01-01 至 2026-01-28)的核对「{r1}」不成立,理由:合成理由甲(署名「录入员甲」,未认证)", + f" 第 3 次导入(统计期未记录)的核对「{waived['title']}」无法核对,理由:合成理由乙(署名「录入员乙」,未认证)", + f" 第 3 次导入(统计期未记录)的核对「{waived['title']}」无法核对,理由:合成理由丙(未署名)", + QUAL_OUTSIDE, + f" 第 3 次导入(统计期未记录){SHEET}!B40:「第3期注0{'字' * 20}」", + f" 第 2 次导入(统计期 2026-02-01 至 2026-02-28){SHEET}!B40:「第2期注0{'字' * 20}」", + f" 第 1 次导入(统计期 2026-01-01 至 2026-01-28){SHEET}!B40:「第1期注0{'字' * 20}」", + ] + + +def _sections(got: list[str]) -> dict[str, list[str]]: + sec: dict[str, list[str]] = {"parts": [], "columns": [], "accepts": [], "outside": [], "tables": []} + mode = None + for x in got: + if x.startswith("口径:表 "): + sec["tables"].append(x) + elif x.startswith("口径:列 "): + sec["columns"].append(x) + elif x == QUAL_ACCEPT: + mode = "accepts" + elif x == QUAL_OUTSIDE: + mode = "outside" + elif x.startswith(" 第 ") and "次导入:" in x: + sec["parts"].append(x) + elif x.startswith(" 第 ") and mode: + sec[mode].append(x) + return sec + + +#: 表的说明越来越长,五步依次被用到(最短的不删,最长的截到表的说明) +_LENGTHS = tuple(range(0, 2400, 60)) + + +def _truncated(world: World, comment_len: int) -> list[str]: + query, schema, store = synthetic(world, 4, accepts=1, outside=1, columns=3, comment_len=comment_len) + return P.judge_lines(query, schema, loader(store), tables=["日客流"], hidden=set()) + + +def test_judge_lines_truncation_order(world): + """超过 PROV_CHARS 时按固定顺序删:分期行(旧的先删,总述行保留)→ 列的说明(从最后一列起)→ 已接受(旧的先删) + → 区域外文字(旧的先删)→ 表的说明截断加「…」;删过任何一项,末尾写一行说明。""" + order = ["parts", "columns", "accepts", "outside", "tables"] + full = {"parts": 3, "columns": 3, "accepts": 4, "outside": 4} + states = set() + for comment_len in _LENGTHS: + got = _truncated(world, comment_len) + assert len("\n".join(got)) <= T.PROV_CHARS, comment_len + assert got[0] == "来源:上传的表格,按期累积,共 4 期(2026-01-01 至 2026-04-28)" + sec = _sections(got) + lost = {k: len(sec[k]) < v for k, v in full.items()} + lost["tables"] = comment_len > 0 and (not sec["tables"] or sec["tables"][0].endswith("…")) + for i, k in enumerate(order): + if lost[k]: + # 后面的类别动过了,前面的必须已经删光 + assert all(not sec[j] for j in order[:i]), (comment_len, k, sec) + assert (got[-1] == CUT) == any(lost.values()), comment_len + # 各类内部:分期行、已接受、区域外文字留下最近的;列的说明留下前几列 + assert [int(x.split()[1]) for x in sec["parts"]] == [2, 3, 4][3 - len(sec["parts"]):] + assert [x.split(" 的说明")[0] for x in sec["columns"]] == [f"口径:列 列{j}" for j in range(len(sec["columns"]))] + assert [int(x.split()[1]) for x in sec["accepts"]] == [1, 2, 3, 4][4 - len(sec["accepts"]):] + assert sorted(int(x.split()[1]) for x in sec["outside"]) == [1, 2, 3, 4][4 - len(sec["outside"]):] + # 同样的输入调两次逐字相同 + assert _truncated(world, comment_len) == got + states.add(next((k for k in reversed(order) if lost[k]), None)) + assert states == {None, *order}, states diff --git a/backend/tests/test_provenance_acceptance.py b/backend/tests/test_provenance_acceptance.py new file mode 100644 index 00000000..2d338db2 --- /dev/null +++ b/backend/tests/test_provenance_acceptance.py @@ -0,0 +1,1737 @@ +"""期 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) diff --git a/backend/tests/test_provenance_api.py b/backend/tests/test_provenance_api.py new file mode 100644 index 00000000..944c04ce --- /dev/null +++ b/backend/tests/test_provenance_api.py @@ -0,0 +1,1479 @@ +"""证据下钻(期 4)接口一侧:文档标记、查询步骤的提示字段、按需裁判 loader 的链扩展、推断来源接口的路由和错误码、 +规则表 R0–R17(P4-SPEC 2.3)与 R14 之后的异常映射。 + +三种写法: +- 单元(R0–R4、loader、提示字段):在测试里现造查询快照、表结构快照(内容寻址存进临时数据目录),拼一份内存里的 + `_Sealed`,直接调 `segment_provenance`、`_chain`、`_sealed_loader`; +- 真导入(R5–R17):经接口把合成的客流表(tests/fixtures/xlsx/flow.py,假名、随机数)按配方导入成上传源,经工具接口 + 真跑查询得到查询快照,再拼内存里的 `_Sealed` 调 `segment_provenance`。要构造的反例(重复主键、篡改的清单、 + 改过的登记哈希……)在这份真数据上改出来; +- 接口:真 runner 加脚本化的模型,跑「工具节点 db_query__<源> → 报告节点」的图,经 AsyncClient(ASGITransport) + 取推断来源。手工 SQLite 源和上传源各一。 + +每份结论都断言 contract_problems(out) == []。端到端验收(A1–A26)由 WP-E 覆盖。不调用任何模型。 +""" +from __future__ import annotations + +import asyncio +import copy +import hashlib +import io +import json +import os +import sqlite3 +import sys +import threading +import types +import uuid +from dataclasses import asdict, dataclass, field, fields +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from openpyxl import load_workbook +from sqlalchemy import select + +from app.api import evidence as api +from app.core import artifact_store +from app.core.config import settings +from app.data.engine import SnapshotTampered, engines +from app.data.provenance_types import ( + DOC_PROVENANCE, + SCHEMA, + AcceptanceView, + Alert, + CanonicalView, + CellRef, + CellSource, + DirectSelect, + FromCell, + LocateProblem, + PartView, + PeriodView, + ProvenanceOut, + Reason, + Recheck, + RelatedCheck, + ReportRef, + StateNote, + TableRef, + VersionView, + YearSource, + contract_problems, +) +from app.data.table_versions import SnapshotManifestMismatch, SnapshotMissing +from app.db.base import SessionLocal +from app.db.models import DataSource, Run +from app.engine.evidence import iter_segments +from app.engine.runner import run_manager +from app.main import app +from tests.fixtures.xlsx.flow import SHEET, flow_workbook + +SOURCE = "flow_demo" +SQL = 'SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\'' + + +# -------------------------------------------------------------------------- +# 单元夹具:现造的工件、内存里的封存范围 +# -------------------------------------------------------------------------- + + +async def put(obj, kind="query_snapshot") -> str: + return await artifact_store.put_json(obj, kind=kind) + + +def _path(artifact: str): + return settings.data_dir / "artifacts" / artifact[:2] / f"{artifact}.json" + + +def tamper(artifact: str) -> None: + """内容改一个字:取回时哈希复验不通过(load 抛 ValueError)。""" + path = _path(artifact) + path.write_text(path.read_text("utf-8").replace("2026", "2027", 1), encoding="utf-8") + + +async def schema_snapshot(**extra) -> str: + return await put({"schema": {"tables": [{"name": "日客流", "primary_key": ["日期"], "columns": [ + {"name": "日期", "type": "TEXT"}, {"name": "全日客流", "type": "INTEGER"}]}]}, + "synced_at": "2026-10-01T00:00:00+00:00", **extra}, kind="schema_snapshot") + + +async def query_snapshot(schema_id: str | None, *, data_version: str | None = "ab" * 32, **extra) -> str: + snap = {"columns": ["日期", "全日客流"], "rows": [["2026-08-05", 8754]], "row_count": 1, "truncated": False, + "elapsed_ms": 1, "sql": SQL, "column_types": {"日期": "date", "全日客流": "number"}, "source": SOURCE, + **({"schema_artifact": schema_id} if schema_id else {}), + **({"data_version": data_version} if data_version else {}), **extra} + return await put(snap) + + +def sealed_of(*, queries=(), schemas=(), trusted=True, ledger=()) -> api._Sealed: + seal = {"sealed": trusted, "ok": True if trusted else None, "manifest_seq": 9 if trusted else None, + "legacy": False} + return api._Sealed(run_id="run-p4c", graph={}, seal=seal, events=[], + queries={q: {"node_id": "fetch", "tool": f"db_query__{SOURCE}"} for q in queries}, + schemas={s: {"node_id": "fetch"} for s in schemas}, ledger=set(ledger)) + + +_TOOL = object() + + +def doc_of(query_id: str, *, marked=True, tool=_TOOL, kind="query", extra_catalog=None, source=SOURCE) -> dict: + tool = f"db_query__{source}" if tool is _TOOL else tool + doc = {"schema": "agentlab.report/1", "entity_syntax": 2, "run_id": "run-p4c", "node_id": "write", + "catalog": {"Q1": {"kind": kind, "artifact": query_id, "tool": tool, "node_id": "fetch", "source": source}, + **(extra_catalog or {})}, "blocks": []} + if marked: + doc["provenance"] = DOC_PROVENANCE + return doc + + +def cell_seg(*, column="全日客流", row=0, **cite) -> dict: + return {"id": "s4", "kind": "value", "state": "deterministic", "text": "8,754", + "cite": {"kind": "cell", "status": "resolved", "alias": "Q1", "locator": {"row": row, "column": column}, + **cite}} + + +def report_of(doc: dict) -> api._Report: + return api._Report(node_id="write", doc_artifact="d0" * 32, ok=True, repairs=0, stats=None, doc=doc, + hash_ok=True, fields=[]) + + +def ok(out: ProvenanceOut) -> ProvenanceOut: + assert contract_problems(out) == [], contract_problems(out) + return out + + +async def provenance(doc, seg, sealed) -> ProvenanceOut: + return ok(await api.segment_provenance(report_of(doc), seg, sealed, masks=api._Masks())) + + +@pytest.fixture +def drill(monkeypatch): + """第二部分(R5 起)的替身:记下 R4 交过去的东西,回一个占位的结论。""" + calls: list[dict] = [] + + async def fake(base, **kw): + calls.append({"base": base, **kw}) + return ProvenanceOut(status="none", reason=api.refuse("manifest_unreadable"), + alert=api.alert("manifest_unreadable"), **base) + + monkeypatch.setattr(api, "_drill", fake) + return calls + + +# -------------------------------------------------------------------------- +# R0–R4 +# -------------------------------------------------------------------------- + + +async def test_r0_legacy_doc_still_names_the_cell(drill): + """期 4 之前的文档(没有标记)一律 legacy_doc;cell 只看片段本身是不是单元格引用,与 status 无关。""" + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid) + out = await provenance(doc_of(qid, marked=False), cell_seg(), sealed_of(queries=[qid], schemas=[sid])) + assert out.status == "none" and out.reason.code == "legacy_doc" and out.alert is None + assert out.version is None and out.cell_source is None and out.checks == [] + assert out.cell == CellRef(alias="Q1", row=0, column="全日客流", artifact=qid) + assert out.report == ReportRef(node_id="write", doc_artifact="d0" * 32) and out.segment == "s4" + assert out.sealed is True and out.schema == SCHEMA + # 标记的版本号不认识(以后的文法)也不按 1 的规则判 + out = await provenance({**doc_of(qid), "provenance": 2}, cell_seg(), sealed_of(queries=[qid], schemas=[sid])) + assert out.reason.code == "legacy_doc" + assert drill == [] + + +@pytest.mark.parametrize("case", ["metric", "unresolved", "not_deterministic", "entry_not_query", "agent_field", + "code_node"]) +async def test_r1_only_direct_query_cells(case, drill): + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid) + seg, doc = cell_seg(), doc_of(qid) + if case == "metric": + seg = {"id": "s9", "kind": "value", "state": "deterministic", "text": "8,754", + "cite": {"kind": "metric", "status": "resolved", "alias": "M1", "locator": {"metric": "total"}}} + elif case == "unresolved": + seg["cite"]["status"] = "unresolved" + elif case == "not_deterministic": + seg["state"] = "flagged" + elif case == "entry_not_query": + doc = doc_of(qid, kind="retrieval") + elif case == "agent_field": + doc = doc_of(qid, tool="agent") + else: + doc = doc_of(qid, tool=None) + out = await provenance(doc, seg, sealed_of(queries=[qid], schemas=[sid])) + assert out.status == "none" and out.reason.code == "not_cell" and out.version is None + # 单元格引用没解析成功也照样给 cell(2.8.1:与 status 无关);指标片段没有 cell + assert (out.cell is None) == (case == "metric") + assert drill == [] + + +async def test_r2_query_snapshot_must_be_returned_in_this_run(drill): + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid) + # 不在本次运行的事件里 + out = await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[], schemas=[sid])) + assert (out.status, out.reason.code, out.alert) == ("none", "not_sealed", None) + # 在事件里,但取回时哈希复验不通过 + bad = await query_snapshot(sid, elapsed_ms=2) + tamper(bad) + out = await provenance(doc_of(bad), cell_seg(), sealed_of(queries=[bad], schemas=[sid])) + assert out.reason.code == "not_sealed" + # 在事件里,文件不在了 + gone = await query_snapshot(sid, elapsed_ms=3) + _path(gone).unlink() + out = await provenance(doc_of(gone), cell_seg(), sealed_of(queries=[gone], schemas=[sid])) + assert out.reason.code == "not_sealed" + assert drill == [] + + +async def test_r3_manual_sources_are_not_uploads(drill): + sid = await schema_snapshot() + qid = await query_snapshot(sid, data_version=None) + out = await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[qid], schemas=[sid])) + assert (out.status, out.reason.code, out.version) == ("none", "not_upload", None) + assert drill == [] + + +async def test_r4_schema_snapshot_and_import_mode(drill): + recipe = await schema_snapshot(import_mode="recipe") + # 查询快照没记表结构快照 + qid = await query_snapshot(None) + assert (await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[qid]))).reason.code == "not_sealed" + # 表结构快照不在本次运行的事件里 + qid = await query_snapshot(recipe) + assert (await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[qid]))).reason.code == "not_sealed" + # 在事件里,哈希不符 + bad = await schema_snapshot(import_mode="recipe", total=1) + tamper(bad) + qid = await query_snapshot(bad) + out = await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[qid], schemas=[bad])) + assert out.reason.code == "not_sealed" and out.alert is None + # 简单导入(没有 import_mode)、v0 迁移出的版本:只说一句,不标红 + simple = await schema_snapshot() + qid = await query_snapshot(simple) + out = await provenance(doc_of(qid), cell_seg(), sealed_of(queries=[qid], schemas=[simple])) + assert (out.status, out.reason.code, out.alert, out.version) == ("none", "simple_upload", None, None) + assert drill == [] + + +@pytest.mark.parametrize("trusted", [True, False]) +async def test_r4_passed_hands_over_to_part_two_regardless_of_seal(drill, trusted): + """配方导入的上传源:R0–R4 都满足,交给第二部分。未封存(停在审批上)、封存核对不通过的运行照常往下判, + `sealed` 照实给,不会被误判成清单无法读取(推断来源接口不走 _sealed_loader)。""" + sid = await schema_snapshot(import_mode="recipe", import_manifests=["c" * 64]) + qid = await query_snapshot(sid) + sealed = sealed_of(queries=[qid], schemas=[sid], trusted=trusted) + doc = doc_of(qid) + out = await api.segment_provenance(report_of(doc), cell_seg(), sealed, masks=api._Masks()) + assert len(drill) == 1 + call = drill[0] + assert call["query"]["data_version"] == "ab" * 32 and call["schema"]["import_mode"] == "recipe" + assert call["entry"] is doc["catalog"]["Q1"] and call["sealed"] is sealed + assert call["base"]["sealed"] is trusted and out.sealed is trusted + # 前几条取过的工件留在同一份备忘里,第二部分不必再取 + assert call["artifacts"]._memo[qid][1] is True and call["artifacts"]._memo[sid][1] is True + + +async def test_artifacts_memo_fetches_once_in_a_thread(monkeypatch): + qid = await query_snapshot(None, elapsed_ms=11) + bad = await query_snapshot(None, elapsed_ms=12) + tamper(bad) + calls: list[tuple[str, bool]] = [] + real = api._fetch + + def spy(artifact): + calls.append((artifact, threading.current_thread() is threading.main_thread())) + return real(artifact) + + monkeypatch.setattr(api, "_fetch", spy) + memo = api._Artifacts() + first = await memo.get(qid) + assert first[1] is True and (await memo.get(qid)) is first and memo.load(qid) == first[0] + assert calls == [(qid, False)] # 只取一次,在线程里 + assert (await memo.get(bad))[1] is False + with pytest.raises(ValueError): + memo.load(bad) # 同步 loader 的语义同 artifact_store.load + assert memo.load("f" * 64) is None and memo.load("") is None + assert await memo.get("") == (None, None) + + +# -------------------------------------------------------------------------- +# R14 之后的异常映射 +# -------------------------------------------------------------------------- + + +def test_snapshot_failure_order(): + """SnapshotManifestMismatch 同时是 SnapshotMissing 和 SnapshotTampered,必须最先认(顺序是变异守卫点)。""" + mismatch = SnapshotManifestMismatch("清单不一致") + assert isinstance(mismatch, SnapshotMissing) and isinstance(mismatch, SnapshotTampered) + reason, red = api._snapshot_failure(mismatch) + assert (reason.code, red) == ("chain_mismatch", Alert(code="chain_mismatch", text=reason.text)) + reason, red = api._snapshot_failure(SnapshotTampered("文件被改")) + assert (reason.code, red.code) == ("db_tampered", "db_tampered") and red.text == reason.text + reason, red = api._snapshot_failure(SnapshotMissing("文件不在")) + assert (reason.code, red) == ("snapshot_gone", None) + assert all(isinstance(e, api._SNAPSHOT_ERRORS) + for e in (mismatch, SnapshotTampered("x"), SnapshotMissing("x"))) + with pytest.raises(KeyError): + api._snapshot_failure(KeyError("不是数据文件的异常,原样抛出")) + # 三种结论都是 table_only 的合法组合 + for exc in (mismatch, SnapshotTampered("x"), SnapshotMissing("x")): + reason, red = api._snapshot_failure(exc) + assert isinstance(reason, Reason) + out = ProvenanceOut(report=ReportRef(node_id="w", doc_artifact="d"), segment="s1", status="table_only", + reason=reason, alert=red, sealed=True, + version=VersionView(source=SOURCE, snapshot_id="ab" * 32, mode="replace", union=False)) + assert contract_problems(out) == [] + + +# -------------------------------------------------------------------------- +# 查询步骤的提示字段(2.8.2) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("marked,upload", [(True, True), (True, False), (False, True), (False, False)]) +async def test_query_step_hint_needs_marker_and_upload(marked, upload): + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid, data_version="ab" * 32 if upload else None) + sealed = sealed_of(queries=[qid], schemas=[sid]) + plain = await api._chain(cell_seg(), doc_of(qid, marked=False), sealed, "write") + steps = await api._chain(cell_seg(), doc_of(qid, marked=marked), sealed, "write") + assert len(steps) == 1 and steps[0]["step"] == "query" + if marked and upload: + assert steps[0]["provenance"] is True + # 只多这一个键,其余一字不差 + assert {k: v for k, v in steps[0].items() if k != "provenance"} == plain[0] + else: + assert "provenance" not in steps[0] and steps[0] == plain[0] + + +async def test_query_step_hint_ignores_seal_but_needs_the_snapshot(): + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid) + # 未封存的运行也给提示:推断来源照常推断 + steps = await api._chain(cell_seg(), doc_of(qid), sealed_of(queries=[qid], schemas=[sid], trusted=False), "write") + assert steps[0]["provenance"] is True and steps[0]["sealed"] is False + # 快照不是本次运行交回过的:一行都不给,也不给提示 + steps = await api._chain(cell_seg(), doc_of(qid), sealed_of(queries=[], schemas=[sid]), "write") + assert steps[0]["note"] == api.UNSEALED_QUERY and "provenance" not in steps[0] + + +async def test_metric_chain_query_steps_never_carry_the_hint(): + """同一份带标记的文档、同一份上传源的快照:指标链里的查询步骤不带提示(指标片段只会回 not_cell)。""" + sid = await schema_snapshot(import_mode="recipe") + qid = await query_snapshot(sid) + card = await put({"caliber": "周报口径", "caliber_version": "v1", "metrics": [{ + "id": "total", "name": "全日客流", "value": 8754, "unit": "人次", "decimals": 0, + "inputs": [{"via": "tool_cell", "artifact": qid, "node_id": "fetch", + "locator": {"row": 0, "column": "全日客流"}}]}]}, kind="metric_set") + doc = doc_of(qid, extra_catalog={"M1": {"kind": "metric", "artifact": card, "node_id": "card", + "locator": {"metric": "total"}, "name": "全日客流"}}) + seg = {"id": "s9", "kind": "value", "state": "deterministic", "text": "8,754", + "cite": {"kind": "metric", "status": "resolved", "alias": "M1", "locator": {"metric": "total"}}} + steps = await api._chain(seg, doc, sealed_of(queries=[qid], schemas=[sid], ledger=[("card", card)]), "write") + queries = [s for s in steps if s.get("step") == "query"] + assert len(queries) == 1 and queries[0]["artifact"] == qid and queries[0]["rows"] + assert "provenance" not in queries[0] + # 同一份文档里直接引用同一格的片段照常带 + assert (await api._chain(cell_seg(), doc, sealed_of(queries=[qid], schemas=[sid]), "write"))[0]["provenance"] + + +# -------------------------------------------------------------------------- +# 按需裁判的 loader:链扩展(2.8.3) +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def chain_world(monkeypatch): + """封存范围里一份查询快照、一份表结构快照;表结构快照经哈希链列出一份导入清单(由 chain_artifacts 的替身给出)。""" + manifest = await put({"source_id": "s" * 32, "seq": 1, "period": {"start": "2026-08-01"}}, kind="import_manifest") + stray = await put({"source_id": "s" * 32, "seq": 9, "period": {"start": "2026-09-01"}}, kind="import_manifest") + sid = await schema_snapshot(import_mode="recipe", import_manifests=[manifest]) + qid = await query_snapshot(sid) + calls: list[tuple[dict, object]] = [] + + def fake_chain(schema, loader): + calls.append((schema, loader)) + return set(schema.get("import_manifests") or []) + + monkeypatch.setattr(api, "_chain_artifacts", fake_chain) + return types.SimpleNamespace(manifest=manifest, stray=stray, schema=sid, query=qid, calls=calls) + + +async def test_sealed_loader_without_chain_is_unchanged(chain_world): + w = chain_world + loader = api._sealed_loader(sealed_of(queries=[w.query], schemas=[w.schema])) + assert loader(w.query)["data_version"] == "ab" * 32 and loader(w.schema)["import_mode"] == "recipe" + assert loader(w.manifest) is None and loader(w.stray) is None + assert w.calls == [] # 老文档根本不去碰清单 + + +async def test_sealed_loader_chain_is_lazy_and_bounded(chain_world): + w = chain_world + sealed = sealed_of(queries=[w.query], schemas=[w.schema]) + loader = api._sealed_loader(sealed, chain=True) + assert loader(w.query) is not None and w.calls == [] # 基本范围内的不触发扩展 + assert loader(w.manifest)["seq"] == 1 # 链上的清单取得到 + assert len(w.calls) == 1 and w.calls[0][0]["import_manifests"] == [w.manifest] + assert loader(w.stray) is None # 不在链上的取不到 + assert loader("f" * 64) is None + assert len(w.calls) == 1 # 同一个 loader 只算一次 + api._sealed_loader(sealed, chain=True)(w.manifest) + assert len(w.calls) == 2 # 新的一次请求重新算 + + +async def test_sealed_loader_chain_still_needs_a_trusted_seal(chain_world): + w = chain_world + loader = api._sealed_loader(sealed_of(queries=[w.query], schemas=[w.schema], trusted=False), chain=True) + assert loader(w.query) is None and loader(w.manifest) is None and w.calls == [] + + +async def test_sealed_loader_chain_skips_unreadable_schema_snapshots(chain_world): + w = chain_world + bad = await schema_snapshot(import_mode="recipe", import_manifests=[w.stray], total=3) + tamper(bad) + loader = api._sealed_loader(sealed_of(queries=[w.query], schemas=[bad, w.schema, "e" * 64]), chain=True) + assert loader(w.manifest)["seq"] == 1 and loader(w.stray) is None + assert [c[0]["import_manifests"] for c in w.calls] == [[w.manifest]] + + +def test_chain_artifacts_delegates_to_the_provenance_module(monkeypatch): + seen = [] + fake = types.ModuleType("app.data.provenance") + fake.chain_artifacts = lambda schema, load: seen.append((schema, load)) or {"m1", "m2"} + monkeypatch.setitem(sys.modules, "app.data.provenance", fake) + loader = object() + assert api._chain_artifacts({"import_manifests": ["m1"]}, loader) == {"m1", "m2"} + assert seen == [({"import_manifests": ["m1"]}, loader)] + + +def test_chain_artifacts_uses_the_real_chain(): + """不经替身:表结构快照列出的导入清单在链上(provenance.chain_artifacts),快照清单取不回、哈希不符时只是少列、 + 不抛(按需裁判不因一份坏清单报错)。""" + def broken(_aid): + raise ValueError("哈希不符") + + assert api._chain_artifacts({"import_manifests": ["m1", "m2"]}, lambda a: None) == {"m1", "m2"} + assert api._chain_artifacts({"import_manifests": ["m1"], "snapshot_manifest": "sm"}, lambda a: None) == {"m1"} + assert api._chain_artifacts({"import_manifests": ["m1"], "snapshot_manifest": "sm"}, broken) == {"m1"} + sm = {"parts": [{"manifest_artifact": "m1"}, {"manifest_artifact": "m0"}]} + assert api._chain_artifacts({"import_manifests": ["m1"], "snapshot_manifest": "sm"}, + lambda a: sm if a == "sm" else None) == {"m1", "m0", "sm"} + + +@pytest.mark.parametrize("marked", [True, False]) +async def test_judge_now_extends_the_loader_only_for_marked_docs(monkeypatch, marked): + from app.engine import judge as judging + + chains: list[bool] = [] + monkeypatch.setattr(api, "_sealed_loader", lambda sealed, *, chain=False: chains.append(chain) or (lambda a: None)) + monkeypatch.setattr(judging, "prepare", lambda *a, **kw: types.SimpleNamespace(skipped={}, cands=[])) + doc = {"blocks": [], "catalog": {}, **({"provenance": DOC_PROVENANCE} if marked else {})} + out = await api._judge_now(sealed_of(), report_of(doc), ["u1"]) + assert chains == [marked] and out["calls"] == 0 + + +# -------------------------------------------------------------------------- +# 接口:真运行 +# -------------------------------------------------------------------------- + +TEXT = "2026-08-05 的全日客流为 [[v:Q1.r0.全日客流]] 人次,口径卡记为 [[m:total]]。" + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture +async def flow_source(tmp_path, engine_up): + """手工 SQLite 源 flow_demo(不是上传表格:查询快照没有 data_version)。""" + path = tmp_path / "flow.db" + db = sqlite3.connect(path) + db.execute('CREATE TABLE "日客流" ("日期" TEXT PRIMARY KEY, "全日客流" INTEGER)') + db.executemany('INSERT INTO "日客流" VALUES (?, ?)', [(f"2026-08-{d:02d}", 1000 + 37 * d) for d in range(1, 11)]) + db.commit() + db.close() + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none() + if row is None: + row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield path + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def graph(*, report=True): + nodes = [node("start", "input"), node("fetch", "tool", tool=f"db_query__{SOURCE}", args={"sql": SQL}), + node("card", "metrics", caliber="周报口径", caliber_version="v1", metrics=[ + {"id": "total", "name": "全日客流", "unit": "人次", "decimals": 0, + "expression": "cell(nodes.fetch, 0, '全日客流')"}])] + if report: + nodes.append(node("write", "report", instructions="写日报", on_violation="flag")) + nodes.append(node("out", "output", fields=[{"name": "日报", "value": "{{ nodes.write.text }}"}])) + else: + nodes.append(node("out", "output", fields=[{"name": "日报", "value": "无"}])) + return chain(*nodes) + + +async def run(monkeypatch, *, report=True) -> Run: + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=TEXT)) + run_id = (await run_manager.start(graph=graph(report=report), input_payload={})).id + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + assert row.status == "succeeded", row.error + return row + raise AssertionError(f"run {run_id} 没有结束:{row.status}") + + +def doc_id(row: Run) -> str: + return row.output["_evidence"]["doc_artifact"] + + +def load_doc(row: Run) -> dict: + return json.loads(_path(doc_id(row)).read_text("utf-8")) + + +def seg_of(doc: dict, kind: str) -> dict: + return next(s for s in iter_segments(doc) if (s.get("cite") or {}).get("kind") == kind) + + +def _opt(cls, value): + return cls(**value) if value is not None else None + + +def from_json(body: dict) -> ProvenanceOut: + """响应 JSON → ProvenanceOut(逐层还原嵌套的类型),给 contract_problems 用。还原后再 asdict 必须逐字等于原 JSON: + 键名(含 cell_source 的「from」)、顺序、取值都没有在路由里走样。""" + assert list(body) == [f.name for f in fields(ProvenanceOut)] + v, cs = body["version"], body["cell_source"] + version = None if v is None else VersionView(**{ + **v, "tables": [TableRef(**t) for t in v["tables"]], + "parts": [PartView(**{**p, "period": _opt(PeriodView, p["period"]), + "acceptances": [AcceptanceView(**a) for a in p["acceptances"]], + "purged": _opt(StateNote, p["purged"]), "revoked": _opt(StateNote, p["revoked"])}) + for p in v["parts"]]}) + cell_source = None if cs is None else CellSource(**{ + **cs, "from": [FromCell(**f) for f in cs["from"]], "year": _opt(YearSource, cs["year"]), + "canonical": _opt(CanonicalView, cs["canonical"]), "recheck": _opt(Recheck, cs["recheck"])}) + out = ProvenanceOut(**{**body, "report": 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": [RelatedCheck(**{**c, "acceptance": _opt(AcceptanceView, c["acceptance"])}) + for c in body["checks"]]}) + assert asdict(out) == body + return out + + +async def test_new_documents_carry_the_marker(monkeypatch, flow_source, client): + row = await run(monkeypatch) + doc = load_doc(row) + # 工件按规范 JSON 存(键排序),标记在顶层、参与内容哈希 + assert doc["provenance"] == DOC_PROVENANCE == 1 + assert artifact_store.content_hash(artifact_store.canonical_json(doc)) == doc_id(row) + assert doc["stats"]["violations"] == 0 + + +async def test_route_answers_with_status_not_errors(monkeypatch, flow_source, client): + """手工源的单元格回 not_upload,指标片段回 not_cell:推断不出来不是错误,都是 200。片段接口的查询步骤 + (手工源)不带提示。""" + row = await run(monkeypatch) + doc = load_doc(row) + cell, metric = seg_of(doc, "cell"), seg_of(doc, "metric") + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}/provenance") + assert resp.status_code == 200, resp.text + body = resp.json() + out = ok(from_json(body)) + assert (out.status, out.reason.code, out.sealed) == ("none", "not_upload", True) + assert out.report == ReportRef(node_id="write", doc_artifact=doc_id(row)) and out.segment == cell["id"] + assert out.cell.alias == "Q1" and out.cell.row == 0 and out.cell.column == "全日客流" + assert out.cell.artifact == doc["catalog"]["Q1"]["artifact"] + # 指定报告节点也一样 + again = await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}/provenance?report=write") + assert again.json() == body + + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/{metric['id']}/provenance") + out = ok(from_json(resp.json())) + assert (out.status, out.reason.code, out.cell) == ("none", "not_cell", None) + + step = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}")).json()["chain"][0] + assert step["step"] == "query" and step["rows"] and "provenance" not in step + + +async def test_route_error_codes(monkeypatch, flow_source, client): + row = await run(monkeypatch) + doc = load_doc(row) + cell = seg_of(doc, "cell") + base = f"/api/runs/{row.id}/evidence/segments" + + async def code(url): + resp = await client.get(url) + return resp.status_code, resp.json().get("code") + + assert await code(f"/api/runs/nosuchrun/evidence/segments/{cell['id']}/provenance") == (404, "run_not_found") + assert await code(f"{base}/{cell['id']}/provenance?report=nosuchnode") == (404, "evidence_report_not_found") + assert await code(f"{base}/s999/provenance") == (404, "evidence_segment_not_found") + path = _path(doc_id(row)) + original = path.read_text("utf-8") + path.write_text(original.replace('"provenance":1', '"provenance":2'), encoding="utf-8") + assert await code(f"{base}/{cell['id']}/provenance") == (409, "evidence_doc_tampered") + path.unlink() + assert await code(f"{base}/{cell['id']}/provenance") == (404, "evidence_doc_missing") + path.write_text(original, encoding="utf-8") + assert (await client.get(f"{base}/{cell['id']}/provenance")).status_code == 200 + + +async def test_route_without_a_report(monkeypatch, flow_source, client): + row = await run(monkeypatch, report=False) + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/s1/provenance") + assert (resp.status_code, resp.json().get("code")) == (404, "evidence_report_not_found") + + +async def test_route_on_a_pre_p4_document(monkeypatch, flow_source, client): + """期 4 之前组装的文档(报告节点存下的就是没有标记的老形状):legacy_doc,cell 照样给。""" + from app.engine.nodes import report as report_node + + real = report_node.compose_doc + + def legacy(*a, **kw): + doc = real(*a, **kw) + doc.pop("provenance", None) + return doc + + monkeypatch.setattr(report_node, "compose_doc", legacy) + row = await run(monkeypatch) + doc = load_doc(row) + assert "provenance" not in doc + cell = seg_of(doc, "cell") + out = ok(from_json((await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}/provenance")).json())) + assert (out.status, out.reason.code) == ("none", "legacy_doc") and out.cell is not None + assert asdict(out)["cell"]["artifact"] == doc["catalog"]["Q1"]["artifact"] + + +# ========================================================================== +# 第二部分:真导入的上传源(R5–R17、异常映射、相关核对、当前状态) +# ========================================================================== + +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +DS_API = "/api/datasources" +FLOW_TABLES = ("日客流", "时段客流", "时段客流_表内合计") +REASON = "合成理由:分区合计口径调整" +WIDE_SQL = 'SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\'' + + +@dataclass +class Upload: + """经接口按配方导入的上传源:名字、id、当前快照、各期导入记录和原件(按导入顺序)。""" + + name: str + source_id: str + snapshot: str + imports: list[str] = field(default_factory=list) + raws: list[bytes] = field(default_factory=list) + files: list[str] = field(default_factory=list) + + +@pytest.fixture +def upload_world(monkeypatch, tmp_path): + """每个用例一个独立的数据目录(原件、构建库、快照库、工件互不串)。导入不接模型:规则起草不完整时会查 AI 可用性, + 换成「不可用」的桩,连模型对象都不建。""" + 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 no_model(session): + return None, "", AiAvailability(False, reason="测试不接模型") + + monkeypatch.setattr(recipe_ai, "resolve_draft_model", no_model) + return data + + +def _answers(questions: list[dict], mode: str) -> dict[str, dict]: + """系统发现一律「登记」,q_mode 按 mode,其余能存为空值的选「存为空值」(参考配方的写法)。""" + 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, old: str, new: str) -> dict: + 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 + + +async def _commit(c: AsyncClient, st: dict, acceptances: list[dict] | None = None) -> dict: + body: dict = {"trial_id": st["trial"]["trial_id"], "confirmations": [i["id"] for i in st["trial"]["confirm_items"]]} + if acceptances: + body["acceptances"] = acceptances + r = await c.post(f"{DS_API}/imports/{st['id']}/commit", json=body) + assert r.status_code == 201, r.text + return r.json() + + +async def import_flow(c: AsyncClient, *, mode: str = "replace", seed: int = 4101, start=None, days: int = 31, + variant: str = "D00") -> Upload: + """合成客流表:宽表改名「日客流」后导入。""" + from tests.fixtures.xlsx.drift import AUG + + raw, fn = flow_workbook(start or AUG, days, seed=seed, variant=variant) + return await import_workbook(c, raw, fn, mode=mode) + + +async def import_workbook(c: AsyncClient, raw: bytes, fn: str, *, mode: str = "replace", + rename: bool = True) -> Upload: + """暂存 → 回答问题 → (客流表)宽表改名「日客流」→ 试运行 → 勾全部确认项提交(同期 3 验收的公共做法)。 + rename=False 给不是客流表的夹具(lab.c01 列表),表名照规则起草的。""" + name = f"p4c2_{uuid.uuid4().hex[:8]}" + r = await c.post(f"{DS_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"{DS_API}/imports/{st['id']}/answers", json={"answers": _answers(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 FLOW_TABLES) + r = await c.put(f"{DS_API}/imports/{st['id']}/recipe", + json={"recipe": _rename_wide(st["recipe"], wide, "日客流")}) + assert r.status_code == 200, r.text + r = await c.post(f"{DS_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 Upload(name=name, source_id=out["source"]["id"], snapshot=out["snapshot_id"], imports=[out["import_id"]], + raws=[raw], files=[fn]) + + +async def add_period(c: AsyncClient, up: Upload, *, seed: int, variant: str = "D00", days: int = 30, + acceptances: list[dict] | None = None) -> Upload: + from tests.fixtures.xlsx.drift import SEP + + raw, fn = flow_workbook(SEP, days, seed=seed, variant=variant) + r = await c.post(f"{DS_API}/{up.source_id}/reupload", files={"file": (fn, raw, XLSX)}) + assert r.status_code == 201, r.text + st = r.json() + assert st["trial"]["status"] in ("passed", "needs_decision"), st["trial"]["problems"] + out = await _commit(c, st, acceptances) + return Upload(name=up.name, source_id=up.source_id, snapshot=out["snapshot_id"], + imports=[*up.imports, out["import_id"]], raws=[*up.raws, raw], files=[*up.files, fn]) + + +@pytest.fixture +async def rep(upload_world, client): + """8 月 D00,每期替换(单期快照)。""" + up = await import_flow(client) + yield up + await engines.invalidate(up.source_id) + + +async def run_sql(c: AsyncClient, up: Upload, sql: str) -> tuple[str, str]: + """经工具接口真跑一次查询(上传源查当前版本):(查询快照 id, 表结构快照 id)。存下的 SQL 是守卫规范过的那一条。""" + r = await c.post(f"/api/tools/db_query__{up.name}/run", json={"args": {"sql": sql}}) + assert r.status_code == 200, r.text + out = r.json() + assert out["ok"], out + payload = json.loads(out["result"]) + assert artifact_store.load(payload["artifact"])["data_version"] == up.snapshot + return payload["artifact"], payload["schema_artifact"] + + +async def drill_of(up: Upload, qid: str, sid: str, *, column: str = "全日客流", row: int = 0, + trusted: bool = True) -> ProvenanceOut: + """拼内存里的封存范围(这份查询快照、这份表结构快照),走 segment_provenance;断言契约、JSON 往返不走样。""" + out = await provenance(doc_of(qid, source=up.name), cell_seg(column=column, row=row), + sealed_of(queries=[qid], schemas=[sid], trusted=trusted)) + from_json(json.loads(json.dumps(asdict(out), ensure_ascii=False))) + return out + + +async def forged_query(qid: str, **changes) -> str: + """查询快照改几项另存一份(新 id):在真数据上造反例。""" + return await put({**artifact_store.load(qid), **changes}) + + +async def forged_schema(sid: str, edit) -> str: + schema = copy.deepcopy(artifact_store.load(sid)) + edit(schema) + return await put(schema, kind="schema_snapshot") + + +def cell_value(raw: bytes, sheet: str, ref: str): + return load_workbook(io.BytesIO(raw), data_only=True)[sheet][ref].value + + +async def edit_row(model, key, **values) -> dict: + """改数据库里的一行,返回原值(用例最后改回去)。""" + async with SessionLocal() as session: + row = await session.get(model, key) + before = {k: getattr(row, k) for k in values} + for k, v in values.items(): + setattr(row, k, v) + await session.commit() + return before + + +# -------------------------------------------------------------------------- +# 满足全部条件:inferred +# -------------------------------------------------------------------------- + + +async def test_inferred_wide_cell(rep, client): + """宽表的一格(P4-SPEC 2.5 推演表第一行、6.2 A4):格子、附带的格、年份、回查、相关核对、数据版本。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + out = await drill_of(rep, qid, sid) + snap = artifact_store.load(qid) + assert (out.status, out.reason, out.alert, out.sealed) == ("inferred", None, None, True) + cs = out.cell_source + assert (cs.table, cs.column, cs.column_role, cs.kind) == ("日客流", "全日客流", "measure", "data") + assert (cs.sheet, cs.cell) == (SHEET, "G5") and cs.part_seq == 1 and cs.part_rowid == cs.rowid + assert cell_value(rep.raws[0], SHEET, "G5") == snap["rows"][0][1] + assert [(f.role, f.column, f.cell, f.text) for f in cs.from_] == [ + ("axis_header", "日期", "G4", None), ("row_label", None, "B5", "全日客流(人次)")] + assert cs.year == YearSource(source="period", cells=[f"{SHEET}!B2"], signed_by=None, mixed=False) + assert cs.pk == {"日期": "2026-08-05"} and cs.raw_purged is False and cs.merged_fill is False + assert cs.recheck == Recheck(sql='SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?', + params=["2026-08-05"], ok=True) + r1 = next(c for c in out.checks if c.id == "R1") + assert (r1.kind, r1.part_status, r1.row_status, r1.acceptance) == ("relation_sum_eq", "passed", "passed", None) + v = out.version + assert (v.source, v.snapshot_id, v.mode, v.union, v.manifest_view) == (rep.name, rep.snapshot, "replace", False, True) + assert v.tables == [TableRef(name="日客流", kind="data")] + assert len(v.parts) == 1 + part = v.parts[0] + assert (part.import_id, part.seq, part.has_row, part.file_name) == (rep.imports[0], 1, True, rep.files[0]) + assert part.raw_sha256 == hashlib.sha256(rep.raws[0]).hexdigest() + assert (part.raw_state, part.purged, part.revoked, part.recipe_seq) == ("kept", None, None, 1) + assert part.region == f"{SHEET}!B4:AG30" and part.period.start == "2026-08-01" + # 路由一律 asdict:cell_source 的「from」在 JSON 里叫 from + assert "from" in asdict(out)["cell_source"] and "from_" not in asdict(out)["cell_source"] + + +async def test_unsealed_runs_still_infer(rep, client): + """停在审批上、封存核对不通过的运行(trusted 为假)照常推断:sealed 照实给 false,不标红(1.3 调整 11)。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + out = await drill_of(rep, qid, sid, trusted=False) + assert (out.status, out.sealed, out.alert) == ("inferred", False, None) + assert out.cell_source.cell == "G5" + + +async def test_heavy_steps_run_in_threads(rep, client, monkeypatch): + """清单取回(resolve_chain)、找回格子和相关核对(要解析配方、还原回执)在线程里跑,不卡事件循环(2.9)。""" + seen: dict[str, bool] = {} + + def spy(name, real): + def wrapped(*a, **kw): + seen[name] = threading.current_thread() is threading.main_thread() + return real(*a, **kw) + return wrapped + + for name in ("resolve_chain", "locate", "related_checks"): + monkeypatch.setattr(api.prov, name, spy(name, getattr(api.prov, name))) + qid, sid = await run_sql(client, rep, WIDE_SQL) + assert (await drill_of(rep, qid, sid)).status == "inferred" + assert seen == {"resolve_chain": False, "locate": False, "related_checks": False} + + +# -------------------------------------------------------------------------- +# R5、R6:清单链 +# -------------------------------------------------------------------------- + + +async def test_r6_chain_mismatch_and_r5_manifest_unreadable(rep, client): + qid, sid = await run_sql(client, rep, WIDE_SQL) + schema = artifact_store.load(sid) + [mid] = schema["import_manifests"] + + async def with_schema(edit) -> ProvenanceOut: + bad = await forged_schema(sid, edit) + return await drill_of(rep, await forged_query(qid, schema_artifact=bad), bad) + + # R6:单期的 import_manifests 必须恰好一个(0 个、2 个都是链本身对不上):none,version 为 null,标红 + other = await put({**artifact_store.load(mid), "created_at": "2026-10-01T00:00:00+00:00"}, kind="import_manifest") + for manifests in ([mid, other], []): + out = await with_schema(lambda s, m=manifests: s.update(import_manifests=m)) + assert (out.status, out.reason.code, out.alert.code, out.version) == ("none", "chain_mismatch", + "chain_mismatch", None) + assert out.cell_source is None and out.checks == [] + # R5:清单哈希不符、取不回:none,manifest_unreadable,标红 + path = _path(mid) + original = path.read_text("utf-8") + path.write_text(original.replace('"seq":1', '"seq":7', 1), encoding="utf-8") + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.alert.code, out.version) == ("none", "manifest_unreadable", + "manifest_unreadable", None) + path.unlink() + out = await drill_of(rep, qid, sid) + assert (out.reason.code, out.alert.code) == ("manifest_unreadable", "manifest_unreadable") + path.write_text(original, encoding="utf-8") + assert (await drill_of(rep, qid, sid)).status == "inferred" + + +# -------------------------------------------------------------------------- +# R7–R11:只看查询快照和冻结表结构 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql,column,code,detail", [ + ('SELECT "日期", "分区甲" AS "分区乙" FROM "日客流" WHERE "日期" = \'2026-08-05\'', "分区乙", "alias", "alias"), + ('SELECT "日期" "日子", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\'', "全日客流", "alias", "alias"), + ('SELECT a."日期", b."全日客流" FROM "日客流" a JOIN "日客流" b ON a."日期" = b."日期" ' + 'WHERE a."日期" = \'2026-08-05\'', "全日客流", "multi_table", "join"), + ('SELECT a."日期", b."全日客流" FROM "日客流" a, "日客流" b WHERE a."日期" = b."日期" ' + 'AND a."日期" = \'2026-08-05\'', "全日客流", "multi_table", "comma_join"), + ('SELECT SUM("全日客流") AS "合计", COUNT(*) AS "天数" FROM "日客流"', "合计", "expression", "expression"), + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE \'2026-08-0%\' ESCAPE \'\\\' ' + 'UNION ALL SELECT "日期", "分区甲" FROM "日客流" WHERE "日期" = \'2026-08-31\'', "全日客流", "multi_table", + "compound"), +]) +async def test_r7_sql_refusals_keep_the_table_level_version(rep, client, sql, column, code, detail): + """R7:受限文法之外的写法只给表级来历(version 照给),原因按 2.4 分组,细分给测试和日志。""" + qid, sid = await run_sql(client, rep, sql) + out = await drill_of(rep, qid, sid, column=column) + assert (out.status, out.reason.code, out.reason.detail, out.alert) == ("table_only", code, detail, None) + assert out.cell_source is None and out.checks == [] + assert out.version.tables == [TableRef(name="日客流", kind="data")] + assert out.version.parts[0].has_row is False + + +async def test_r8_defensive_column_mapping(rep, client, monkeypatch): + """R8:被引用的结果列对不上选取项(识别器已拒绝重名,只是防御)→ unparsed,不抛。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + monkeypatch.setattr(api.direct_select, "recognize", lambda sql, **kw: DirectSelect("日客流", None, ["日期"])) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.reason.detail) == ("table_only", "unparsed", "") + out = await drill_of(rep, qid, sid, column="不在结果里") + assert out.reason.code == "unparsed" + + +async def test_r9_primary_key(rep, client): + # 没带齐主键列:原文里写出缺的那一列 + qid, sid = await run_sql(client, rep, 'SELECT "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\'') + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code) == ("table_only", "pk_missing") and "(日期)" in out.reason.text + # 主键重复(同一张表经集合运算拼出来的结果可能这样):与解析无关的兜底,multi_table · duplicate_pk + qid, sid = await run_sql(client, rep, WIDE_SQL) + row = artifact_store.load(qid)["rows"][0] + dup = await forged_query(qid, rows=[row, list(row)], row_count=2) + out = await drill_of(rep, dup, sid) + assert (out.reason.code, out.reason.detail) == ("multi_table", "duplicate_pk") + # 冻结表结构里这张表没有主键 + nopk = await forged_schema(sid, lambda s: s["tables"]["日客流"].update(primary_key=[])) + out = await drill_of(rep, await forged_query(qid, schema_artifact=nopk), nopk) + assert (out.status, out.reason.code) == ("table_only", "no_pk") + + +async def test_r10_masks(rep, client): + """R10:被引用的列、主键列、推断要用到的键列在遮罩里(数据源现在设的 ∪ 快照记下的)都不下钻;数据源设有任何 + 遮罩时数据版本一节不出「查看导入清单」。""" + from app.db.models import DataSource as Source + + wide, sid = await run_sql(client, rep, 'SELECT "日期", "全日客流", "分区甲" FROM "日客流" ' + 'WHERE "日期" = \'2026-08-05\'') + hourly, hsid = await run_sql(client, rep, 'SELECT "日期", "时段", "客流" FROM "时段客流" ' + 'WHERE "日期" = \'2026-08-05\' AND "时段" = \'13-14\'') + assert (await drill_of(rep, wide, sid)).version.manifest_view is True + receipt = artifact_store.load(artifact_store.load(hsid)["import_manifests"][0])["receipt"] + hourly_cols = next(t for t in receipt["tables"] if t["name"] == "时段客流")["columns"] + const = next(c["name"] for c in hourly_cols if c["role"] == "const") + derive = next(c["name"] for c in hourly_cols if c["role"] == "derive") + + async def masked_as(mask: str) -> None: + await edit_row(Source, rep.source_id, options={"mask_columns": mask}) + + try: + await masked_as("分区甲") + out = await drill_of(rep, wide, sid, column="分区甲") + assert (out.status, out.reason.code, out.version.manifest_view) == ("table_only", "masked", False) + # 别的列照常推断,但数据源设了遮罩:不在面板里出导入清单 + out = await drill_of(rep, wide, sid) + assert (out.status, out.version.manifest_view) == ("inferred", False) + # 主键列。宽表的「日期」同时是日期表头格写出的列,附带格的复核也会拦;只靠主键这一项拦的情形见 + # test_r10_a_masked_primary_key_is_enough + await masked_as("日期") + assert (await drill_of(rep, wide, sid)).reason.code == "masked" + await masked_as(const) # 常量列:分段标题的定位文字就是它的值 + out = await drill_of(rep, hourly, hsid, column="客流") + assert (out.status, out.reason.code) == ("table_only", "masked") + # 派生列(起始小时)不在查询里、也不在附带的格里,但行标签原文就是它的来源:按回执的键列判 + await masked_as(derive) + out = await drill_of(rep, hourly, hsid, column="客流") + assert (out.status, out.reason.code) == ("table_only", "masked") + await masked_as("") + assert (await drill_of(rep, hourly, hsid, column="客流")).status == "inferred" + # 查询当时记下的遮罩(数据源事后改名、删掉、撤了遮罩也照遮) + recorded = await forged_query(wide, mask_columns=["全日客流"]) + out = await drill_of(rep, recorded, sid) + assert (out.reason.code, out.version.manifest_view) == ("masked", False) + finally: + await masked_as("") + + +async def test_r10_a_masked_primary_key_is_enough(upload_world, client): + """R10 的「主键列」这一项单独拦得住:列表形态(lab.c01)的主键「地区」角色是 text(不是维度、派生、常量,回执 + 键列不含它),附带的格只有 column 为空的列表头(附带格的复核也拦不住)。只遮「地区」、引用「金额」也不下钻, + 否则遮罩的主键值会经 cell_source.pk 和 recheck.params 交给界面。""" + from app.db.models import DataSource as Source + from tests.fixtures.xlsx import lab + + up = await import_workbook(client, *lab.c01(), rename=False) + try: + qid, sid = await run_sql(client, up, 'SELECT "地区", "金额" FROM "月报" WHERE "地区" = \'分区丙\'') + schema = artifact_store.load(sid) + assert schema["tables"]["月报"]["primary_key"] == ["地区"] + receipt = artifact_store.load(schema["import_manifests"][0])["receipt"] + roles = {c["name"]: c["role"] for t in receipt["tables"] if t["name"] == "月报" for c in t["columns"]} + assert roles["地区"] == "text" + # 前提:没遮罩时推断得出,主键值在 pk 和回查参数里,附带的格不写任何列的值 + clear = await drill_of(up, qid, sid, column="金额") + assert clear.status == "inferred", clear.reason + assert clear.cell_source.pk == {"地区": "分区丙"} and clear.cell_source.recheck.params == ["分区丙"] + assert [(f.role, f.column) for f in clear.cell_source.from_] == [("col_header", None)] + async with SessionLocal() as session: + options = dict((await session.get(Source, up.source_id)).options or {}) + await edit_row(Source, up.source_id, options={**options, "mask_columns": "地区"}) + try: + out = await drill_of(up, qid, sid, column="金额") + finally: + await edit_row(Source, up.source_id, options=options) + assert (out.status, out.reason.code, out.reason.detail, out.alert) == ("table_only", "masked", "", None) + assert out.cell_source is None and out.checks == [] and out.version.manifest_view is False + finally: + await engines.invalidate(up.source_id) + + +async def test_r11_null_value_and_null_pk(rep, client): + # 夹具第 10 行(7-8 时)是占位符,存为空值:片段本身是 resolved(显示「—」),不推断来源 + qid, sid = await run_sql(client, rep, 'SELECT "日期", "时段", "客流" FROM "时段客流" ' + 'WHERE "时段" = \'7-8\' AND "日期" = \'2026-08-03\'') + assert artifact_store.load(qid)["rows"][0][2] is None + out = await drill_of(rep, qid, sid, column="客流") + assert (out.status, out.reason.code, out.alert) == ("table_only", "null_value", None) + qid, sid = await run_sql(client, rep, WIDE_SQL) + row = artifact_store.load(qid)["rows"][0] + out = await drill_of(rep, await forged_query(qid, rows=[[None, row[1]]]), sid) + assert out.reason.code == "null_pk" + + +# -------------------------------------------------------------------------- +# R12、R13:数据文件记录 +# -------------------------------------------------------------------------- + + +async def test_r12_r13_data_file_record(rep, client, monkeypatch): + from app.db.models import SourceSnapshot + + qid, sid = await run_sql(client, rep, WIDE_SQL) + async with SessionLocal() as session: + record = await session.get(SourceSnapshot, rep.snapshot) + path, sha, source_id = Path(record.db_path), record.db_sha256, record.source_id + + def never(*_a, **_kw): + raise AssertionError("R12 不满足时不该去绑定、打开数据文件") + + # R12:记录已回收、文件不在、记录不存在:只给表级来历,不标红;在 R13、R14 之前就判掉 + with monkeypatch.context() as m: + m.setattr(api.provenance_db, "view_of", never) + before = await edit_row(SourceSnapshot, rep.snapshot, retired_at=datetime.now(timezone.utc)) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.alert) == ("table_only", "snapshot_gone", None) + assert out.version is not None and out.cell_source is None + await edit_row(SourceSnapshot, rep.snapshot, **before) + moved = path.with_suffix(".moved") + path.rename(moved) + assert (await drill_of(rep, qid, sid)).reason.code == "snapshot_gone" + moved.rename(path) + stray = await forged_query(qid, data_version="ab" * 32) # 单期不比快照 id,链照样能解析 + out = await drill_of(rep, stray, sid) + assert (out.status, out.reason.code) == ("table_only", "snapshot_gone") + # R13:登记哈希和清单里期望的库哈希不等(含为空)、source_id 不一致:标红。不带细分(契约的 Reason.detail + # 只装 DETAILS 和 ChainProblem / LocateProblem 的细分),和异常映射的 chain_mismatch 同一个形状(WP-E A5b) + for change in ({"db_sha256": "0" * 64}, {"db_sha256": ""}, {"source_id": "f" * 32}): + await edit_row(SourceSnapshot, rep.snapshot, **change) + try: + out = await drill_of(rep, qid, sid) + finally: + await edit_row(SourceSnapshot, rep.snapshot, db_sha256=sha, source_id=source_id) + assert (out.status, out.reason.code, out.reason.detail) == ("table_only", "chain_mismatch", "") + assert out.alert == Alert(code="chain_mismatch", text=out.reason.text) + assert out.version is not None and out.cell_source is None and out.checks == [] + assert (await drill_of(rep, qid, sid)).status == "inferred" + + +# -------------------------------------------------------------------------- +# R14 之后:异常映射、R15、R16、R17 +# -------------------------------------------------------------------------- + +_FAILURES = { + "chain_mismatch": lambda: SnapshotManifestMismatch("清单不一致"), + "db_tampered": lambda: SnapshotTampered("文件被改"), + "snapshot_gone": lambda: SnapshotMissing("文件不在"), +} + + +def _raise_at(m: pytest.MonkeyPatch, stage: str, exc: BaseException) -> None: + async def boom(*_a, **_kw): + raise exc + + def boom_sync(*_a, **_kw): + raise exc + + if stage == "view_of": + m.setattr(api.provenance_db, "view_of", boom_sync) + elif stage == "engine": + m.setattr(engines, "get", boom) + else: + m.setattr(api.provenance_db, stage, boom) + + +@pytest.mark.parametrize("stage", ["view_of", "engine", "compile_facts", "recheck", "row_status"]) +async def test_snapshot_errors_after_r14_map_in_order(rep, client, monkeypatch, stage): + """绑定、取引擎、编译核对、回查、行级核对,任何一步抛三类数据文件异常都按 2.3 的顺序映射:不给格子,已算出的 + 行级状态一律丢掉,数据版本照给。SnapshotManifestMismatch 同时是另外两类,必须先认成 chain_mismatch。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + clean = await drill_of(rep, qid, sid) + assert clean.status == "inferred" and any(c.row_status == "passed" for c in clean.checks) + for code, make in _FAILURES.items(): + with monkeypatch.context() as m: + _raise_at(m, stage, make()) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code) == ("table_only", code), (stage, code) + assert (out.alert.code if out.alert else None) == (None if code == "snapshot_gone" else code) + 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 test_other_errors_are_not_swallowed(rep, client, monkeypatch): + """不是那三类的异常不当成「推断不出来」吞掉(会是 500,测试和日志看得见)。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + + async def boom(*_a, **_kw): + raise KeyError("意料之外") + + monkeypatch.setattr(api.provenance_db, "recheck", boom) + with pytest.raises(KeyError): + await drill_of(rep, qid, sid) + + +@pytest.mark.parametrize("sql,detail", [ + # 实测 B7:藏在 ESCAPE '\' 后面的 UNION ALL(ResultRow 有 2 个) + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" LIKE \'2026-08-0%\' ESCAPE \'\\\' ' + 'UNION ALL SELECT "日期", "分区甲" FROM "日客流" WHERE "日期" = \'2026-08-31\'', "compound"), + # UNION(有 Yield)、INTERSECT(ResultRow 只有 1 个,靠 Yield 认出) + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-05\' ' + 'UNION SELECT "日期", "分区甲" FROM "日客流" WHERE "日期" = \'2026-08-31\'', "compound"), + ('SELECT "日期", "全日客流" FROM "日客流" INTERSECT SELECT "日期", "全日客流" FROM "日客流"', "compound"), + # 子查询读了另一张表:授权回调报出来 + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" IN (SELECT "日期" FROM "时段客流")', "authorizer"), +]) +async def test_r15_compile_facts_catch_what_the_recognizer_let_through(rep, client, monkeypatch, sql, detail): + """R15 是与解析无关的兜底:用桩让识别器放行,编译核对照样拦下(读到的表、ResultRow 恰好 1 个、没有 Yield)。""" + qid, sid = await run_sql(client, rep, WIDE_SQL) + monkeypatch.setattr(api.direct_select, "recognize", + lambda _sql, **kw: DirectSelect("日客流", None, ["日期", "全日客流"])) + out = await drill_of(rep, await forged_query(qid, sql=sql), sid) + assert (out.status, out.reason.code, out.reason.detail, out.alert) == ("table_only", "multi_table", detail, None) + + +async def test_r15_uncompilable_sql_is_unparsed(rep, client, monkeypatch): + qid, sid = await run_sql(client, rep, WIDE_SQL) + monkeypatch.setattr(api.direct_select, "recognize", + lambda _sql, **kw: DirectSelect("日客流", None, ["日期", "全日客流"])) + bad = await forged_query(qid, sql='SELECT "日期", "全日客流" FROM "日客流" WHERE "没有这一列" = 1') + out = await drill_of(rep, bad, sid) + assert (out.status, out.reason.code, out.alert) == ("table_only", "unparsed", None) + + +async def test_r16_recheck(rep, client, monkeypatch): + qid, sid = await run_sql(client, rep, WIDE_SQL) + value = artifact_store.load(qid)["rows"][0][1] + rehashed: list[bool] = [] + real_rehash, real_recheck = api.provenance_db.rehash, api.provenance_db.recheck + + async def spy_rehash(view): + rehashed.append(await real_rehash(view)) + return rehashed[-1] + + monkeypatch.setattr(api.provenance_db, "rehash", spy_rehash) + assert (await drill_of(rep, qid, sid)).status == "inferred" and rehashed == [] # 一致时不重算 + + def answering(rows): + async def fake(view, **kw): + _, sql, params = await real_recheck(view, **kw) + return rows, sql, params + return fake + + with monkeypatch.context() as m: + m.setattr(api.provenance_db, "recheck", answering([])) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.reason.detail) == ("table_only", "recheck_missing", "") + assert out.alert is None + m.setattr(api.provenance_db, "recheck", answering([(5, value), (6, value)])) + assert (await drill_of(rep, qid, sid)).reason.code == "recheck_multiple" + # 类型严格相等:快照里记成 8754.0(float),回查得到 8754(int)不算相等。文件没变:不标红 + as_float = await forged_query(qid, rows=[["2026-08-05", float(value)]]) + out = await drill_of(rep, as_float, sid) + assert (out.status, out.reason.code, out.alert, rehashed) == ("table_only", "recheck_mismatch", None, [True]) + # 文件原地改过一个字节、再还原修改时间(指纹不变,EngineCache 认不出):值对不上时强制重算哈希 → 标红 + path = await _db_path(rep) + _poke(path) + with monkeypatch.context() as m: + m.setattr(api.provenance_db, "recheck", answering([(5, value + 1)])) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.alert.code) == ("table_only", "db_tampered", "db_tampered") + assert rehashed == [True, False] + + +async def test_r16_recheck_errors(rep, client, monkeypatch): + """回查本身出错:SQLite 报错只给表级来历(recheck_missing,不带契约外的细分、不标红);连接池事件里抛出、被 + SQLAlchemy 包成 StatementError 的数据文件异常照原样映射(借连接时指纹不符 → db_tampered,标红)。""" + from sqlalchemy.exc import StatementError + + qid, sid = await run_sql(client, rep, WIDE_SQL) + + def raising(exc: BaseException): + async def fake(view, **kw): + raise exc + return fake + + with monkeypatch.context() as m: + m.setattr(api.provenance_db, "recheck", raising(sqlite3.OperationalError("no such column"))) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.reason.detail, out.alert) == ("table_only", "recheck_missing", "", None) + assert out.cell_source is None and out.checks == [] and out.version is not None + wrapped = StatementError("借连接时核对失败", "SELECT 1", None, SnapshotTampered("指纹不符")) + with monkeypatch.context() as m: + m.setattr(api.provenance_db, "recheck", raising(wrapped)) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.reason.detail) == ("table_only", "db_tampered", "") + assert out.alert == Alert(code="db_tampered", text=out.reason.text) and out.cell_source is None + + +async def _db_path(up: Upload) -> str: + from app.db.models import SourceSnapshot + + async with SessionLocal() as session: + return (await session.get(SourceSnapshot, up.snapshot)).db_path + + +def _poke(path: str) -> None: + """原地改一个字节(大小不变),再用 os.utime 还原修改时间:EngineCache 的指纹认不出这种改动。""" + st = os.stat(path) + os.chmod(path, 0o644) + with open(path, "r+b") as f: + f.seek(-1, os.SEEK_END) + last = f.read(1) + f.seek(-1, os.SEEK_END) + f.write(bytes([last[0] ^ 0x01])) + os.chmod(path, 0o444) + os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns)) + assert os.stat(path).st_mtime_ns == st.st_mtime_ns + + +@pytest.mark.parametrize("problem,code,red", [ + (LocateProblem("no_lineage", "anchor_block"), "no_lineage", False), + (LocateProblem("chain_mismatch", "rowid_out_of_parts"), "chain_mismatch", True), +]) +async def test_r17_locate_problems(rep, client, monkeypatch, problem, code, red): + qid, sid = await run_sql(client, rep, WIDE_SQL) + monkeypatch.setattr(api.prov, "locate", lambda *a, **kw: problem) + out = await drill_of(rep, qid, sid) + assert (out.status, out.reason.code, out.reason.detail) == ("table_only", code, problem.detail) + assert (out.alert is not None) == red and out.cell_source is None and out.version is not None + + +# -------------------------------------------------------------------------- +# 相关核对、累积并集、当前状态 +# -------------------------------------------------------------------------- + + +async def test_row_status_and_acceptance_on_an_accepted_period(upload_world, client): + """每期替换:先 D00,再 D26 并写理由接受 R1。不成立的那一天这一行判 mismatch,其余 passed;接受理由挂在 R1 上, + 数据版本只有 D26 这一期(seq 2),它的接受理由列在那一期下面。""" + up = await add_period(client, await import_flow(client, seed=4102), seed=4103, variant="D26", + acceptances=[{"check_id": "R1", "reason": REASON}]) + try: + qid, sid = await run_sql(client, up, 'SELECT "日期", "全日客流", "分区甲", "分区乙" FROM "日客流"') + rows = artifact_store.load(qid)["rows"] + bad = [i for i, r in enumerate(rows) if None not in r[1:] and r[1] != r[2] + r[3]] + assert len(bad) == 1 + good = next(i for i, r in enumerate(rows) if None not in r[1:] and r[1] == r[2] + r[3]) + outs = {} + for i in (bad[0], good): + out = await drill_of(up, qid, sid, row=i) + assert out.status == "inferred", out.reason + outs[i] = out + for i, row_status in ((bad[0], "mismatch"), (good, "passed")): + r1 = next(c for c in outs[i].checks if c.id == "R1") + assert (r1.part_status, r1.row_status) == ("mismatch", row_status) + assert (r1.acceptance.kind, r1.acceptance.reason, r1.acceptance.signed_by) == ("override", REASON, None) + [part] = outs[i].version.parts + assert (part.seq, part.import_id, part.has_row) == (2, up.imports[1], True) + assert [a.check_id for a in part.acceptances] == ["R1"] and part.acceptances[0].reason == REASON + assert outs[i].cell_source.part_seq == 2 + finally: + await engines.invalidate(up.source_id) + + +async def test_union_cell_and_row_status_on_union_rowid(upload_world, client, monkeypatch): + """累积并集(8 月 + 9 月):9 月那一格落在第 2 期,part_rowid 按快照清单的区间换算;行级核对按**并集** rowid 跑。""" + up = await add_period(client, await import_flow(client, mode="accumulate", seed=4104), seed=4105) + try: + qid, sid = await run_sql(client, up, 'SELECT "日期", "时段", "客流" FROM "时段客流" ' + 'WHERE "日期" = \'2026-09-12\' AND "时段" = \'8-9\'') + out = await drill_of(up, qid, sid, column="客流") + assert out.status == "inferred", out.reason + cs = out.cell_source + schema = artifact_store.load(sid) + rows = artifact_store.load(schema["snapshot_manifest"])["parts"][1]["rows"]["时段客流"] + assert cs.part_seq == 2 and cs.part_rowid == cs.rowid - rows["union"][0] + 1 and cs.rowid > cs.part_rowid + assert cell_value(up.raws[1], SHEET, cs.cell) == artifact_store.load(qid)["rows"][0][2] + # 两列主键:pk 的键、回查 SQL 的 WHERE 和 params 都按冻结表结构 primary_key 的顺序(契约 Recheck) + assert list(cs.pk) == schema["tables"]["时段客流"]["primary_key"] == ["日期", "时段"] + assert cs.recheck == Recheck(sql='SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ? AND "时段" = ?', + params=["2026-09-12", "8-9"], ok=True) + v = out.version + assert (v.mode, v.union) == ("accumulate", True) + assert [(p.seq, p.has_row) for p in v.parts] == [(1, False), (2, True)] + assert [p.import_id for p in v.parts] == up.imports + + seen: list[int] = [] + real = api.provenance_db.row_status + + async def spy(view, rule, snapshot_rowid): + seen.append(snapshot_rowid) + return await real(view, rule, snapshot_rowid) + + monkeypatch.setattr(api.provenance_db, "row_status", spy) + qid, sid = await run_sql(client, up, 'SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-09-12\'') + out = await drill_of(up, qid, sid) + assert out.status == "inferred" and out.cell_source.part_seq == 2 + assert seen == [out.cell_source.rowid] and out.cell_source.rowid != out.cell_source.part_rowid + assert next(c for c in out.checks if c.id == "R1").row_status == "passed" + finally: + await engines.invalidate(up.source_id) + + +async def test_recheck_follows_the_primary_key_order_not_the_select_order(rep, client): + """选取项的顺序和主键相反(「时段」在「日期」前面):pk 的键、回查的 WHERE 和 params 仍按冻结表结构 + primary_key 的顺序,面板「技术细节」显示的回查 SQL 和表结构一致。""" + qid, sid = await run_sql(client, rep, 'SELECT "客流", "时段", "日期" FROM "时段客流" ' + 'WHERE "日期" = \'2026-08-05\' AND "时段" = \'13-14\'') + out = await drill_of(rep, qid, sid, column="客流") + assert out.status == "inferred", out.reason + cs = out.cell_source + assert list(cs.pk) == artifact_store.load(sid)["tables"]["时段客流"]["primary_key"] == ["日期", "时段"] + assert cs.pk == {"日期": "2026-08-05", "时段": "13-14"} + assert cs.recheck == Recheck(sql='SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ? AND "时段" = ?', + params=["2026-08-05", "13-14"], ok=True) + + +async def test_current_state_from_the_import_record(rep, client): + """原件已清除、接受已作废是导入记录上的当前状态:数据版本照实写(StateNote),格子照样给,标原件已清除。""" + from app.db.models import TableImport + + qid, sid = await run_sql(client, rep, WIDE_SQL) + purged = {"at": "2026-09-30T08:00:00+00:00", "reason": "合成理由:到期清除", "signed_by": "录入员甲", + "signed_by_verified": False} + revoked = {"at": "2026-09-30T09:00:00+00:00", "reason": "合成理由:接受有误", "signed_by": None} + await edit_row(TableImport, rep.imports[0], raw_state="purged", purged=purged, revoked=revoked) + out = await drill_of(rep, qid, sid) + assert out.status == "inferred" and out.cell_source.raw_purged is True and out.cell_source.cell == "G5" + part = out.version.parts[0] + assert part.raw_state == "purged" + assert part.purged == StateNote(at=purged["at"], signed_by="录入员甲", reason=purged["reason"]) + assert part.revoked == StateNote(at=revoked["at"], signed_by=None, reason=revoked["reason"]) + + +# -------------------------------------------------------------------------- +# 接口:上传源的单元格(真 runner) +# -------------------------------------------------------------------------- + + +async def test_route_on_an_upload_cell_returns_200(upload_world, client, engine_up, monkeypatch): + """配方源单元格:查询步骤带 provenance: true,推断来源接口回 200、inferred,契约不变式全过。""" + from app.providers import mock_model + + up = await import_flow(client, seed=4106) + text = "2026-08-05 的全日客流为 [[v:Q1.r0.全日客流]] 人次。" + monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text)) + g = chain(node("start", "input"), node("fetch", "tool", tool=f"db_query__{up.name}", args={"sql": WIDE_SQL}), + node("write", "report", instructions="写日报", on_violation="flag"), + node("out", "output", fields=[{"name": "日报", "value": "{{ nodes.write.text }}"}])) + run_id = (await run_manager.start(graph=g, input_payload={})).id + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "succeeded", row.error + try: + doc = load_doc(row) + cell = seg_of(doc, "cell") + step = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}")).json()["chain"][0] + assert step["step"] == "query" and step["provenance"] is True + resp = await client.get(f"/api/runs/{row.id}/evidence/segments/{cell['id']}/provenance") + assert resp.status_code == 200, resp.text + out = ok(from_json(resp.json())) + assert (out.status, out.sealed) == ("inferred", True) + assert (out.cell_source.sheet, out.cell_source.cell) == (SHEET, "G5") + assert out.version.snapshot_id == up.snapshot and out.version.parts[0].has_row is True + assert out.report == ReportRef(node_id="write", doc_artifact=doc_id(row)) + finally: + await engines.invalidate(up.source_id) diff --git a/backend/tests/test_provenance_contract.py b/backend/tests/test_provenance_contract.py new file mode 100644 index 00000000..730eae52 --- /dev/null +++ b/backend/tests/test_provenance_contract.py @@ -0,0 +1,819 @@ +"""期 4 证据下钻的契约(P4-SPEC 7.1,WP-0):provenance_types 的常量、取值、原文、响应形状和中间结构。 + +并行的几个工作包看不到彼此的代码,只靠这份契约对齐。这里钉住的是「改了就会合错」的东西:字段名、顺序、缺省值、 +asdict 之后的 JSON 键(逐层对 P4-SPEC 2.8.1)、原文覆盖每个 code、原文守文案规范。另外两份 WP-0 交付物也在这里 +做最低限度的自检:前端夹具里每一份推断来源的答复都能按契约读回、满足不变式;金样的工件 id 等于内容哈希。 +""" +from __future__ import annotations + +import ast +import dataclasses +import json +import re +import types +import typing +from dataclasses import MISSING, asdict +from pathlib import Path +from typing import Any, get_args, get_origin + +import pydantic +import pytest + +from app.data import provenance_types as P +from app.data import recipe_types as T + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "frontend" / "src" / "run" / "__tests__" / "evidence-provenance.json" +GOLDEN = Path(__file__).resolve().parent / "fixtures" / "evidence" / "pre_p4_golden.json" + +# --------------------------------------------------------------------------- +# 常量与取值 +# --------------------------------------------------------------------------- + + +def test_constants(): + assert P.DOC_PROVENANCE == 1 + assert P.SCHEMA == "agentlab.provenance/1" + assert (P.PROV_PARTS, P.PROV_ACCEPT, P.PROV_OUTSIDE, P.PROV_OUTSIDE_CHARS, P.PROV_COLUMN_NOTES, P.PROV_CHARS) == ( + 3, 4, 4, 200, 6, 1200) + + +def test_literals(): + assert get_args(P.Status) == ("inferred", "table_only", "none") + assert set(get_args(P.ReasonCode)) == { + "legacy_doc", "not_cell", "not_sealed", "not_upload", "simple_upload", "manifest_unreadable", + "chain_mismatch", "expression", "alias", "multi_table", "unparsed", "no_pk", "pk_missing", "masked", + "null_value", "null_pk", "snapshot_gone", "db_tampered", "recheck_missing", "recheck_multiple", + "recheck_mismatch", "no_lineage"} + assert len(get_args(P.ReasonCode)) == len(set(get_args(P.ReasonCode))) + assert set(get_args(P.AlertCode)) == {"db_tampered", "chain_mismatch", "manifest_unreadable"} + assert set(get_args(P.AlertCode)) <= set(get_args(P.ReasonCode)) + assert get_args(P.RefusalCode) == ("expression", "alias", "multi_table", "unparsed") + assert set(P.DETAILS) == set(get_args(P.RefusalCode)) + assert P.DETAILS["multi_table"] == ("join", "comma_join", "subquery", "cte", "compound", "authorizer", + "duplicate_pk") + assert "literal_keyword" in P.DETAILS["expression"] and "ambiguous_column" in P.DETAILS["unparsed"] + assert get_args(P.FromRole) == ("axis_header", "row_label", "section_title", "col_header", "total_label") + assert get_args(P.RowStatus) == ("passed", "mismatch", "unverifiable") + assert get_args(P.CellStatus) == ("ok", "unverifiable", "unknown", "not_formula") + assert get_args(P.RawState) == ("kept", "purged", "absent") + assert get_args(P.Mode) == ("replace", "accumulate") + assert get_args(P.AcceptanceKind) == ("override", "waiver") + assert get_args(P.YearFrom) == ("period", "human") + assert get_args(P.ColumnRole) == ("axis", "dim", "derive", "const", "measure", "value", "text") + # 和配方契约同名的取值:照搬 CheckResult.status、TableOut.kind、PeriodOut.source,不能各写一份漂开 + assert get_args(P.PartStatus) == get_args(T.CheckStatus) + assert get_args(P.TableKind) == get_args(typing.get_type_hints(T.TableOut)["kind"]) + assert get_args(typing.get_type_hints(P.PeriodView)["source"]) == get_args( + typing.get_type_hints(T.PeriodOut)["source"]) + + +def test_module_imports_only_stdlib(): + """识别器只许导入守卫、names 和这个模块(P4-SPEC 7.2):这里不能把配方模块、pydantic 拖进来。""" + tree = ast.parse(Path(P.__file__).read_text("utf-8")) + mods = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + mods |= {a.name.split(".")[0] for a in node.names} + elif isinstance(node, ast.ImportFrom): + mods.add((node.module or "").split(".")[0]) + assert mods <= {"__future__", "logging", "re", "collections", "dataclasses", "typing"}, mods + + +# --------------------------------------------------------------------------- +# 每个 dataclass 的字段名、顺序和缺省值(防止实现者改名;REQ = 必填) +# --------------------------------------------------------------------------- + +REQ = "<必填>" +FACTORY = "<默认工厂>" + + +def _shape(cls) -> list[tuple[str, object]]: + out = [] + for f in dataclasses.fields(cls): + if f.default is not MISSING: + out.append((f.name, f.default)) + elif f.default_factory is not MISSING: + out.append((f.name, FACTORY)) + else: + out.append((f.name, REQ)) + return out + + +SHAPES: dict[str, list[tuple[str, object]]] = { + # 响应(P4-SPEC 2.8.1) + "ReportRef": [("node_id", REQ), ("doc_artifact", REQ)], + "CellRef": [("alias", REQ), ("row", REQ), ("column", REQ), ("artifact", None)], + "Reason": [("code", REQ), ("detail", ""), ("text", REQ)], + "Alert": [("code", REQ), ("text", REQ)], + "TableRef": [("name", REQ), ("kind", "data")], + "PeriodView": [("start", REQ), ("end", REQ), ("source", REQ), ("cells", FACTORY), ("signed_by", None)], + "AcceptanceView": [("check_id", REQ), ("title", REQ), ("kind", REQ), ("reason", REQ), ("signed_by", None), + ("at", None)], + "StateNote": [("at", None), ("signed_by", None), ("reason", None)], + "PartView": [("import_id", REQ), ("seq", REQ), ("manifest", REQ), ("period", REQ), ("file_name", REQ), + ("raw_sha256", REQ), ("region", None), ("excluded_rows", None), ("recipe_sha256", None), + ("recipe_seq", None), ("committed_at", None), ("signed_by", None), ("acceptances", FACTORY), + ("raw_state", None), ("purged", None), ("revoked", None), ("has_row", False)], + "VersionView": [("source", REQ), ("snapshot_id", REQ), ("mode", REQ), ("union", REQ), ("tables", FACTORY), + ("manifest_view", True), ("parts", FACTORY)], + "FromCell": [("role", REQ), ("column", REQ), ("sheet", REQ), ("cell", REQ), ("text", None), + ("locate_title", None)], + "YearSource": [("source", REQ), ("cells", FACTORY), ("signed_by", None), ("mixed", False)], + "CanonicalView": [("raw", REQ), ("canonical", REQ)], + "Recheck": [("sql", REQ), ("params", FACTORY), ("ok", REQ)], + "CellSource": [("table", REQ), ("column", REQ), ("column_role", REQ), ("kind", "data"), ("pk", FACTORY), + ("rowid", None), ("part_seq", REQ), ("part_rowid", REQ), ("sheet", REQ), ("cell", REQ), + ("header", None), ("unit", None), ("from", FACTORY), ("year", None), ("canonical", None), + ("raw_purged", False), ("merged_fill", False), ("recheck", None)], + "RelatedCheck": [("id", REQ), ("kind", REQ), ("title", REQ), ("part_status", REQ), ("row_status", None), + ("cell_status", None), ("acceptance", None), ("detail", None)], + "ProvenanceOut": [("schema", P.SCHEMA), ("report", REQ), ("segment", REQ), ("cell", None), ("status", REQ), + ("reason", None), ("alert", None), ("sealed", REQ), ("version", None), + ("cell_source", None), ("checks", FACTORY)], + # 中间结构(P4-SPEC 7.2–7.4) + "DirectSelect": [("table", REQ), ("alias", REQ), ("columns", REQ)], + "SelectRefusal": [("code", REQ), ("detail", REQ)], + "ChainPart": [("seq", REQ), ("import_id", REQ), ("manifest_id", REQ), ("manifest", REQ), ("union_rows", REQ)], + "Chain": [("source", REQ), ("source_id", REQ), ("snapshot_id", REQ), ("mode", REQ), ("union", REQ), + ("expected_db_sha256", REQ), ("parts", REQ), ("schema", REQ), ("snapshot_manifest", REQ)], + "ChainProblem": [("code", REQ), ("detail", "")], + "Located": [("part", REQ), ("part_rowid", REQ), ("cell", REQ)], + "LocateProblem": [("code", REQ), ("detail", "")], + "SumEqRule": [("table", REQ), ("total", REQ), ("parts", REQ), ("types", REQ)], + "RelatedCheckPlan": [("check", REQ), ("rule", REQ)], + "CompileFacts": [("tables", REQ), ("result_rows", REQ), ("yields", REQ)], +} +RESPONSE = ("ReportRef", "CellRef", "Reason", "Alert", "TableRef", "PeriodView", "AcceptanceView", "StateNote", + "PartView", "VersionView", "FromCell", "YearSource", "CanonicalView", "Recheck", "CellSource", + "RelatedCheck", "ProvenanceOut") + + +@pytest.mark.parametrize("name", sorted(SHAPES)) +def test_dataclass_shape(name): + cls = getattr(P, name) + assert dataclasses.is_dataclass(cls) + assert _shape(cls) == SHAPES[name] + + +def test_every_dataclass_is_pinned(): + declared = {n for n, v in vars(P).items() if isinstance(v, type) and dataclasses.is_dataclass(v)} + assert declared == set(SHAPES), declared ^ set(SHAPES) + + +@pytest.mark.parametrize("name", RESPONSE) +def test_response_types_are_keyword_only(name): + """响应的字段多、几个包各自构造:按位置传参会被拒,传错名字立刻报错。""" + cls = getattr(P, name) + assert all(f.kw_only for f in dataclasses.fields(cls)), name + with pytest.raises(TypeError): + cls("x") + + +def test_intermediate_types_take_positional_args_as_the_spec_writes_them(): + assert P.ChainProblem("manifest_unreadable").detail == "" + assert P.LocateProblem("no_lineage") == P.LocateProblem(code="no_lineage", detail="") + assert P.SelectRefusal("alias", "alias").code == "alias" + assert P.CompileFacts({"日客流"}, 1, 0).yields == 0 + assert P.DirectSelect("日客流", None, ["日期", "全日客流"]).columns == ["日期", "全日客流"] + + +# --------------------------------------------------------------------------- +# asdict 之后逐层对 P4-SPEC 2.8.1 的 JSON +# --------------------------------------------------------------------------- + +KEYS = { + "top": ["schema", "report", "segment", "cell", "status", "reason", "alert", "sealed", "version", "cell_source", + "checks"], + "report": ["node_id", "doc_artifact"], + "cell": ["alias", "row", "column", "artifact"], + "reason": ["code", "detail", "text"], + "alert": ["code", "text"], + "version": ["source", "snapshot_id", "mode", "union", "tables", "manifest_view", "parts"], + "table": ["name", "kind"], + "part": ["import_id", "seq", "manifest", "period", "file_name", "raw_sha256", "region", "excluded_rows", + "recipe_sha256", "recipe_seq", "committed_at", "signed_by", "acceptances", "raw_state", "purged", + "revoked", "has_row"], + "period": ["start", "end", "source", "cells", "signed_by"], + "acceptance": ["check_id", "title", "kind", "reason", "signed_by", "at"], + "state": ["at", "signed_by", "reason"], + "cell_source": ["table", "column", "column_role", "kind", "pk", "rowid", "part_seq", "part_rowid", "sheet", + "cell", "header", "unit", "from", "year", "canonical", "raw_purged", "merged_fill", "recheck"], + "from": ["role", "column", "sheet", "cell", "text", "locate_title"], + "year": ["source", "cells", "signed_by", "mixed"], + "canonical": ["raw", "canonical"], + "recheck": ["sql", "params", "ok"], + "check": ["id", "kind", "title", "part_status", "row_status", "cell_status", "acceptance", "detail"], +} + + +def _accept(**kw) -> P.AcceptanceView: + return P.AcceptanceView(**{"check_id": "R1", "title": "「日客流」:全日客流 = 分区甲 + 分区乙", "kind": "override", + "reason": "合成理由:分区合计口径调整", "signed_by": None, + "at": "2026-10-01T08:00:00+00:00", **kw}) + + +def full_out() -> P.ProvenanceOut: + """字段齐全的一份(每个可空的嵌套对象都非空、每个列表至少一项):空的 checks 会让键集合的比较空过。""" + return P.ProvenanceOut( + report=P.ReportRef(node_id="write", doc_artifact="d" * 64), + segment="s7", + cell=P.CellRef(alias="Q3", row=0, column="全日客流", artifact="a" * 64), + status="inferred", + sealed=True, + version=P.VersionView( + source="flow_demo", snapshot_id="b" * 64, mode="accumulate", union=True, + tables=[P.TableRef(name="日客流", kind="data")], + manifest_view=True, + parts=[P.PartView( + import_id="i1", seq=1, manifest="c" * 64, + period=P.PeriodView(start="2026-08-01", end="2026-08-31", source="cells", cells=["客流汇总!B2"]), + file_name="月报导出_2026-08-01_2026-08-31.xlsx", raw_sha256="e" * 64, + region="客流汇总!B4:AG30", excluded_rows=0, recipe_sha256="f" * 64, recipe_seq=1, + committed_at="2026-10-01T08:00:00+00:00", signed_by=None, + acceptances=[_accept()], raw_state="purged", + purged=P.StateNote(at="2026-10-02T08:00:00+00:00", signed_by="录入员甲", reason="合成理由"), + revoked=P.StateNote(at="2026-10-02T09:00:00+00:00", signed_by=None, reason="合成理由"), + has_row=True)]), + cell_source=P.CellSource( + table="日客流", column="全日客流", column_role="measure", kind="data", pk={"日期": "2026-08-05"}, + rowid=5, part_seq=1, part_rowid=5, sheet="客流汇总", cell="G5", header="全日客流(人次)", unit="人次", + from_=[P.FromCell(role="axis_header", column="日期", sheet="客流汇总", cell="G4"), + P.FromCell(role="row_label", column=None, sheet="客流汇总", cell="B5", text="全日客流(人次)")], + year=P.YearSource(source="period", cells=["客流汇总!B2"]), + canonical=P.CanonicalView(raw="8–9", canonical="8-9"), + raw_purged=True, merged_fill=False, + recheck=P.Recheck(sql='SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?', params=["2026-08-05"], + ok=True)), + checks=[P.RelatedCheck(id="R1", kind="relation_sum_eq", title="「日客流」:全日客流 = 分区甲 + 分区乙", + part_status="mismatch", row_status="passed", cell_status=None, + acceptance=_accept(), detail="合成的原因摘要")], + ) + + +def test_asdict_matches_the_spec_json_level_by_level(): + d = asdict(full_out()) + v, part, cs = d["version"], d["version"]["parts"][0], d["cell_source"] + levels = { + "top": d, "report": d["report"], "cell": d["cell"], "version": v, "table": v["tables"][0], "part": part, + "period": part["period"], "acceptance": part["acceptances"][0], "state": part["purged"], + "cell_source": cs, "from": cs["from"][0], "year": cs["year"], "canonical": cs["canonical"], + "recheck": cs["recheck"], "check": d["checks"][0], + } + for name, obj in levels.items(): + assert isinstance(obj, dict), name + assert list(obj) == KEYS[name], name + assert list(part["revoked"]) == KEYS["state"] + assert list(d["checks"][0]["acceptance"]) == KEYS["acceptance"] + # 原因、提示:table_only 加红的一份 + red = P.ProvenanceOut(report=P.ReportRef(node_id="write", doc_artifact="d" * 64), segment="s9", + status="table_only", reason=P.refuse("db_tampered"), alert=P.alert("db_tampered"), + sealed=True, version=full_out().version) + rd = asdict(red) + assert list(rd["reason"]) == KEYS["reason"] and list(rd["alert"]) == KEYS["alert"] + assert rd["cell_source"] is None and rd["checks"] == [] and rd["cell"] is None + json.dumps(d, ensure_ascii=False) + + +def test_null_and_empty_defaults_match_the_spec(): + out = P.ProvenanceOut(report=P.ReportRef(node_id="write", doc_artifact="d"), segment="s1", status="none", + reason=P.refuse("legacy_doc"), sealed=False) + d = asdict(out) + assert d["schema"] == "agentlab.provenance/1" + assert (d["cell"], d["alert"], d["version"], d["cell_source"], d["checks"]) == (None, None, None, None, []) + assert d["reason"] == {"code": "legacy_doc", "detail": "", "text": P.REASON_TEXT["legacy_doc"]} + + +# --------------------------------------------------------------------------- +# CellSource 的「from」:Python 里是 from_,asdict / fields / replace / JSON 里是 from +# --------------------------------------------------------------------------- + + +def _cell(**kw) -> P.CellSource: + base = {"table": "日客流", "column": "全日客流", "column_role": "measure", "part_seq": 1, "part_rowid": 5, + "sheet": "客流汇总", "cell": "G5"} + return P.CellSource(**{**base, **kw}) + + +def test_cell_source_from_key(): + fc = P.FromCell(role="axis_header", column="日期", sheet="客流汇总", cell="G4") + a = _cell(from_=[fc]) + assert a.from_ == [fc] and getattr(a, "from") == [fc] + assert [f.name for f in dataclasses.fields(P.CellSource)].count("from") == 1 + assert "from_" not in {f.name for f in dataclasses.fields(P.CellSource)} + d = asdict(a) + assert "from" in d and "from_" not in d and d["from"][0]["cell"] == "G4" + # 从 JSON 读回来:键是 from + b = P.CellSource(**{**d, "from": [P.FromCell(**x) for x in d["from"]]}) + assert b == a + # replace 不丢 from(WP-C 回查后用 replace 补 rowid、pk、recheck 是常见写法) + c = dataclasses.replace(a, rowid=5, pk={"日期": "2026-08-05"}) + assert c.from_ == [fc] and c.rowid == 5 + # replace 换 from:两种名字都认,Python 名字优先 + fc2 = P.FromCell(role="row_label", column=None, sheet="客流汇总", cell="B5") + assert dataclasses.replace(a, from_=[fc2]).from_ == [fc2] + assert dataclasses.replace(a, **{"from": [fc2]}).from_ == [fc2] + # 属性写入两种名字指同一份 + setattr(a, "from", []) + assert a.from_ == [] and asdict(a)["from"] == [] + assert _cell().from_ == [] and _cell().from_ is not _cell().from_ + assert "from_=" in repr(_cell()) + # 注解里同样是 from(pydantic 按注解收集字段);值存在实例字典的 from 键下 + hints = typing.get_type_hints(P.CellSource) + assert "from" in hints and "from_" not in hints + assert list(hints) == [f.name for f in dataclasses.fields(P.CellSource)] + assert "from" in vars(a) and "from_" not in vars(a) + assert not hasattr(object.__new__(P.CellSource), "from_") + + +def test_pydantic_sees_the_same_json_as_asdict(): + """WP-C 若把 ProvenanceOut 写成返回注解或 response_model,pydantic 会按类型再序列化一遍:输出必须等于 asdict, + 不能悄悄少掉「from」(只改字段表、不改注解时就是这样)。从 JSON 校验回来的实例也要能照常读写 from_。""" + ta = pydantic.TypeAdapter(P.ProvenanceOut) + out = full_out() + want = asdict(out) + assert ta.dump_python(out) == want and "from" in ta.dump_python(out)["cell_source"] + assert json.loads(ta.dump_json(out)) == json.loads(json.dumps(want)) + back = ta.validate_json(json.dumps(want), strict=True) + assert back == out and back.cell_source.from_ == out.cell_source.from_ + assert asdict(back) == want and dataclasses.replace(back.cell_source, rowid=9).from_ == out.cell_source.from_ + cs_ta = pydantic.TypeAdapter(P.CellSource) + assert cs_ta.dump_python(out.cell_source) == asdict(out.cell_source) + + +async def test_fastapi_routes_return_the_same_json_however_typed(): + """三种写法的路由(推荐的 asdict + dict 注解、带类型的返回注解、response_model)回的 JSON 逐字相同。""" + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + + app = FastAPI() + + @app.get("/plain") + async def plain() -> dict[str, Any]: + return asdict(full_out()) + + @app.get("/typed") + async def typed() -> P.ProvenanceOut: + return full_out() + + @app.get("/model", response_model=P.ProvenanceOut) + async def model() -> Any: + return asdict(full_out()) + + want = json.loads(json.dumps(asdict(full_out()))) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client: + for path in ("/plain", "/typed", "/model"): + r = await client.get(path) + assert r.status_code == 200, path + assert r.json() == want, path + assert "from" in r.json()["cell_source"], path + assert (await client.get("/openapi.json")).status_code == 200 + + +# --------------------------------------------------------------------------- +# 原文 +# --------------------------------------------------------------------------- + + +def test_reason_text_covers_every_code(): + assert set(P.REASON_TEXT) == set(get_args(P.ReasonCode)) + assert set(P.ALERT_TEXT) == set(get_args(P.AlertCode)) + for code, text in P.ALERT_TEXT.items(): + assert text == P.REASON_TEXT[code] + # 4.2 的原文(WP-0 写定,界面原样显示):抽几条与规格逐字对照 + assert P.REASON_TEXT["simple_upload"] == "这份表格按表头行直接导入,没有记录原表格子的溯源" + assert P.REASON_TEXT["masked"] == "这一格涉及设置了遮罩的列,不推断来源" + assert P.REASON_TEXT["null_value"] == "这一格是空值,不推断来源" + assert P.REASON_TEXT["expression"] == "这一格是计算、聚合或改写的结果,只给出表级来历" + assert "可能被修改过" in P.REASON_TEXT["db_tampered"] and "可能被修改过" in P.REASON_TEXT["chain_mismatch"] + assert "改了名" in P.REASON_TEXT["alias"] and "同一张表多次" in P.REASON_TEXT["multi_table"] + assert "已回收或不存在" in P.REASON_TEXT["snapshot_gone"] + assert P.REASON_TEXT["recheck_missing"] == P.REASON_TEXT["recheck_multiple"] == P.REASON_TEXT["recheck_mismatch"] + + +_H = "[㐀-鿿]" +_HALF = re.compile(f"{_H}[,;!?]|[,;!?]{_H}|{_H}:(?![/\\d])|:{_H}|{_H}\\(|\\){_H}|{_H}\"|\"{_H}|[“”]|\\.\\.\\.") +_BANNED = re.compile("钉|放行|后端|跑|多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了|快照版本") + + +@pytest.mark.parametrize("code", sorted(P.REASON_TEXT)) +def test_reason_text_follows_the_copy_rules(code): + """原文上界面:不含数字、坐标(prose_problems),不写「钉」「放行」等禁用写法,全角标点、「」引号、不用「...」, + 不以句号收尾(界面上是一行说明,和其余面板文字一致)。""" + text = P.REASON_TEXT[code] + assert T.prose_problems(text) == [], (code, T.prose_problems(text)) + assert not _BANNED.search(text), code + assert not _HALF.search(text), (code, _HALF.search(text)) + assert not text.endswith("。"), code + assert re.search(_H, text) + + +def test_refuse_and_alert(): + r = P.refuse("pk_missing", 列=["日期", "时段"]) + assert r == P.Reason(code="pk_missing", detail="", + text="查询结果没有带齐主键列(日期、时段),无法按主键回查,只给出表级来历") + assert "(日期)" in P.refuse("pk_missing", 列="日期").text + assert P.refuse("multi_table", "duplicate_pk").detail == "duplicate_pk" + assert P.refuse("multi_table", detail="compound").text == P.REASON_TEXT["multi_table"] + assert P.refuse("no_lineage", "regions 没有 block 键").detail == "regions 没有 block 键" + with pytest.raises(ValueError): + P.refuse("pk_missing") + with pytest.raises(ValueError): + P.refuse("no_such_code") # type: ignore[arg-type] + assert P.alert("chain_mismatch") == P.Alert(code="chain_mismatch", text=P.ALERT_TEXT["chain_mismatch"]) + with pytest.raises(ValueError): + P.alert("no_lineage") # type: ignore[arg-type] + + +@pytest.mark.parametrize("code,detail", [ + # 原型识别器自己的名字(p4-probe/probe_recognizer.py),WP-A 照抄就会走到这里;还有 2.4 没逐条写的写法 + ("unparsed", "indexed_by"), ("multi_table", "union"), ("expression", "aggregate"), + ("alias", "expression_or_alias"), ("multi_table", "subquery_or_compound"), ("alias", "join"), +]) +def test_refuse_with_an_unknown_detail_degrades_instead_of_raising(code, detail, caplog): + """细分取决于识别器对这条 SQL 的判断:漏了映射时推断来源接口不能回 500。照样给分组的原文、记警告, + contract_problems 报出来(接口和验收测试据此抓到)。""" + with caplog.at_level("WARNING", logger=P.__name__): + r = P.refuse(code, detail) + assert r == P.Reason(code=code, detail=detail, text=P.REASON_TEXT[code]) + assert detail in caplog.text + out = P.ProvenanceOut(report=P.ReportRef(node_id="write", doc_artifact="d"), segment="s1", status="table_only", + reason=r, sealed=True, version=_no_row_version()) + assert any("DETAILS" in p for p in P.contract_problems(out)) + + +def test_every_listed_detail_passes_and_empty_detail_is_allowed(caplog): + v = _no_row_version() + ref = P.ReportRef(node_id="write", doc_artifact="d") + with caplog.at_level("WARNING", logger=P.__name__): + for code, details in P.DETAILS.items(): + for detail in (*details, ""): + out = P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse(code, detail), + sealed=True, version=v) + assert P.contract_problems(out) == [], (code, detail) + assert caplog.text == "" + # 只有四个判据分组受 DETAILS 约束;其余 code 的细分是自由文字(ChainProblem / LocateProblem 的 detail) + assert P.contract_problems(P.ProvenanceOut(report=ref, segment="s1", status="table_only", + reason=P.refuse("no_lineage", "regions 没有 block 键"), sealed=True, + version=v)) == [] + + +def _no_row_version() -> P.VersionView: + v = full_out().version + for part in v.parts: + part.has_row = False + return v + + +# --------------------------------------------------------------------------- +# 不变式 +# --------------------------------------------------------------------------- + + +def test_contract_problems_accepts_the_valid_shapes(): + assert P.contract_problems(full_out()) == [] + v = full_out().version + for part in v.parts: + part.has_row = False + ref = P.ReportRef(node_id="write", doc_artifact="d") + ok = [ + P.ProvenanceOut(report=ref, segment="s1", status="none", reason=P.refuse("legacy_doc"), sealed=True), + P.ProvenanceOut(report=ref, segment="s1", status="none", reason=P.refuse("chain_mismatch"), + alert=P.alert("chain_mismatch"), sealed=True), + P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse("alias", "alias"), + sealed=False, version=v), + P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse("chain_mismatch"), + alert=P.alert("chain_mismatch"), sealed=True, version=v), + ] + for out in ok: + assert P.contract_problems(out) == [], out + + +def test_contract_problems_catches_broken_shapes(): + ref = P.ReportRef(node_id="write", doc_artifact="d") + v = full_out().version + broken = [ + dataclasses.replace(full_out(), reason=P.refuse("alias", "alias")), + dataclasses.replace(full_out(), cell_source=None), + dataclasses.replace(full_out(), cell_source=dataclasses.replace(full_out().cell_source, recheck=None)), + P.ProvenanceOut(report=ref, segment="s1", status="none", sealed=True), + P.ProvenanceOut(report=ref, segment="s1", status="none", reason=P.refuse("db_tampered"), sealed=True), + P.ProvenanceOut(report=ref, segment="s1", status="none", reason=P.refuse("legacy_doc"), sealed=True, + version=v), + P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse("no_pk"), sealed=True), + P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse("no_pk"), sealed=True, + version=v, checks=full_out().checks), + P.ProvenanceOut(report=ref, segment="s1", status="table_only", reason=P.refuse("no_pk"), + alert=P.alert("db_tampered"), sealed=True, version=v), + ] + for out in broken: + assert P.contract_problems(out), out + + +def _with_recheck(sql: str, params: list[Any], pk: dict[str, Any]) -> P.ProvenanceOut: + out = full_out() + out.cell_source.pk = pk + out.cell_source.recheck = P.Recheck(sql=sql, params=params, ok=True) + return out + + +def test_recheck_uses_question_marks_in_primary_key_order(): + """回查 SQL 的占位符是 ?,params 与 pk 按 primary_key 的顺序一一对应(Recheck 的说明;规格 R16 的 :p0 以此为准)。""" + pk = {"日期": "2026-08-20", "时段": "13-14"} + good = 'SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ? AND "时段" = ?' + assert P.contract_problems(_with_recheck(good, ["2026-08-20", "13-14"], pk)) == [] + # 列名里的问号不算占位符 + assert P.contract_problems(_with_recheck('SELECT rowid, "是否?" FROM "表""甲" WHERE "日期" = ?', + ["2026-08-05"], {"日期": "2026-08-05"})) == [] + for sql, params in [ + ('SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = :p0 AND "时段" = :p1', ["2026-08-20", "13-14"]), + (good, ["13-14", "2026-08-20"]), + (good, ["2026-08-20"]), + ('SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ?', ["2026-08-20", "13-14"]), + ]: + assert any("recheck" in p for p in P.contract_problems(_with_recheck(sql, params, pk))), (sql, params) + + +# --------------------------------------------------------------------------- +# recipe_types.OutsideText.hidden +# --------------------------------------------------------------------------- + + +def test_outside_text_hidden(): + names = [f.name for f in dataclasses.fields(T.OutsideText)] + assert names == ["sheet", "cell", "text", "kind", "period_source", "hidden"] + assert T.OutsideText("客流汇总", "客流汇总!B3", "客流汇总表", "text").hidden is None + assert asdict(T.OutsideText("客流汇总", "客流汇总!B3", "注", "text", False, True))["hidden"] is True + from app.data.recipe_imports import extraction_from_dict + + old = {"ok": True, "outside_text": [{"sheet": "客流汇总", "cell": "客流汇总!B3", "text": "客流汇总表", "kind": "text", + "period_source": False}]} + assert extraction_from_dict(old).outside_text[0].hidden is None + new = {"ok": True, "outside_text": [{**old["outside_text"][0], "hidden": False}]} + assert extraction_from_dict(new).outside_text[0].hidden is False + + +# --------------------------------------------------------------------------- +# 前端夹具:每一份推断来源的答复都按契约读得回、满足不变式 +# --------------------------------------------------------------------------- + + +def _convert(tp: Any, value: Any) -> Any: + if value is None: + return None + if isinstance(tp, type) and dataclasses.is_dataclass(tp): + return _build(tp, value) + origin = get_origin(tp) + if origin is list: + (arg,) = get_args(tp) + return [_convert(arg, x) for x in value] + if origin in (typing.Union, types.UnionType): + for arg in get_args(tp): + if isinstance(arg, type) and dataclasses.is_dataclass(arg): + return _build(arg, value) + return value + + +def _build(cls: type, data: dict[str, Any]) -> Any: + """按契约把 JSON 读回 dataclass。键必须一个不多、一个不少。""" + hints = typing.get_type_hints(cls) + names = [f.name for f in dataclasses.fields(cls)] + assert set(data) == set(names), (cls.__name__, set(data) ^ set(names)) + return cls(**{n: _convert(hints[n], data[n]) for n in names}) + + +def _fixture() -> dict[str, Any]: + return json.loads(FIXTURE.read_text("utf-8")) + + +def test_frontend_fixture_answers_follow_the_contract(): + fx = _fixture() + assert fx["provenance"], "夹具里至少有一份推断来源的答复" + statuses = set() + strict = pydantic.TypeAdapter(P.ProvenanceOut) + for sid, body in fx["provenance"].items(): + if not isinstance(body, dict) or body.get("schema") != P.SCHEMA: + continue # 模拟出错的答复({status, json})由检查脚本按原样回放 + out = _build(P.ProvenanceOut, body) + assert asdict(out) == body, sid + # 键之外再按类型严格校验一遍:取值不在 Literal 里、数写成字符串、该有的对象是 null 都会报 + assert asdict(strict.validate_json(json.dumps(body), strict=True)) == body, sid + assert P.contract_problems(out) == [], (sid, P.contract_problems(out)) + assert out.segment == sid + if out.reason is not None: + assert out.reason.text == P.refuse(out.reason.code, out.reason.detail, 列=_pk_cols(out)).text, sid + if out.alert is not None: + assert out.alert.text == P.ALERT_TEXT[out.alert.code] + statuses.add(out.status) + assert statuses == {"inferred", "table_only", "none"} + + +def _pk_cols(out: P.ProvenanceOut) -> str: + m = re.search(r"主键列(([^)]*))", out.reason.text if out.reason else "") + return m.group(1) if m else "" + + +def test_frontend_fixture_canonical_follows_the_a8_rule(): + """CanonicalView 的口径(P4-SPEC 2.5、6.2 A8、A18):这一行标签列(dim、合计项)的原文与数据库里的值不同时给出, + 被引用的是值列也给;相同、或这一行没有标签列(宽表的行标签是指标名,column 为 null)时为 null。""" + fx = _fixture() + given = set() + for sid, body in fx["provenance"].items(): + cs = body.get("cell_source") if isinstance(body, dict) else None + if not cs: + continue + if cs["column_role"] == "dim": + # 被引用的就是标签格:原文不在 from 里(from 只列其余的格),只核对规范写法那一侧 + if cs["canonical"] is not None: + assert cs["canonical"]["canonical"] == cs["pk"][cs["column"]], sid + given.add(sid) + continue + labels = [f for f in cs["from"] if f["role"] in ("row_label", "total_label") and f["column"] is not None] + assert len(labels) <= 1, sid + if labels and labels[0]["text"] is not None and labels[0]["text"] != cs["pk"][labels[0]["column"]]: + assert cs["canonical"] == {"raw": labels[0]["text"], "canonical": cs["pk"][labels[0]["column"]]}, sid + given.add(sid) + else: + assert cs["canonical"] is None, sid + # 合计表那一格(18-22 时合计)、累积并集里全角标签那一期的值格和标签格(8-9) + cases = fx["cases"] + assert {cases[k]["segment"] for k in ("total", "union", "fullwidth")} <= given, given + assert fx["provenance"][cases["total"]["segment"]]["cell_source"]["canonical"] == { + "raw": "18-22 时合计", "canonical": "18-22时合计"} + assert fx["provenance"][cases["union"]["segment"]]["cell_source"]["canonical"] == {"raw": "8-9", "canonical": "8-9"} + + +def test_frontend_fixture_cases_point_at_real_segments(): + """检查脚本按片段文字认片段:cases 里每个场景的文字在文档里恰好对应一个片段,编号与之一致。""" + fx = _fixture() + segs = [s for b in fx["doc"]["blocks"] for u in b["units"] for s in u["segments"]] + assert fx["doc"].get("provenance") == P.DOC_PROVENANCE + for key, case in fx["cases"].items(): + hits = [s for s in segs if s["text"] == case["text"] and s.get("cite")] + assert len(hits) == 1, (key, case["text"], len(hits)) + assert hits[0]["id"] == case["segment"], key + assert case["segment"] in fx["segments"], key + + +# --------------------------------------------------------------------------- +# 前端类型(frontend/src/types.ts)与契约逐个字段对齐:合并后 types.ts 归 WP-D,改名会在这里暴露 +# --------------------------------------------------------------------------- + +TYPES_TS = ROOT / "frontend" / "src" / "types.ts" +TS_INTERFACES = { + "EvidenceProvenance": "ProvenanceOut", "ProvenanceReportRef": "ReportRef", "ProvenanceCellRef": "CellRef", + "ProvenanceReason": "Reason", "ProvenanceAlert": "Alert", "ProvenanceTableRef": "TableRef", + "ProvenancePeriod": "PeriodView", "ProvenanceAcceptance": "AcceptanceView", "ProvenanceStateNote": "StateNote", + "ProvenancePart": "PartView", "ProvenanceVersion": "VersionView", "ProvenanceFromCell": "FromCell", + "ProvenanceYear": "YearSource", "ProvenanceCanonical": "CanonicalView", "ProvenanceRecheck": "Recheck", + "ProvenanceCellSource": "CellSource", "ProvenanceCheck": "RelatedCheck", +} +TS_UNIONS = { + "ProvenanceStatus": P.Status, "ProvenanceReasonCode": P.ReasonCode, "ProvenanceAlertCode": P.AlertCode, + "ProvenanceFromRole": P.FromRole, "ProvenancePartStatus": P.PartStatus, "ProvenanceRowStatus": P.RowStatus, + "ProvenanceCellStatus": P.CellStatus, +} + + +#: 有意与 Python 不同的几处(字段 → TS 里应写的联合成员): +#: - rowid、recheck 只在 inferred 时有、那时必填(contract_problems 钉住),TS 写成非空,界面不用到处判空; +#: - schema、column_role 带开放的尾巴 `(string & {})`:以后加取值时老界面照样能编译、能显示原值 +TS_OVERRIDES: dict[tuple[str, str], tuple[str, ...]] = { + ("ProvenanceCellSource", "rowid"): ("number",), + ("ProvenanceCellSource", "recheck"): ("ProvenanceRecheck",), + ("EvidenceProvenance", "schema"): (f"'{P.SCHEMA}'", "(string & {})"), + ("ProvenanceCellSource", "column_role"): (*(f"'{x}'" for x in get_args(P.ColumnRole)), "(string & {})"), +} + + +def _ts_fields(src: str, name: str) -> list[str]: + return list(_ts_field_types(src, name)) + + +def _ts_field_types(src: str, name: str) -> dict[str, str]: + """接口的字段 → 类型原文(`name: type`、`name?: type`,可选的在键名后保留问号)。""" + m = re.search(rf"^export interface {name} \{{\n(.*?)^\}}", src, re.S | re.M) + assert m, name + return {f + q: t.strip() for f, q, t in re.findall(r"^ (\w+)(\??): (.+)$", m.group(1), re.M)} + + +def _ts_aliases(src: str) -> dict[str, tuple[str, ...]]: + """TS_UNIONS 里的具名联合 → 字面量成员(带引号,同 _ts_members 的写法)。""" + out = {} + for ts in TS_UNIONS: + m = re.search(rf"^export type {ts} =((?:[^\n]*\n)(?:\s+\|[^\n]*\n)*)", src, re.M) + assert m, ts + out[ts] = tuple(f"'{x}'" for x in re.findall(r"'([^']+)'", m.group(1))) + return out + + +def _ts_union(text: str, aliases: dict[str, tuple[str, ...]]) -> tuple[str, ...]: + """TS 类型原文 → 联合成员(顶层按「|」拆,具名联合展开成字面量)。这一节的类型里没有带「|」的泛型。""" + out: list[str] = [] + for member in (m.strip() for m in text.split("|")): + out.extend(aliases.get(member, (member,))) + return tuple(out) + + +def _ts_members(tp: Any) -> tuple[str, ...]: + """Python 注解 → 应写的 TS 联合成员:str/int/bool/Any → string/number/boolean/unknown,Literal → 带引号的取值, + X | None → X 的成员加 null,list[X] → X[],dict[str, X] → Record,dataclass → 对应的 TS 接口名。""" + if tp is type(None): + return ("null",) + simple = {str: "string", bool: "boolean", int: "number", Any: "unknown"} + if tp in simple: + return (simple[tp],) + if isinstance(tp, type) and dataclasses.is_dataclass(tp): + return (TS_NAME[tp.__name__],) + origin = get_origin(tp) + if origin is typing.Literal: + return tuple(f"'{x}'" for x in get_args(tp)) + if origin in (typing.Union, types.UnionType): + return tuple(m for arg in get_args(tp) for m in _ts_members(arg)) + if origin is list: + (inner,) = (_ts_members(a) for a in get_args(tp)) + assert len(inner) == 1, tp + return (f"{inner[0]}[]",) + if origin is dict: + key, value = get_args(tp) + assert key is str, tp + return (f"Record",) + raise AssertionError(f"契约里出现了没教过的注解:{tp!r}") + + +TS_NAME = {py: ts for ts, py in TS_INTERFACES.items()} + + +def test_frontend_types_match_the_contract(): + """字段名、顺序之外,逐个字段比类型:取值联合(含行内的 'override' | 'waiver' 这种)、可空性、数组、嵌套接口。 + 合并后 types.ts 归 WP-D,这里是唯一能发现它和服务端契约漂开的地方。""" + _ts_alignment(TYPES_TS.read_text("utf-8")) + + +def _ts_alignment(src: str) -> None: + aliases = _ts_aliases(src) + for ts, lit in TS_UNIONS.items(): + assert aliases[ts] == tuple(f"'{x}'" for x in get_args(lit)), ts + for ts, py in TS_INTERFACES.items(): + cls = getattr(P, py) + got = _ts_field_types(src, ts) + names = [f.name for f in dataclasses.fields(cls)] + # 答复里每个键都在(值可以是 null),TS 一律不写成可选 + assert list(got) == names, ts + hints = typing.get_type_hints(cls) + for name in names: + want = TS_OVERRIDES.get((ts, name), _ts_members(hints[name])) + assert _ts_union(got[name], aliases) == want, (ts, name, got[name], want) + # 白名单不能烂掉:有意写成非空的两项在 Python 里确实可空;开放尾巴的两项 Python 一侧分别是 str、ColumnRole + cs_hints = typing.get_type_hints(P.CellSource) + assert _ts_members(cs_hints["rowid"]) == ("number", "null") + assert _ts_members(cs_hints["recheck"]) == ("ProvenanceRecheck", "null") + assert typing.get_type_hints(P.ProvenanceOut)["schema"] is str + assert get_args(cs_hints["column_role"]) == get_args(P.ColumnRole) + step = re.search(r"^export interface EvidenceStep .*?^\}", src, re.S | re.M).group(0) + assert re.search(r"^ provenance\?: boolean$", step, re.M) + outside = re.search(r"^export interface OutsideText \{.*?^\}", src, re.S | re.M).group(0) + assert re.search(r"^ hidden\?: boolean \| null$", outside, re.M) + + +@pytest.mark.parametrize("old,new", [ + # 评审实测过抓不到的两处,加上几种同类的改法:都必须让上面的对齐测试失败 + (" kind: 'override' | 'waiver'\n", " kind: 'overrides' | 'waivers'\n"), + (" rowid: number\n", " rowid: string\n"), + (" raw_state: 'kept' | 'purged' | 'absent' | null\n", " raw_state: 'kept' | 'purged' | null\n"), + (" mode: 'replace' | 'accumulate'\n", " mode: 'replace' | 'accumulate' | 'append'\n"), + (" source: 'period' | 'human'\n", " source: 'cells' | 'human'\n"), + (" period: ProvenancePeriod | null\n", " period: ProvenancePeriod\n"), + (" title: string | null\n kind: 'override'", " title: string\n kind: 'override'"), + (" params: unknown[]\n", " params: string\n"), + (" row_status: ProvenanceRowStatus | null\n", " row_status: ProvenanceCellStatus | null\n"), + (" column_role: 'axis' | 'dim'", " column_role: 'axis' | 'dims'"), +]) +def test_frontend_type_alignment_catches_drift(old, new): + src = TYPES_TS.read_text("utf-8") + start = src.index("// 期 4:推断的来源") + assert old in src[start:], old + with pytest.raises(AssertionError): + _ts_alignment(src[:start] + src[start:].replace(old, new, 1)) + + +# --------------------------------------------------------------------------- +# 金样:工件 id 等于内容哈希(被人改过一个字就对不上) +# --------------------------------------------------------------------------- + + +def test_golden_is_content_addressed(): + from app.core.artifact_store import canonical_json, content_hash + + g = json.loads(GOLDEN.read_text("utf-8")) + assert "2cdbf9a" in g["_comment"] and g["commit"] == "2cdbf9a" + assert g["script"].endswith("p4-golden/gen.py") + for aid, content in g["artifacts"].items(): + assert content_hash(canonical_json(content)) == aid + assert set(g["reports"]) == {"recipe", "simple", "manual"} + for rep in g["reports"].values(): + doc = rep["outputs"]["doc"] + assert content_hash(canonical_json(doc)) == rep["doc_artifact"] + assert "provenance" not in doc + assert set(rep["artifacts"]) <= set(g["artifacts"]) + assert rep["outputs"]["chain"] and rep["outputs"]["excerpts"] diff --git a/backend/tests/test_provenance_db.py b/backend/tests/test_provenance_db.py new file mode 100644 index 00000000..a1d5bd15 --- /dev/null +++ b/backend/tests/test_provenance_db.py @@ -0,0 +1,497 @@ +"""证据下钻(期 4)里碰数据库和数据文件的几步:provenance_db 的各函数,engine.py 的两处小改,sum_eq_conditions。 + +数据文件都是测试里现造的小 SQLite 库(表名「日客流」「时段客流」,分区甲、分区乙,数字随机),数据文件记录 +直接写进测试库的 source_snapshots,不经导入管线:这里要钉的是回查、编译核对、强制重算、行级核对各自的口径, +整条链(真导入、真运行)由 WP-E 的验收覆盖。 +""" +from __future__ import annotations + +import hashlib +import os +import random +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone + +import pytest + +from app.data import engine as engine_mod +from app.data import provenance_db +from app.data import recipe_checks +from app.data.engine import EngineCache, SnapshotTampered, cache_key, engines, open_checked_sqlite +from app.data.provenance_types import _QUOTED, CompileFacts, StateNote, SumEqRule +from app.data.recipe_checks import sum_eq_conditions +from app.data.recipe_types import ColumnOut, SumEq +from app.data.table_versions import SnapshotManifestMismatch, SnapshotMissing +from app.db.base import SessionLocal +from app.db.models import DataSource, SourceSnapshot, TableImport, TableRecipe + +RNG = random.Random(4004) + + +# -------------------------------------------------------------------------- +# 夹具 +# -------------------------------------------------------------------------- + + +def _sha(path) -> str: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + + +def _build(path, *, daily, hourly=(), real=()) -> str: + """造一个快照库:日客流(单列主键,写成 PRIMARY KEY DESC,和执行器一样不让它成为 rowid 的别名)、 + 时段客流(两列主键)、分区占比(REAL 列)。""" + conn = sqlite3.connect(path) + conn.executescript(""" + CREATE TABLE "日客流" ("日期" TEXT NOT NULL, "全日客流" INTEGER, "分区甲" INTEGER, "分区乙" INTEGER, + PRIMARY KEY ("日期" DESC)); + CREATE TABLE "时段客流" ("日期" TEXT NOT NULL, "时段" TEXT NOT NULL, "客流" INTEGER, + PRIMARY KEY ("日期", "时段")); + CREATE TABLE "分区占比" ("日期" TEXT NOT NULL, "合计" REAL, "分区甲" REAL, "分区乙" REAL, + PRIMARY KEY ("日期" DESC)); + """) + conn.executemany('INSERT INTO "日客流" VALUES (?, ?, ?, ?)', list(daily)) + conn.executemany('INSERT INTO "时段客流" VALUES (?, ?, ?)', list(hourly)) + conn.executemany('INSERT INTO "分区占比" VALUES (?, ?, ?, ?)', list(real)) + conn.commit() + conn.close() + os.chmod(path, 0o444) + return str(path) + + +def _daily_rows(n=6): + """前几行成立,第 3 行不成立(合计多 1),第 5 行分区乙为空。""" + rows = [] + for i in range(n): + a, b = RNG.randint(1000, 9000), RNG.randint(1000, 9000) + total = a + b + if i == 2: + total += 1 + rows.append((f"2026-08-{i + 1:02d}", total, a, None if i == 4 else b)) + return rows + + +async def _register(path, *, source_id=None, retired=False, sha=None, manifest=None) -> SourceSnapshot: + snap = SourceSnapshot( + id=uuid.uuid4().hex + uuid.uuid4().hex, source_id=source_id or uuid.uuid4().hex, + db_path=str(path), db_sha256=_sha(path) if sha is None else sha, schema_cache={}, + retired_at=datetime.now(timezone.utc) if retired else None, manifest_artifact=manifest, + ) + async with SessionLocal() as session: + session.add(snap) + await session.commit() + await session.refresh(snap) + return snap + + +@pytest.fixture +async def snapshot(tmp_path): + """一个登记好的快照库,数据源记录不存在(模拟数据源已删除)。用完清掉引擎缓存。""" + path = _build(tmp_path / "snap.db", daily=_daily_rows(), + hourly=[("2026-08-03", "7-8", None), ("2026-08-03", "8-9", 321)], + real=[("2026-08-01", 0.3, 0.1, 0.2), ("2026-08-02", 1.5, 0.5, 0.75)]) + snap = await _register(path) + yield snap + await engines.invalidate(snap.source_id) + + +async def _view(snap: SourceSnapshot): + return provenance_db.view_of(snap, source_name="flow_demo") + + +def _poke(path: str, *, keep_mtime: bool) -> None: + """原地改一个字节(大小不变)。keep_mtime 时再用 os.utime 还原修改时间:EngineCache 的指纹认不出这种改动。""" + st = os.stat(path) + os.chmod(path, 0o644) + with open(path, "r+b") as f: + f.seek(-1, os.SEEK_END) + last = f.read(1) + f.seek(-1, os.SEEK_END) + f.write(bytes([last[0] ^ 0x01])) + os.chmod(path, 0o444) + if keep_mtime: + os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns)) + + +# -------------------------------------------------------------------------- +# sum_eq_conditions:导入时的核对和行级核对共用一段条件 +# -------------------------------------------------------------------------- + + +def test_sum_eq_conditions_integer_and_real(): + nonnull, anynull, mismatch = sum_eq_conditions("全日客流", ["分区甲", "分区乙"], + {"全日客流": "INTEGER", "分区甲": "INTEGER"}) + assert nonnull == '"全日客流" IS NOT NULL AND "分区甲" IS NOT NULL AND "分区乙" IS NOT NULL' + assert anynull == '"全日客流" IS NULL OR "分区甲" IS NULL OR "分区乙" IS NULL' + assert mismatch == '"全日客流" <> "分区甲" + "分区乙"' + # 任一列是 REAL 就按相对误差比:0.1 + 0.2 不严格等于 0.3 + _, _, real = sum_eq_conditions("合计", ["分区甲", "分区乙"], {"分区乙": "REAL"}) + assert real == 'ABS("合计" - ("分区甲" + "分区乙")) > 1e-9 * MAX(1, ABS("合计"))' + # 名字里的双引号照样转义(DQS 关闭的连接上写错的名字直接报错) + assert sum_eq_conditions('a"b', ["c", "d"], {})[2] == '"a""b" <> "c" + "d"' + + +@pytest.mark.parametrize("table,total,parts,types", [ + ("日客流", "全日客流", ["分区甲", "分区乙"], {"全日客流": "INTEGER", "分区甲": "INTEGER", "分区乙": "INTEGER"}), + ("分区占比", "合计", ["分区甲", "分区乙"], {"合计": "REAL", "分区甲": "REAL", "分区乙": "REAL"}), +]) +async def test_row_status_agrees_with_the_import_check(snapshot, table, total, parts, types): + """逐行的 row_status 和导入时的 _check_sum_eq 在同一个库上的结论对得上:成立、不成立、含空值的行数都相同。 + REAL 表里 0.1 + 0.2 = 0.3 那一行两边都判成立(改成严格比较的条件会被这里抓到)。""" + view = await _view(snapshot) + rule = SumEqRule(table=table, total=total, parts=parts, types=types) + conn = open_checked_sqlite(snapshot.db_path) + try: + rowids = [r[0] for r in conn.execute(f'SELECT rowid FROM "{table}" ORDER BY rowid')] + result = recipe_checks._check_sum_eq( + recipe_checks._Sql(conn), SumEq(id="R1", kind="sum_eq", table=table, total=total, parts=parts), "核对", + [ColumnOut(name=n, type=t) for n, t in types.items()], ["日期"], {}) + finally: + conn.close() + statuses = [await provenance_db.row_status(view, rule, rid) for rid in rowids] + assert statuses.count("mismatch") == result.failed + assert statuses.count("unverifiable") == result.unverifiable + assert statuses.count("passed") + statuses.count("mismatch") == result.checked + if table == "日客流": + assert statuses == ["passed", "passed", "mismatch", "passed", "unverifiable", "passed"] + else: + assert statuses == ["passed", "mismatch"] + + +async def test_row_status_follows_the_snapshot_rowid_it_is_given(snapshot): + """按传进来的快照库 rowid 判,不按别的编号:同一条规则,第 3 行不成立、第 4 行成立。""" + view = await _view(snapshot) + rule = SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={}) + assert await provenance_db.row_status(view, rule, 3) == "mismatch" + assert await provenance_db.row_status(view, rule, 4) == "passed" + + +async def test_row_status_is_none_when_the_row_or_a_column_is_not_there(snapshot): + view = await _view(snapshot) + assert await provenance_db.row_status( + view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={}), 999) is None + assert await provenance_db.row_status( + view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区丙"], types={}), 1) is None + assert await provenance_db.row_status( + view, SumEqRule(table="没有这张表", total="a", parts=["b", "c"], types={}), 1) is None + # 名字只对 ASCII 不区分大小写(和 SQLite 一致),中文名原样比 + assert await provenance_db.row_status( + view, SumEqRule(table="日客流", total="全日客流", parts=["分区甲", "分区乙"], types={}), 1) == "passed" + + +# -------------------------------------------------------------------------- +# 数据文件记录与绑定 +# -------------------------------------------------------------------------- + + +async def test_snapshot_record(snapshot): + async with SessionLocal() as session: + got = await provenance_db.snapshot_record(session, snapshot.id) + assert got is not None and got.id == snapshot.id and got.db_path == snapshot.db_path + assert await provenance_db.snapshot_record(session, "f" * 64) is None + assert await provenance_db.snapshot_record(session, "") is None + + +async def test_view_of_uses_a_stand_in_keyed_like_the_query(snapshot): + """数据源记录不存在(已删除)也能绑定;id 用 snapshot.source_id,缓存键和查询时相同,首次哈希只算一次。""" + async with SessionLocal() as session: + assert await session.get(DataSource, snapshot.source_id) is None + view = provenance_db.view_of(snapshot, source_name="flow_demo") + assert view.id == snapshot.source_id and view.name == "flow_demo" + assert view.snapshot_id == snapshot.id and view.database == snapshot.db_path + assert view.immutable is True and view.readonly is True + assert view.expected_sha256 == snapshot.db_sha256 + assert view.options == {} and view.description == "" and view.enabled is True + assert cache_key(view) == f"{snapshot.source_id}:{snapshot.id}" + + +async def test_view_of_refuses_unregistered_hash_and_gone_files(tmp_path): + path = _build(tmp_path / "a.db", daily=_daily_rows(2)) + with pytest.raises(SnapshotMissing): + provenance_db.view_of(await _register(path, sha=""), source_name="flow_demo") + with pytest.raises(SnapshotMissing): + provenance_db.view_of(await _register(path, retired=True), source_name="flow_demo") + gone = await _register(path) + os.chmod(path, 0o644) + os.remove(path) + # 文件不在:绑定前就认出来(snapshot_gone,不标红),不留给 EngineCache 报成「文件被改」 + with pytest.raises(SnapshotMissing) as info: + provenance_db.view_of(gone, source_name="flow_demo") + assert not isinstance(info.value, SnapshotTampered) + + +async def test_view_of_raises_manifest_mismatch_untouched(tmp_path): + """快照清单里的库哈希和登记的不一致:bind_snapshot 抛的 SnapshotManifestMismatch 原样抛出。""" + from app.core import artifact_store + + path = _build(tmp_path / "u.db", daily=_daily_rows(2)) + mid = await artifact_store.put_json({"snapshot_id": None, "union": {"db_sha256": "0" * 64}}, + kind="snapshot_manifest") + snap = await _register(path, manifest=mid) + with pytest.raises(SnapshotManifestMismatch): + provenance_db.view_of(snap, source_name="flow_demo") + + +# -------------------------------------------------------------------------- +# R16:回查 +# -------------------------------------------------------------------------- + + +async def test_recheck_sql_params_and_rows(snapshot): + view = await _view(snapshot) + rows, sql, params = await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-02"}) + assert sql == 'SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?' + assert params == ["2026-08-02"] + conn = sqlite3.connect(snapshot.db_path) + want = conn.execute('SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?', ["2026-08-02"]).fetchall() + conn.close() + assert rows == want and isinstance(rows[0][1], int) + # 契约 Recheck 的口径:去掉双引号里的标识符后 ? 的个数等于主键列数 + assert _QUOTED.sub("", sql).count("?") == 1 + + +async def test_recheck_composite_key_keeps_primary_key_order_and_types(snapshot): + view = await _view(snapshot) + rows, sql, params = await provenance_db.recheck(view, table="时段客流", column="客流", + pk={"日期": "2026-08-03", "时段": "8-9"}) + assert sql == 'SELECT rowid, "客流" FROM "时段客流" WHERE "日期" = ? AND "时段" = ?' + assert params == ["2026-08-03", "8-9"] and [v for _, v in rows] == [321] + # 空值照实回(R11 已拦下空值,这里只回事实);REAL 回 float,不和 int 混 + rows, _, _ = await provenance_db.recheck(view, table="时段客流", column="客流", pk={"日期": "2026-08-03", "时段": "7-8"}) + assert [v for _, v in rows] == [None] + rows, _, _ = await provenance_db.recheck(view, table="分区占比", column="合计", pk={"日期": "2026-08-02"}) + assert rows[0][1] == 1.5 and isinstance(rows[0][1], float) + rows, _, _ = await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "1999-01-01"}) + assert rows == [] + + +async def test_recheck_quotes_identifiers_with_double_quotes(tmp_path): + path = tmp_path / "q.db" + conn = sqlite3.connect(path) + conn.execute('CREATE TABLE "表""甲" ("键""一" TEXT NOT NULL, "值?" INTEGER, PRIMARY KEY ("键""一" DESC))') + conn.execute('INSERT INTO "表""甲" VALUES (?, ?)', ["k", 7]) + conn.commit() + conn.close() + snap = await _register(path) + try: + rows, sql, params = await provenance_db.recheck( + await _view(snap), table='表"甲', column="值?", pk={'键"一': "k"}) + assert sql == 'SELECT rowid, "值?" FROM "表""甲" WHERE "键""一" = ?' + assert [v for _, v in rows] == [7] and params == ["k"] + assert _QUOTED.sub("", sql).count("?") == 1 + finally: + await engines.invalidate(snap.source_id) + + +async def test_recheck_raises_tampered_when_the_file_changed(snapshot): + """改过一个字节(修改时间也变了):经 EngineCache 取引擎时重新核对哈希,SnapshotTampered 原样抛出。""" + view = await _view(snapshot) + await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-01"}) + _poke(snapshot.db_path, keep_mtime=False) + with pytest.raises(SnapshotTampered): + await provenance_db.recheck(view, table="日客流", column="全日客流", pk={"日期": "2026-08-01"}) + + +async def test_recheck_needs_a_primary_key(snapshot): + with pytest.raises(ValueError): + await provenance_db.recheck(await _view(snapshot), table="日客流", column="全日客流", pk={}) + + +# -------------------------------------------------------------------------- +# R16:强制重算 +# -------------------------------------------------------------------------- + + +async def test_rehash_catches_an_in_place_edit_that_keeps_the_fingerprint(snapshot, monkeypatch): + """原地改一个字节、再还原修改时间:EngineCache 的指纹不变、照样给缓存的引擎,强制重算认得出(R16,A25②)。 + 重算放在线程里。""" + view = await _view(snapshot) + await engines.get(view) + assert await provenance_db.rehash(view) is True + threads: list[bool] = [] + real = engine_mod._file_sha256 + + def spy(path): + threads.append(threading.current_thread() is threading.main_thread()) + return real(path) + + monkeypatch.setattr(engine_mod, "_file_sha256", spy) + before = engines.pinned(view) + _poke(snapshot.db_path, keep_mtime=True) + assert engine_mod._fingerprint(snapshot.db_path) == before + await engines.get(view) # 指纹没变,缓存命中,不重新核对 + assert await provenance_db.rehash(view) is False + assert threads and not any(threads) + + +async def test_rehash_is_false_without_a_hash_or_a_file(snapshot): + view = await _view(snapshot) + view.expected_sha256 = None + assert await provenance_db.rehash(view) is False + view = await _view(snapshot) + view.database = str(snapshot.db_path) + ".missing" + assert await provenance_db.rehash(view) is False + + +# -------------------------------------------------------------------------- +# R15:编译核对 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql,tables,rows,yields", [ + ('SELECT "日期", "全日客流" FROM "日客流" WHERE "日期" = \'2026-08-01\'', {"日客流"}, 1, 0), + ('SELECT * FROM "日客流" ORDER BY "全日客流" DESC LIMIT 3', {"日客流"}, 1, 0), + ('SELECT "日期","全日客流" FROM "日客流" UNION ALL SELECT "日期","分区甲" FROM "日客流"', {"日客流"}, 2, 0), + ('SELECT "日期","全日客流" FROM "日客流" WHERE "日期" LIKE \'2026%\' ESCAPE \'\\\' ' + 'UNION ALL SELECT "日期","分区甲" FROM "日客流"', {"日客流"}, 2, 0), + ('SELECT "日期","全日客流" FROM "日客流" WHERE "日期" IN (SELECT "日期" FROM "时段客流")', + {"日客流", "时段客流"}, 1, 0), +]) +async def test_compile_facts_reports_tables_and_result_rows(snapshot, sql, tables, rows, yields): + facts = await provenance_db.compile_facts(await _view(snapshot), sql) + assert facts == CompileFacts(tables=tables, result_rows=rows, yields=yields) + + +@pytest.mark.parametrize("op", ["UNION", "INTERSECT", "EXCEPT"]) +async def test_compile_facts_sees_yield_in_set_operations(snapshot, op): + facts = await provenance_db.compile_facts( + await _view(snapshot), f'SELECT "日期","全日客流" FROM "日客流" {op} SELECT "日期","分区甲" FROM "日客流"') + assert facts.yields >= 1 + + +async def test_compile_facts_runs_in_a_thread_on_an_immutable_read_only_connection(snapshot, monkeypatch): + seen: list[dict] = [] + real_open = provenance_db.open_checked_sqlite + + def spy(path, **kw): + seen.append({"path": path, **kw, "main": threading.current_thread() is threading.main_thread()}) + return real_open(path, **kw) + + monkeypatch.setattr(provenance_db, "open_checked_sqlite", spy) + await provenance_db.compile_facts(await _view(snapshot), 'SELECT "日期" FROM "日客流"') + assert seen == [{"path": snapshot.db_path, "readonly": True, "immutable": True, "main": False}] + + +async def test_compile_facts_compares_against_the_engine_cache_fingerprint(snapshot, monkeypatch): + """开连接前的指纹和 EngineCache 核对过的那份不等:SnapshotTampered(→ db_tampered)。""" + view = await _view(snapshot) + monkeypatch.setattr(engines, "pinned", lambda source: (0, 0, 0, 0)) + with pytest.raises(SnapshotTampered): + await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"') + + +async def test_compile_facts_checks_the_fingerprint_again_after_closing(snapshot, monkeypatch): + """编译期间文件被换(关连接后的指纹对不上):同样 SnapshotTampered,不交出编译结果。""" + view = await _view(snapshot) + real = provenance_db._fingerprint + calls: list[int] = [] + + def drifting(path): + calls.append(1) + fp = real(path) + return fp if len(calls) == 1 else (fp[0], fp[1] + 1, fp[2], fp[3]) + + monkeypatch.setattr(provenance_db, "_fingerprint", drifting) + with pytest.raises(SnapshotTampered): + await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"') + assert len(calls) == 2 + + +async def test_compile_facts_without_a_pinned_engine_is_snapshot_missing(snapshot, monkeypatch): + monkeypatch.setattr(engines, "pinned", lambda source: None) + with pytest.raises(SnapshotMissing) as info: + await provenance_db.compile_facts(await _view(snapshot), 'SELECT "日期" FROM "日客流"') + assert not isinstance(info.value, SnapshotTampered) + + +async def test_compile_facts_refuses_a_replaced_file(snapshot): + """引擎已在缓存里、文件随后被改(修改时间变了):取引擎时就重新核对,SnapshotTampered。""" + view = await _view(snapshot) + await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"') + _poke(snapshot.db_path, keep_mtime=False) + with pytest.raises(SnapshotTampered): + await provenance_db.compile_facts(view, 'SELECT "日期" FROM "日客流"') + + +# -------------------------------------------------------------------------- +# 导入记录上的当前状态 +# -------------------------------------------------------------------------- + + +async def test_import_states(): + sid = uuid.uuid4().hex + async with SessionLocal() as session: + recipe = TableRecipe(source_id=sid, seq=3, recipe={}, recipe_sha256="a" * 64) + session.add(recipe) + await session.flush() + kept = TableImport(source_id=sid, seq=1, raw_state="kept", recipe_id=recipe.id) + purged = TableImport(source_id=sid, seq=2, raw_state="purged", recipe_id="nosuchrecipe", + purged={"at": "2026-09-30T01:02:03+00:00", "reason": "合成理由:清除原件", + "signed_by": "录入员甲", "signed_by_verified": False}) + revoked = TableImport(source_id=sid, seq=3, raw_state="kept", + revoked={"at": "2026-10-01T00:00:00+00:00", "reason": "合成理由:作废", + "signed_by": None, "signed_by_verified": False}) + session.add_all([kept, purged, revoked]) + await session.commit() + ids = [kept.id, purged.id, revoked.id] + got = await provenance_db.import_states(session, [*ids, "nosuchimport", kept.id]) + assert set(got) == set(ids) + assert got[kept.id] == {"raw_state": "kept", "purged": None, "revoked": None, "recipe_seq": 3} + assert got[purged.id] == {"raw_state": "purged", "revoked": None, "recipe_seq": None, "purged": StateNote( + at="2026-09-30T01:02:03+00:00", signed_by="录入员甲", reason="合成理由:清除原件")} + assert got[revoked.id]["revoked"] == StateNote(at="2026-10-01T00:00:00+00:00", signed_by=None, + reason="合成理由:作废") + assert got[revoked.id]["recipe_seq"] is None + async with SessionLocal() as session: + assert await provenance_db.import_states(session, []) == {} + + +# -------------------------------------------------------------------------- +# engine.py:EngineCache.pinned、open_checked_sqlite 的 immutable +# -------------------------------------------------------------------------- + + +async def test_pinned_returns_the_verified_fingerprint(tmp_path): + path = _build(tmp_path / "p.db", daily=_daily_rows(2)) + snap = await _register(path) + view = await _view(snap) + cache = EngineCache() + try: + assert cache.pinned(view) is None # 还没建引擎 + await cache.get(view) + st = os.stat(path) + assert cache.pinned(view) == (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns) + manual = type("S", (), {"id": "m1", "kind": "sqlite", "database": str(path), "readonly": True, + "options": {}, "name": "手工源"})() + await cache.get(manual) + assert cache.pinned(manual) is None # 手工源没有核对过的指纹 + await cache.invalidate(snap.source_id) + assert cache.pinned(view) is None + finally: + await cache.close() + + +def test_open_checked_sqlite_immutable_flag(tmp_path, monkeypatch): + path = _build(tmp_path / "o.db", daily=_daily_rows(1)) + uris: list[str] = [] + real = sqlite3.connect + + def spy(database, *a, **kw): + uris.append(database) + return real(database, *a, **kw) + + monkeypatch.setattr(engine_mod.sqlite3, "connect", spy) + for kwargs in ({}, {"readonly": True}, {"readonly": True, "immutable": True}): + conn = open_checked_sqlite(path, **kwargs) + try: + assert conn.execute('SELECT COUNT(*) FROM "日客流"').fetchone() == (1,) + finally: + conn.close() + # 缺省行为不变:只读、不带 immutable + assert uris[0].endswith("?mode=ro") and uris[1].endswith("?mode=ro") + assert uris[2].endswith("?mode=ro&immutable=1") + with pytest.raises(ValueError): + open_checked_sqlite(path, readonly=False, immutable=True) + diff --git a/backend/tests/test_provenance_unchanged.py b/backend/tests/test_provenance_unchanged.py new file mode 100644 index 00000000..8dab4191 --- /dev/null +++ b/backend/tests/test_provenance_unchanged.py @@ -0,0 +1,394 @@ +"""期 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: + """金样生成时 `_Masks` 的替身:预填 memo(源名 → (遮罩列小写, 数据源还在)),不查库。""" + + def __init__(self, memo: dict[str, tuple[set[str], bool]]) -> None: + 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: _StubMasks(memo)) + + +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 diff --git a/backend/tests/test_recipe_engine_crosstab.py b/backend/tests/test_recipe_engine_crosstab.py index 08f2d53e..a0cc20ef 100644 --- a/backend/tests/test_recipe_engine_crosstab.py +++ b/backend/tests/test_recipe_engine_crosstab.py @@ -351,6 +351,36 @@ def test_outside_text_with_digits_needs_confirmation(): assert (f"{SHEET}!B32", "text_digits", False) in [(o.cell, o.kind, o.period_source) for o in ex.outside_text] +def test_outside_text_records_whether_its_row_or_column_is_hidden(): + """期 4(P4-SPEC 7.3、调整 10,评审 hid.py 的复现):区域外文字在隐藏行、隐藏列里时 hidden 为 True,可见的为 + False;带附加文字的统计期格(period_source)同样写。裁判摘录只送 hidden is False 的项,隐藏行里的内部备注 + 不能多一条经裁判外发的通道。隐藏行列在认领区域之外,不触发 hidden_rows / hidden_cols。""" + def edit(ws, info): + ws["B32"] = "注:8月15日闸机故障,当日客流为估算值" # 可见、含数字 + ws["B33"] = "内部:本月补贴 45678 元" # 隐藏行 + ws.row_dimensions[33].hidden = True + ws["H34"] = "内部备注:分区甲口令 abc123" # 区域右侧、区域下方的隐藏列 + ws.column_dimensions["H"].hidden = True + ws["B3"] = "客流汇总表(2026年8月)" # 可见的统计期附加文字 + ws["B35"] = "注:2026年8月数据为初步统计" # 隐藏行里的统计期附加文字 + ws.row_dimensions[35].hidden = True + ex = run(flow_book(days=3, edit=edit)) + assert ex.ok, [(p.code, p.message) for p in ex.problems] + assert not {"hidden_rows", "hidden_cols"} & codes(ex) + got = {o.cell: (o.kind, o.period_source, o.hidden) for o in ex.outside_text} + assert got == { + f"{SHEET}!B3": ("text_digits", True, False), + f"{SHEET}!B32": ("text_digits", False, False), + f"{SHEET}!B33": ("text_digits", False, True), + f"{SHEET}!H34": ("text_digits", False, True), + f"{SHEET}!B35": ("text_digits", True, True), + } + # 回执(asdict)里一律带 hidden 键:导入清单按它判断,老清单没有这个键才是「未记录」 + from dataclasses import asdict + + assert all(isinstance(o["hidden"], bool) for o in asdict(ex)["outside_text"]) + + def test_outside_number_is_structure_and_merges_per_row(): def edit(ws, info): ws["B1"] = 987654 # 日期表头以上:区域外 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8bae6170..031f64be 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,7 +5,7 @@ import type { } from '../types' import type { Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, - EvidenceAudit, EvidenceJudgeResult, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run, + EvidenceAudit, EvidenceJudgeResult, EvidenceProvenance, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, UploadDecision, UploadMixedColumn, UploadResult, UploadShapeReason, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, } from '../types' @@ -891,6 +891,15 @@ export const api = { */ judge: (runId: string, body: { units: string[]; report?: string }) => post(`/runs/${encodeURIComponent(runId)}/evidence/judge`, body), + /** + * 推断的来源(期 4):上传表格的一个单元格来自原表的哪一格、这次查询查的是哪一版数据、相关核对。按需算, + * 推断不出来不是错误(200,结论在 status、reason、alert 里)。只在片段接口的查询步骤带 provenance: true 时请求; + * 老服务端没有这个接口,回 404 且没有 code。report 同片段接口 + */ + provenance: (runId: string, segmentId: string, opts?: RequestOptions & { report?: string }) => + get( + `/runs/${encodeURIComponent(runId)}/evidence/segments/${encodeURIComponent(segmentId)}/provenance${qs({ report: opts?.report })}`, + opts), /** * 导出审计表:?format=json / csv 作为附件下载。回来的是文件本身和后端给的文件名 * (Content-Disposition;没给就用 evidence-<运行号前 8 位>.<格式>) diff --git a/frontend/src/dev/ui-harness.tsx b/frontend/src/dev/ui-harness.tsx index 17f9a971..a607cddd 100644 --- a/frontend/src/dev/ui-harness.tsx +++ b/frontend/src/dev/ui-harness.tsx @@ -30,6 +30,7 @@ import evidenceQuery from '../run/__tests__/evidence-query.json' import evidenceEntity from '../run/__tests__/evidence-entity.json' import evidenceJudge from '../run/__tests__/evidence-judge.json' import evidenceVerdict from '../run/__tests__/evidence-verdict.json' +import evidenceProvenance from '../run/__tests__/evidence-provenance.json' import { EvidenceGuessView } from '../run/EvidenceGuess' import { IssuanceBanner } from '../run/AssistantStream' import mdPinned from '../run/__tests__/markdown-pinned.json' @@ -207,6 +208,11 @@ const EV_JUDGE_PLAIN = evidenceJudge.plain.doc as unknown as EvidenceDocData */ const EV_VERDICT = evidenceVerdict.formal.doc as unknown as EvidenceDocData const EV_VERDICT_EXPLORE = evidenceVerdict.explore.doc as unknown as EvidenceDocData +/** + * Excel 导入期 4:上传表格的单元格追到原表格子(evidence-provenance.json)。文档由 compose_doc 真跑、带文档标记; + * 片段接口、推断来源接口、导入清单的工件都由 check-evidence 用 page.route 伪造(按 runId 认出这一份夹具) + */ +const EV_PROVENANCE = evidenceProvenance.doc as unknown as EvidenceDocData /** 同一份旧契约的出具,去掉位置:老运行就是这样,只能按字符串标 */ const LEGACY_LOOSE = { matched: EV_LEGACY.matched.map(({ start: _s, end: _e, ...m }) => m), @@ -431,6 +437,20 @@ function EvidenceOnly() { ))} + {/* + Excel 导入期 4:推断的来源。宽栏一份、360 宽的画布右栏一份(栏内展开,不出横向滚动) + */} +
+
+
上传表格的单元格:数据版本、推断的来源、相关核对
+ +
+
+ +
+
长报告:{EV_LONG.blocks.length} 块,块级 content-visibility,超过 3000 字先折叠
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2c3ca503..14065607 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -341,6 +341,78 @@ export const EVIDENCE_TEXT = { guessNone: '未找到数值相同的单元格', guessDiff: (d: string) => `相差 ${d}`, guessCandidates: '可能来自', + + // ---- 期 4:推断的来源(上传表格的单元格追到原表格子,P4-SPEC 4.2)---- + // 用词同版本页:不写「快照」「构建」「并集」,写「数据版本」「这一期」「数据文件」;署名一律标「署名(未认证)」。 + // 不下钻的原因、标红的提示由服务端给原文(provenance_types.REASON_TEXT / ALERT_TEXT),界面原样显示,不在这里另写 + provenance: { + version: '数据版本', + /** 首行。每期替换只有一期,不写期数;导入模式的说法同版本页(VERSIONS_TEXT.modeLabel) */ + versionHead: (source: string, mode: string, n: number) => (mode === 'accumulate' + ? `数据源「${source}」· 按期累积 · 共 ${formatNumber(n)} 期` + : `数据源「${source}」· 每期替换`), + part: (seq: number, start: string, end: string) => `第 ${formatNumber(seq)} 次导入 · 统计期 ${start} 至 ${end}`, + partNoPeriod: (seq: number) => `第 ${formatNumber(seq)} 次导入 · 统计期未记录`, + /** sha 是原件 sha256 的前 12 位;清单没记哈希时只写文件名 */ + file: (name: string, sha: string) => (sha ? `文件「${name}」· sha256 ${sha}` : `文件「${name}」`), + /** S9:回执里块内各区域的外接矩形和排除的行数。排除 0 行、没有记录时不写 */ + region: (region: string, excluded: number | null) => + (excluded ? `区域 ${region} · 排除 ${formatNumber(excluded)} 行` : `区域 ${region}`), + excluded: (n: number) => `排除 ${formatNumber(n)} 行`, + committedAt: (at: string) => `导入于 ${at}`, + /** 查询用到的表(与冻结表结构的交集)。原表写明的合计表另注一句:它不能和明细相加 */ + tables: (names: string[]) => `涉及的表:${names.join('、')}`, + totalTable: (name: string) => `${name}(原表写明的合计)`, + hasRow: '这一格所在的一期', + current: '当前状态', + revoked: '这一期的接受已作废', + /** 清除原件、作废接受的记录:时间 · 理由 · 署名(未认证)。缺的不写(时间没记时调用方传空串),署名没填写「未填写」 */ + stateDetail: (at: string, reason: string | null, name: string | null) => + [at, reason ? `理由:${reason}` : '', `署名(未认证):${name || '未填写'}`].filter(Boolean).join(' · '), + /** 这一期写过的接受理由(导入清单里的,是当时的事实)。核对标题取不到时写核对编号 */ + acceptedLine: (check: string, reason: string, name: string | null) => + `已接受:${check} · ${reason} · 署名(未认证):${name || '未填写'}`, + manifest: '查看导入清单', + manifestMasked: '数据源设置了遮罩,导入清单请到数据源卡片上的「版本」查看', + unsealed: '本次运行尚未封存或封存核对未通过,以下内容不计入已封存的证据', + morePeriods: (n: number) => `另有 ${formatNumber(n)} 期`, + title: '推断的来源', + badge: '推断,不属于已封存的证据', + cell: (sheet: string, ref: string) => `工作表「${sheet}」${ref}`, + /** 多期时格子在哪一期的文件里:坐标是那一期原件的坐标,不是别的期的 */ + fromPart: (seq: number, file: string | null) => + (file ? `取自第 ${formatNumber(seq)} 次导入的文件「${file}」` : `取自第 ${formatNumber(seq)} 次导入`), + axisHeader: (ref: string) => `日期取自表头格 ${ref}`, + yearPeriod: (cells: string) => (cells ? `年份取自统计期(${cells})` : '年份取自统计期'), + yearHuman: (name: string) => `年份取自人工录入的统计期(署名(未认证):${name})`, + yearMixed: '这一块的日期表头有的是日期格(自带年份),有的是文本(年份取自统计期),无法确定这一格属于哪一种', + /** 格子原文拿不到时(text 为 null)只写坐标,不拿规范写法冒充原文 */ + rowLabel: (ref: string, text?: string | null) => (text ? `行标签 ${ref}「${text}」` : `行标签 ${ref}`), + /** 清单里没有分段标题格的原文,只有配方里的定位文字:照实说是「按配方中的标题定位」 */ + sectionTitle: (ref: string, title?: string | null) => + (title ? `分段标题 ${ref}(按配方中的标题「${title}」定位)` : `分段标题 ${ref}`), + colHeader: (refs: string) => `列表头 ${refs}`, + totalLabel: (ref: string, text?: string | null) => (text ? `合计标签 ${ref}「${text}」` : `合计标签 ${ref}`), + canonical: (raw: string, canon: string) => `原文「${raw}」,按规范写法存为「${canon}」`, + reportedTotal: '原表写明的合计:不要彼此相加,也不要与明细相加', + rawPurged: '原件已清除:坐标来自导入时的记录,无法再对照原件', + mergedFill: '这一块按合并单元格的左上格填充:如果这一格在合并区域内,值取自该区域的左上格', + recheck: '已按主键回查数据文件,值一致', + details: '技术细节', + recheckSql: '回查 SQL', + checks: '相关核对', + row: { passed: '这一行成立', mismatch: '这一行不成立', unverifiable: '这一行含空值,未能核对' } as Record, + cellStatus: { + ok: '这一格一致', + unverifiable: '这一格未能核对', + unknown: '本期有格子未能核对,这一格是否在其中无法确定', + not_formula: '这一格是写死的数,不做公式引用核对', + } as Record, + partStatus: { passed: '本期通过', mismatch: '本期不成立', unverifiable: '本期未能核对', info: '口径说明' } as Record, + accepted: (reason: string, name: string | null) => `已接受,理由:${reason}(署名(未认证):${name || '未填写'})`, + loading: '正在推断来源…', + failed: '未能获取推断的来源', + }, } as const /** 结论句按裁判结论分成的几堆(lib/evidence 的 claimCounts 数出来的) */ @@ -1074,7 +1146,8 @@ export const VERSIONS_TEXT = { /** tokens 是 formatTokens 的结果(带单位),cost 是 formatCost 的结果 */ aiUsage: (calls: number, tokens: string, cost: string) => `调用模型 ${formatNumber(calls)} 次,共 ${tokens},约 ${cost}`, aiNone: '这次导入没有调用模型', - outsideTextNote: '区域外文字只在这里显示,不提供给模型', + /** 期 4 起,带来源标记的报告里,裁判摘录会带上可见单元格里的区域外文字(隐藏行列里的不送) */ + outsideTextNote: '区域外文字不导入数据表;核对报告时,裁判可以看到其中可见单元格的文字', revokedBadge: '接受已作废', /** 当前版本或回滚目标被别人改过(base_changed、revoke_target_changed):服务端原话说了变的是什么,这里只说列表已刷新 */ refreshHint: '列表已刷新,请重新确认', diff --git a/frontend/src/pages/import/ManifestBody.tsx b/frontend/src/pages/import/ManifestBody.tsx new file mode 100644 index 00000000..c19a028e --- /dev/null +++ b/frontend/src/pages/import/ManifestBody.tsx @@ -0,0 +1,237 @@ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { CheckResult, ImportAcceptance, ManifestOut } from '../../types' +import { CopyButton, Notice } from '../../components/ui' +import { formatBytes, formatCost, formatDateTime, formatTokens } from '../../lib/format' +import { RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, VERSIONS_TEXT as VT } from '../../lib/terms' +import { CheckStatusChip } from './ImportReceipt' +import { ReceiptSummary } from './ReceiptBlocks' + +// =========================================================================== +// 导入清单的渲染(P3-SPEC 7.3):文件、配方、统计期、核对、接受、确认清单、修改记录、说明、回执摘要、AI 用量, +// 原始清单默认收起。 +// +// 从版本页(VersionsDialog)抽出来,证据面板的「数据版本」一节也用它(P4-SPEC 4.1):面板里查看导入清单 +// 不能退回整份 JSON 输出——那样遮罩不起作用,64 KB 的清单也没法读。两处的行为必须一致,check-versions 守版本页, +// check-evidence 的 provenance 段守面板 +// =========================================================================== + +function Block({ id, title, children }: { id: string; title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +const short = (sha: unknown) => (typeof sha === 'string' && sha ? sha.slice(0, 8) : '') +const cellsShort = (cells: string[]) => cells.map((c) => c.slice(c.lastIndexOf('!') + 1)).join('、') +const list = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) + +/** + * 导入清单的各块。content 是服务端存的原样(内容寻址,取回时复验了哈希),字段都按「可能缺」读:期 2 的清单 + * 没有修改记录,简单导入只有导入回执。 + * + * 清单之外的三样(文件名兜底、原件状态、配方第几版)由调用方给:版本页取自导入记录,证据面板取自推断来源接口的 + * 那一期(PartView)。原件状态和配方版次是当前状态,不在清单里 + */ +export function ManifestBody({ m, fileName, rawState, recipeSeq }: { + m: ManifestOut + /** 清单没记文件名时显示的名字 */ + fileName?: string | null + /** 原件状态(kept / purged / absent),取不到为 null 时不显示 */ + rawState?: string | null + /** 配方第几版,取不到为 null 时不显示 */ + recipeSeq?: number | null +}) { + const c = m.content ?? {} + const B = VT.manifestBlocks + const simple = m.kind === 'build_report' + const receipt = simple ? c : c.receipt + const checks = list(c.checks) + const titleOf = (id: string) => checks.find((x) => x.id === id)?.title + const accepts: ImportAcceptance[] = [ + ...list(c.acceptances?.overrides).map((a) => ({ ...a, kind: 'override' })), + ...list(c.acceptances?.waivers).map((a) => ({ ...a, kind: 'waiver' })), + ] + const confirmations = list<{ id: string; label: string; at?: string }>(c.confirmations) + const edits = Array.isArray(c.edits) ? list>(c.edits) : null + const rendered = Object.entries((c.notes?.rendered ?? {}) as Record }>) + const usage = list>(c.ai?.usage) + const tokens = usage.reduce((n, u) => n + (Number(u.input_tokens) || 0) + (Number(u.output_tokens) || 0), 0) + const cost = usage.reduce((n, u) => n + (Number(u.cost_usd) || 0), 0) + const period = c.period as { start?: string; end?: string; source?: string; cells?: string[]; signed_by?: string | null } | null + const outside = list(receipt?.outside_text) + + return ( +
+ {simple + ? {VT.manifestSimple} + : !m.verified && {VT.manifestUnverified}} + + {!simple && ( + <> + +
+ {c.file?.name || fileName} + {c.file?.size != null && {formatBytes(Number(c.file.size))}} + {short(c.file?.raw_sha256) && {VT.sha(short(c.file?.raw_sha256))}} + {rawState != null && {RAW_STATE_LABEL[rawState] ?? rawState}} +
+ {c.signed_by?.name &&
{VT.signedBy(c.signed_by.name)}
} +
+ + +
+ {recipeSeq != null && {VT.recipeSeq(recipeSeq)}} + {c.recipe?.origin && {RECIPE_ORIGIN_LABEL[c.recipe.origin] ?? c.recipe.origin}} + {short(c.recipe?.sha256) && {VT.sha(short(c.recipe?.sha256))}} +
+ {c.recipe?.canonical && } +
+ + + {period?.start && period?.end ? ( +
+
{VT.period(period.start, period.end)}
+
+ {period.source === 'human' + ? `${VT.periodHuman},${VT.signedBy(period.signed_by || VT.unsigned)}` + : period.cells?.length ? RECIPE_TEXT.periodFromCells(cellsShort(period.cells)) : ''} +
+
+ ) :

{VT.periodUnknown}

} +
+ + + {checks.length ? ( +
    + {checks.map((k) => ( +
  • +
    + + {k.id} + {k.title} + {/* 计数照试运行回执的规则(ImportReceipt 的 CheckRow):「说明」类的 failed 是预期内的不相等个数,不写 */} + {!!k.checked && k.status !== 'info' && ( + + {VT.checkCounts(k.checked, k.failed ?? 0, k.unverifiable ?? 0)} + + )} +
    + {/* 清单是当证据核对的,细节不截断:服务端每个核对最多逐条列 20 格,其余写成「另有 N 格不一致」 + 「N 格无法核对:…」,截掉的正是这几行 */} + {!!k.details?.length && ( +
      + {k.details.map((d, i) =>
    • {d}
    • )} +
    + )} + {k.sql && } +
  • + ))} +
+ ) :

{VT.checksNone}

} +
+ + + {accepts.length ? ( +
    + {accepts.map((a, i) => ( +
  • +
    + {VT.acceptKind[a.kind] ?? VT.acceptKind.override} + + {VT.acceptRow(titleOf(a.check_id) ? `${a.check_id} ${titleOf(a.check_id)}` : a.check_id, a.reason)} + +
    +
    + {VT.signedBy(a.signed_by || VT.unsigned)} + {a.at && {formatDateTime(a.at)}} +
    +
  • + ))} +
+ ) :

{VT.acceptancesNone}

} +
+ + + {confirmations.length ? ( +
    + {confirmations.map((x, i) =>
  • {x.label}
  • )} +
+ ) :

{VT.confirmationsNone}

} +
+ + + {edits == null + ?

{VT.editsUnrecorded}

+ : edits.length ? ( +
    + {edits.map((e, i) => ( +
  • + {VT.editKind[e.kind] ?? VT.editKind.fix} + {e.title} + {e.superseded && {VT.editSuperseded}} + {VT.signedBy(e.signed_by || VT.unsigned)} + {e.at && {formatDateTime(e.at)}} +
  • + ))} +
+ ) :

{VT.editsNone}

} +
+ + + {rendered.length ? ( +
    + {rendered.map(([table, n]) => ( +
  • +
    {table}
    + {n.comment &&

    {n.comment}

    } + {Object.entries(n.columns ?? {}).length > 0 && ( +
      + {Object.entries(n.columns ?? {}).map(([col, text]) =>
    • {col}:{text}
    • )} +
    + )} +
  • + ))} +
+ ) :

{VT.notesNone}

} +
+ + )} + +
+ + {outside.length > 0 &&

{VT.outsideTextNote}

} +
+ + {!simple && ( + +

+ {usage.length ? VT.aiUsage(usage.length, formatTokens(tokens), formatCost(cost)) : VT.aiNone} +

+
+ )} + + +
+ ) +} + +/** 等宽的原文(原始清单、配方 JSON、核对的 SQL),默认收起:给要逐字核对的人看,带复制 */ +function RawJson({ summary, value, attr }: { summary: string; value: unknown; attr: string }) { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + return ( +
+ {summary} +
+
+          {text}
+        
+ +
+
+ ) +} diff --git a/frontend/src/pages/import/VersionsDialog.tsx b/frontend/src/pages/import/VersionsDialog.tsx index 40ba64c4..d33baea8 100644 --- a/frontend/src/pages/import/VersionsDialog.tsx +++ b/frontend/src/pages/import/VersionsDialog.tsx @@ -4,17 +4,16 @@ import { AlertTriangle, ArrowLeft, ChevronRight, FileText, Info, RotateCcw, Scis import clsx from 'clsx' import { ApiError, api } from '../../api/client' import type { - CheckResult, DataSource, ImportAcceptance, ImportRecord, ManifestOut, PeriodRef, RevokePlan, SnapshotOut, SnapshotPart, + DataSource, ImportAcceptance, ImportRecord, ManifestOut, PeriodRef, RevokePlan, SnapshotOut, SnapshotPart, } from '../../types' import { - CopyButton, EmptyState, ErrorState, Modal, Notice, Skeleton, TabPanel, Tabs, promptDialog, toast, + EmptyState, ErrorState, Modal, Notice, Skeleton, TabPanel, Tabs, promptDialog, toast, } from '../../components/ui' import { localActor } from '../../lib/actor' import { humanizeError } from '../../lib/errors' -import { formatBytes, formatCost, formatDateTime, formatTime, formatTokens, parseServerTime } from '../../lib/format' -import { RAW_STATE_LABEL, RECIPE_ORIGIN_LABEL, RECIPE_TEXT, VERSIONS_TEXT as VT } from '../../lib/terms' -import { CheckStatusChip } from './ImportReceipt' -import { ReceiptSummary } from './ReceiptBlocks' +import { formatBytes, formatDateTime, formatTime, parseServerTime } from '../../lib/format' +import { RAW_STATE_LABEL, VERSIONS_TEXT as VT } from '../../lib/terms' +import { ManifestBody } from './ManifestBody' // =========================================================================== // 版本页(数据源卡片上的「版本」,P3-SPEC 7.8):当前版本的各期、历史版本、全部导入记录三个页签, @@ -703,216 +702,7 @@ function ManifestView({ sourceId, rec, onBack }: { sourceId: string; rec: Import ? setNonce((n) => n + 1)} /> : !m ? - : } + : }
) } - -function Block({ id, title, children }: { id: string; title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -const short = (sha: unknown) => (typeof sha === 'string' && sha ? sha.slice(0, 8) : '') -const cellsShort = (cells: string[]) => cells.map((c) => c.slice(c.lastIndexOf('!') + 1)).join('、') -const list = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) - -/** - * 导入清单的各块。content 是服务端存的原样(内容寻址,取回时复验了哈希),字段都按「可能缺」读:期 2 的清单 - * 没有修改记录,简单导入只有导入回执 - */ -function ManifestBody({ m, rec }: { m: ManifestOut; rec: ImportRecord }) { - const c = m.content ?? {} - const B = VT.manifestBlocks - const simple = m.kind === 'build_report' - const receipt = simple ? c : c.receipt - const checks = list(c.checks) - const titleOf = (id: string) => checks.find((x) => x.id === id)?.title - const accepts: ImportAcceptance[] = [ - ...list(c.acceptances?.overrides).map((a) => ({ ...a, kind: 'override' })), - ...list(c.acceptances?.waivers).map((a) => ({ ...a, kind: 'waiver' })), - ] - const confirmations = list<{ id: string; label: string; at?: string }>(c.confirmations) - const edits = Array.isArray(c.edits) ? list>(c.edits) : null - const rendered = Object.entries((c.notes?.rendered ?? {}) as Record }>) - const usage = list>(c.ai?.usage) - const tokens = usage.reduce((n, u) => n + (Number(u.input_tokens) || 0) + (Number(u.output_tokens) || 0), 0) - const cost = usage.reduce((n, u) => n + (Number(u.cost_usd) || 0), 0) - const period = c.period as { start?: string; end?: string; source?: string; cells?: string[]; signed_by?: string | null } | null - const outside = list(receipt?.outside_text) - - return ( -
- {simple - ? {VT.manifestSimple} - : !m.verified && {VT.manifestUnverified}} - - {!simple && ( - <> - -
- {c.file?.name || rec.file_name} - {c.file?.size != null && {formatBytes(Number(c.file.size))}} - {short(c.file?.raw_sha256) && {VT.sha(short(c.file?.raw_sha256))}} - {RAW_STATE_LABEL[rec.raw_state] ?? rec.raw_state} -
- {c.signed_by?.name &&
{VT.signedBy(c.signed_by.name)}
} -
- - -
- {rec.recipe_seq != null && {VT.recipeSeq(rec.recipe_seq)}} - {c.recipe?.origin && {RECIPE_ORIGIN_LABEL[c.recipe.origin] ?? c.recipe.origin}} - {short(c.recipe?.sha256) && {VT.sha(short(c.recipe?.sha256))}} -
- {c.recipe?.canonical && } -
- - - {period?.start && period?.end ? ( -
-
{VT.period(period.start, period.end)}
-
- {period.source === 'human' - ? `${VT.periodHuman},${VT.signedBy(period.signed_by || VT.unsigned)}` - : period.cells?.length ? RECIPE_TEXT.periodFromCells(cellsShort(period.cells)) : ''} -
-
- ) :

{VT.periodUnknown}

} -
- - - {checks.length ? ( -
    - {checks.map((k) => ( -
  • -
    - - {k.id} - {k.title} - {/* 计数照试运行回执的规则(ImportReceipt 的 CheckRow):「说明」类的 failed 是预期内的不相等个数,不写 */} - {!!k.checked && k.status !== 'info' && ( - - {VT.checkCounts(k.checked, k.failed ?? 0, k.unverifiable ?? 0)} - - )} -
    - {/* 清单是当证据核对的,细节不截断:服务端每个核对最多逐条列 20 格,其余写成「另有 N 格不一致」 - 「N 格无法核对:…」,截掉的正是这几行 */} - {!!k.details?.length && ( -
      - {k.details.map((d, i) =>
    • {d}
    • )} -
    - )} - {k.sql && } -
  • - ))} -
- ) :

{VT.checksNone}

} -
- - - {accepts.length ? ( -
    - {accepts.map((a, i) => ( -
  • -
    - {VT.acceptKind[a.kind] ?? VT.acceptKind.override} - - {VT.acceptRow(titleOf(a.check_id) ? `${a.check_id} ${titleOf(a.check_id)}` : a.check_id, a.reason)} - -
    -
    - {VT.signedBy(a.signed_by || VT.unsigned)} - {a.at && {formatDateTime(a.at)}} -
    -
  • - ))} -
- ) :

{VT.acceptancesNone}

} -
- - - {confirmations.length ? ( -
    - {confirmations.map((x, i) =>
  • {x.label}
  • )} -
- ) :

{VT.confirmationsNone}

} -
- - - {edits == null - ?

{VT.editsUnrecorded}

- : edits.length ? ( -
    - {edits.map((e, i) => ( -
  • - {VT.editKind[e.kind] ?? VT.editKind.fix} - {e.title} - {e.superseded && {VT.editSuperseded}} - {VT.signedBy(e.signed_by || VT.unsigned)} - {e.at && {formatDateTime(e.at)}} -
  • - ))} -
- ) :

{VT.editsNone}

} -
- - - {rendered.length ? ( -
    - {rendered.map(([table, n]) => ( -
  • -
    {table}
    - {n.comment &&

    {n.comment}

    } - {Object.entries(n.columns ?? {}).length > 0 && ( -
      - {Object.entries(n.columns ?? {}).map(([col, text]) =>
    • {col}:{text}
    • )} -
    - )} -
  • - ))} -
- ) :

{VT.notesNone}

} -
- - )} - -
- - {outside.length > 0 &&

{VT.outsideTextNote}

} -
- - {!simple && ( - -

- {usage.length ? VT.aiUsage(usage.length, formatTokens(tokens), formatCost(cost)) : VT.aiNone} -

-
- )} - - -
- ) -} - -/** 等宽的原文(原始清单、配方 JSON、核对的 SQL),默认收起:给要逐字核对的人看,带复制 */ -function RawJson({ summary, value, attr }: { summary: string; value: unknown; attr: string }) { - const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) - return ( -
- {summary} -
-
-          {text}
-        
- -
-
- ) -} diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index 201262ac..0a71294f 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -27,6 +27,7 @@ import type { } from '../types' import { ArtifactViewer, ResultTable } from './AssistantStream' import { useEvidenceHost } from './evidenceHost' +import { EvidenceProvenance } from './EvidenceProvenance' import { CopyChip } from './Markdown' /** @@ -58,6 +59,9 @@ import { CopyChip } from './Markdown' * 裁判拆档(JR):证据不足的写明缺什么;裁判模型和写作模型相同、模型不在价格表里时在「模型的解释」下提醒; * 表名实体步骤列出字段清单;讲方法、讲结构的句子在「挂的依据」下直接列出 SQL 和字段清单。 * + * Excel 导入期 4:上传表格的单元格多三节——数据版本、推断的来源、相关核对(EvidenceProvenance)。只在片段是单元格 + * 引用、且片段接口的查询步骤带 provenance: true 时请求推断来源接口;推断来源的缓存只在面板开着期间有效。 + * * 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 * 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。 * 都不是模态的:开着面板照样能在正文里走。 @@ -82,6 +86,10 @@ export function EvidencePanel({ id, mode, doc, artifact, runId, runClass, view, onViolations: () => void }) { const titleId = `${id}-title` + // 推断的来源只在面板这次打开期间缓存:回答里有当前状态(原件已清除、接受已作废)和取决于数据文件现状的结论, + // 关掉面板(卸载)或换了运行就作废,再打开重新取。同一次打开里来回点同一片段只取一次 + const holdProvenance = useEvidence((s) => s.holdProvenance) + useEffect(() => (runId ? holdProvenance(runId) : undefined), [runId, holdProvenance]) const found = useMemo(() => (view.kind === 'seg' ? findSeg(doc, view.id) : null), [doc, view]) const claimAt = useMemo(() => (view.kind === 'unit' ? findUnit(doc, view.id) : null), [doc, view]) const overlay = useVerdicts(runId, doc.node_id || undefined) @@ -247,6 +255,10 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block && (cite?.kind === 'cell' || entry?.kind === 'query' || !!queries.length) const bad = state === 'none' || state === 'suspect' || state === 'unverified' const closest = closestNames(detail?.closest ?? entityStep?.closest) + // 期 4:推断的来源。条件写死成「片段是单元格引用、它的查询步骤带了提示」:期 4 之前的运行、手工源(查询步骤没有 + // 提示)、指标片段(cite.kind 是 metric,哪怕它链上的查询步骤误带了提示)一个请求都不多发。接口答的是另一份报告 + // (docForeign)时 detail 为空、链是空的,也就不请求 + const provenanceOn = !!runId && cite?.kind === 'cell' && queries.some((q) => q.provenance === true) // 画布右栏:点开片段时把证据路径交给画布(产出证据的节点实线、报告虚线),链取回来后再补全 const host = useEvidenceHost() @@ -386,6 +398,8 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block ))} + {provenanceOn && runId && } + {state === 'deterministic' && !isMetric && !isInput && !isCell && !isEntity && !isQuote && (

{sourceOf(seg, doc) || detail?.note}

)} diff --git a/frontend/src/run/EvidenceProvenance.tsx b/frontend/src/run/EvidenceProvenance.tsx new file mode 100644 index 00000000..b9de78e0 --- /dev/null +++ b/frontend/src/run/EvidenceProvenance.tsx @@ -0,0 +1,489 @@ +import { useEffect, useId, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { AlertTriangle, ChevronRight, FileText } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api } from '../api/client' +import { ErrorState, Modal, Skeleton } from '../components/ui' +import { NONE, formatDateTime } from '../lib/format' +import { EVIDENCE_TEXT, RAW_STATE_LABEL, VERSIONS_TEXT } from '../lib/terms' +import { ManifestBody } from '../pages/import/ManifestBody' +import { segmentKey, useEvidence } from '../store/evidence' +import type { + EvidenceProvenance as Provenance, ManifestOut, ProvenanceCellSource, ProvenanceCheck, ProvenanceFromCell, ProvenancePart, + ProvenanceVersion, +} from '../types' +import { CopyChip } from './Markdown' + +/** + * 证据面板里的「数据版本」「推断的来源」「相关核对」三节(Excel 导入期 4,P4-SPEC 4.1–4.3)。 + * + * 上传表格的单元格:这次查询查的是哪一版数据(各期的统计期、文件、原件状态、写过的接受理由),这一格来自原表的 + * 哪个工作表、哪一格(日期、年份、行标签、分段标题各取自哪里),以及这一行、这一格的核对结果。 + * + * 数据都来自推断来源接口(按需算,面板打开那个片段时才算)。调用方只在片段是单元格引用、且片段接口的查询步骤 + * 带 provenance: true 时挂这个组件,所以期 4 之前的运行、手工源、指标片段一个请求都不多发。 + * + * 三节分两种性质,界面分开标注: + * - 数据版本来自封存链上的导入清单(内容寻址),不加「推断」标注; + * - 推断的来源、相关核对是先按主键认出这一行再推出来的,挂「推断,不属于已封存的证据」,用虚线左边框和 + * 别的几节区分。封存那一行(verdict)不受它影响,推断的来源不计入「已封存 · 核对一致」。 + * + * 标题用

(读屏按标题跳得到),视觉照抄面板里 Part 的小标题;Part 本身不动——改它会改变期 4 之前所有面板的 + * DOM。标红的提示用自己的属性 data-ev-prov-alert,不用 data-ev-integrity:现有断言数过后者的个数。 + * 折叠、展开不加动效(P4-SPEC 4.3)。 + */ + +const P = EVIDENCE_TEXT.provenance + +/** 小标题的样式:同 EvidencePanel 的 Part(mb-1 text-2xs font-medium text-faint),只是换成真正的标题元素 */ +const HEAD = 'text-2xs font-medium text-faint' + +/** 期数多于这个数时折叠:只展开这一格所在的一期和最近一期 */ +const FOLD_AT = 3 + +/** 原件 sha256 显示前几位(P4-SPEC 4.1:前 12 位) */ +const SHA_PREFIX = 12 + +/** + * 导入、清除、作废记录的时间。没记(契约里 at 可以是 null)或认不出时给空串,由调用方略去这一项:formatDateTime + * 拿不到时间时返回「—」,它是真值,filter(Boolean) 去不掉,会在「原件已清除 · — · 理由:…」里留下一个孤立的「—」 + */ +function stamp(at: string | null | undefined): string { + const text = formatDateTime(at) + return text === NONE ? '' : text +} + +export function EvidenceProvenance({ runId, segId, report }: { runId: string; segId: string; report?: string }) { + const key = segmentKey(runId, segId, report) + const slot = useEvidence((s) => s.provenance[key]) + const load = useEvidence((s) => s.loadProvenance) + // 槽没了就再取:面板开着时不会被清(清缓存只在最后一个面板关掉以后),这里只是不让一节永远停在「正在推断」 + const missing = !slot + useEffect(() => { + if (missing) void load(runId, segId, report) + }, [missing, runId, segId, report, load]) + + if (!slot || slot.status === 'loading') { + // 取的过程中只画这一节的加载态,不先画三节空壳 + return ( + +

{P.loading}

+
+ ) + } + if (slot.status === 'error' || !slot.data || typeof slot.data !== 'object') { + // 老服务端没有这个接口(404、405 且没有机读码):三节都不画,和期 4 之前一样 + if (oldServer(slot.error)) return null + return ( + +

{P.failed}

+
+ ) + } + // 按片段换实例:展开的「技术细节」「另有 n 期」是这一格的状态,点到别的格子时收起 + return +} + +function oldServer(error: unknown): boolean { + return error instanceof ApiError && (error.status === 404 || error.status === 405) && !error.code +} + +/** 三节画不画、推断的来源那一节画什么(P4-SPEC 4.1 的渲染矩阵) */ +interface Plan { + version: boolean + source: 'none' | 'reason' | 'alert' | 'cell' + checks: boolean +} + +const NOTHING: Plan = { version: false, source: 'none', checks: false } + +/** + * 渲染矩阵。表外的组合不该出现(服务端的契约检查会拦),出现时按「table_only、无 alert」画:给出数据版本(有的话) + * 和一句原因。alert 只在矩阵里列出的组合下画成红色 + */ +function planOf(d: Provenance): Plan { + const code = d.reason?.code + const alert = d.alert?.code + if (d.status === 'none') { + if (!alert && (code === 'legacy_doc' || code === 'not_upload' || code === 'not_cell')) return NOTHING + if (!alert && (code === 'not_sealed' || code === 'simple_upload')) return { version: false, source: 'reason', checks: false } + if (alert === 'manifest_unreadable' || alert === 'chain_mismatch') return { version: false, source: 'alert', checks: false } + } else if (d.status === 'table_only') { + if (alert === 'db_tampered' || alert === 'chain_mismatch') return { version: true, source: 'alert', checks: false } + if (!alert) return { version: true, source: 'reason', checks: false } + } else if (d.status === 'inferred') { + if (!alert && d.cell_source) return { version: true, source: 'cell', checks: true } + } + return { version: true, source: 'reason', checks: false } +} + +function Answer({ d }: { d: Provenance }) { + const plan = planOf(d) + const version = plan.version && d.version ? d.version : null + const [manifest, setManifest] = useState(null) + if (plan.source === 'none') return null + return ( + <> + {version && } + + {plan.source === 'alert' && d.alert && ( + // 标红:样式同 IntegrityList(失败色的框),放在这一节最前面;有它时不再画 reason.text +

+ + {d.alert.text} +

+ )} + {plan.source === 'reason' && d.reason?.text && ( +

{d.reason.text}

+ )} + {plan.source === 'cell' && d.cell_source && } +
+ {plan.checks && Array.isArray(d.checks) && d.checks.length > 0 && } + {manifest && createPortal( + // 弹窗挂到 body 上:侧边面板是 fixed 的一层,里面的遮罩盖不住整页(同「打开完整快照」) + setManifest(null)} />, + document.body, + )} + + ) +} + +/** 「推断,不属于已封存的证据」:推断的来源、相关核对两节的标题旁都挂 */ +function Badge() { + return ( + + {P.badge} + + ) +} + +/** 推断出来的一节:虚线左边框,标题旁挂「推断」标签 */ +function Inferred({ title, children, ...rest }: { title: string; children: ReactNode } & Record<`data-${string}`, string>) { + return ( +
+
+

{title}

+ +
+ {children} +
+ ) +} + +/** 推断的来源一节的外壳:data-ev-provenance 写这一节的状态(inferred / table_only / none,取的过程中 loading,出错 error) */ +function SourceShell({ state, children }: { state: string; children: ReactNode }) { + return {children} +} + +// --------------------------------------------------------------------------- +// 数据版本 +// --------------------------------------------------------------------------- + +function VersionPart({ v, sealed, onManifest }: { + v: ProvenanceVersion; sealed: boolean; onManifest: (part: ProvenancePart) => void +}) { + const [open, setOpen] = useState(false) + const moreId = useId() + const parts = Array.isArray(v.parts) ? v.parts : [] + // 多于 3 期:这一格所在的一期和最近一期展开,其余收进「另有 n 期」 + const fold = parts.length > FOLD_AT + const latest = parts[parts.length - 1] + const keep = parts.filter((p) => p.has_row || p === latest) + const folded = fold ? parts.length - keep.length : 0 + const shown = fold && !open ? keep : parts + const tables = (Array.isArray(v.tables) ? v.tables : []).filter((t) => t && t.name) + return ( +
+

{P.version}

+ {!sealed && ( + // 封存那一行已经照实说了状态,这里只是不让「来自封存链」的说法越界:dim 色,不标红 +

{P.unsealed}

+ )} +

{P.versionHead(v.source, v.mode, parts.length)}

+ {tables.length > 0 && ( +

+ {P.tables(tables.map((t) => (t.kind === 'reported_total' ? P.totalTable(t.name) : t.name)))} +

+ )} +
    + {shown.map((p) => ( + + ))} +
+ {fold && folded > 0 && ( + // 展开后按钮还在、焦点留在它上面(同一个元素),再按一次收起 + + )} + {v.manifest_view === false && ( + // 清单里有核对细节里的数、标签原文、区域外文字:数据源设了遮罩时面板自己的入口不多暴露 +

{P.manifestMasked}

+ )} +
+ ) +} + +/** 当前状态(取自导入记录,不在封存范围内):作废、清除后面加这枚小字 */ +function Current() { + return {P.current} +} + +function PartRow({ p, manifest, onManifest }: { + p: ProvenancePart; manifest: boolean; onManifest: (part: ProvenancePart) => void +}) { + const period = p.period && p.period.start && p.period.end ? p.period : null + const purged = p.raw_state === 'purged' || !!p.purged + const sha = typeof p.raw_sha256 === 'string' ? p.raw_sha256.slice(0, SHA_PREFIX) : '' + const committed = stamp(p.committed_at) + const facts = [ + p.file_name ? P.file(p.file_name, sha) : '', + p.region ? P.region(p.region, p.excluded_rows) : p.excluded_rows ? P.excluded(p.excluded_rows) : '', + period?.source === 'human' ? `${VERSIONS_TEXT.periodHuman}(${VERSIONS_TEXT.signedBy(period.signed_by || VERSIONS_TEXT.unsigned)})` : '', + // 清除过的另起一行写清除记录和「当前状态」,这里不重复 + !purged && p.raw_state ? RAW_STATE_LABEL[p.raw_state] ?? '' : '', + committed ? P.committedAt(committed) : '', + p.recipe_seq != null ? VERSIONS_TEXT.recipeSeq(p.recipe_seq) : '', + p.signed_by ? VERSIONS_TEXT.signedBy(p.signed_by) : '', + ].filter(Boolean) + const accepts = Array.isArray(p.acceptances) ? p.acceptances : [] + return ( +
  • +
    + {period ? P.part(p.seq, period.start, period.end) : P.partNoPeriod(p.seq)} + {p.has_row && ( + {P.hasRow} + )} +
    + {facts.length > 0 &&

    {facts.join(' · ')}

    } + {purged && ( +

    + {RAW_STATE_LABEL.purged} + {p.purged ? ` · ${P.stateDetail(stamp(p.purged.at), p.purged.reason, p.purged.signed_by)}` : ''} + +

    + )} + {p.revoked && ( +

    + {P.revoked} · {P.stateDetail(stamp(p.revoked.at), p.revoked.reason, p.revoked.signed_by)} + +

    + )} + {accepts.length > 0 && ( + // 接受理由在清单里,是当时的事实:作废之后照旧列出 +
      + {accepts.map((a, i) => ( +
    • + {P.acceptedLine(a.title || a.check_id, a.reason, a.signed_by)} +
    • + ))} +
    + )} + {manifest && p.manifest && ( + + )} +
  • + ) +} + +/** + * 查看导入清单:版本页的清单渲染(ManifestBody),内容按清单的工件 id 经工件接口取(内容寻址,取回时复验哈希)。 + * 不用 ArtifactViewer:它对非表格内容整份 JSON 输出,遮罩不起作用,64 KB 的清单也没法读 + */ +function ManifestDialog({ part, onClose }: { part: ProvenancePart; onClose: () => void }) { + const [state, setState] = useState<{ id: string; content?: unknown; error?: unknown } | null>(null) + useEffect(() => { + let alive = true + setState({ id: part.manifest }) + api.artifact(part.manifest).then( + (res) => { if (alive) setState({ id: part.manifest, content: res?.content }) }, + (error) => { if (alive) setState({ id: part.manifest, error }) }, + ) + return () => { alive = false } + }, [part.manifest]) + const mine = state?.id === part.manifest ? state : null + const m: ManifestOut | null = mine?.content && typeof mine.content === 'object' + ? { artifact_id: part.manifest, verified: true, kind: 'import_manifest', content: mine.content as Record } + : null + return ( + + {mine?.error + ? + : !m + ? + : ( +
    + +
    + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// 推断的来源:格子与逐条 +// --------------------------------------------------------------------------- + +function CellPart({ cs, version }: { cs: ProvenanceCellSource; version: ProvenanceVersion | null }) { + const [tech, setTech] = useState(false) + const techId = useId() + const parts = version?.parts ?? [] + const part = parts.find((p) => p.has_row) ?? parts.find((p) => p.seq === cs.part_seq) + const items = fromItems(cs) + const recheck = cs.recheck + const sql = recheck?.sql ? (recheck.params?.length ? `${recheck.sql}\n-- ${JSON.stringify(recheck.params)}` : recheck.sql) : '' + return ( + <> +

    {P.cell(cs.sheet, cs.cell)}

    + {parts.length > 1 && ( + // 多期时坐标是哪一期原件的坐标:同一个位置在别的期的文件里是另一个数 +

    + {P.fromPart(cs.part_seq, part?.file_name ?? null)} +

    + )} + {items.length > 0 && ( +
      + {items.map((it) =>
    • {it.text}
    • )} +
    + )} + {cs.merged_fill && ( +

    {P.mergedFill}

    + )} + {cs.raw_purged && ( +

    {P.rawPurged}

    + )} + {(recheck?.ok || sql) && ( +
    + {recheck?.ok && {P.recheck}} + {sql && ( + + )} +
    + )} + {sql && ( + // 收起时留在 DOM 里(hidden):按钮的 aria-controls 始终指得到它 + + )} + + ) +} + +interface Item { key: string; kind: string; text: string } + +/** + * 逐条:这一行其余键列各取自哪一格,加上年份、规范写法、合计口径。顺序照 4.1:日期 → 年份 → 行标签 → 分段标题 → + * 规范写法。坐标和格子同一张工作表时只写坐标,不同时带上工作表名。列表的多行表头合成一条 + */ +function fromItems(cs: ProvenanceCellSource): Item[] { + const from: ProvenanceFromCell[] = Array.isArray(cs.from) ? cs.from.filter((f) => f && f.cell) : [] + const ref = (f: ProvenanceFromCell) => (f.sheet && f.sheet !== cs.sheet ? `${f.sheet}!${f.cell}` : f.cell) + const year = yearItem(cs) + const heads = from.filter((f) => f.role === 'col_header') + const out: Item[] = [] + let yearDone = false + from.forEach((f, i) => { + const key = `${f.role}-${i}` + if (f.role === 'axis_header') { + out.push({ key, kind: f.role, text: P.axisHeader(ref(f)) }) + if (year && !yearDone) { out.push(year); yearDone = true } + } else if (f.role === 'row_label') { + out.push({ key, kind: f.role, text: P.rowLabel(ref(f), f.text) }) + } else if (f.role === 'section_title') { + out.push({ key, kind: f.role, text: P.sectionTitle(ref(f), f.locate_title) }) + } else if (f.role === 'total_label') { + out.push({ key, kind: f.role, text: P.totalLabel(ref(f), f.text) }) + } else if (f.role === 'col_header' && f === heads[0]) { + out.push({ key, kind: f.role, text: P.colHeader(heads.map(ref).join('、')) }) + } + }) + if (year && !yearDone) out.push(year) + if (cs.canonical?.raw && cs.canonical.canonical) { + out.push({ key: 'canonical', kind: 'canonical', text: P.canonical(cs.canonical.raw, cs.canonical.canonical) }) + } + if (cs.kind === 'reported_total') out.push({ key: 'total', kind: 'reported_total', text: P.reportedTotal }) + return out +} + +function yearItem(cs: ProvenanceCellSource): Item | null { + const y = cs.year + if (!y) return null + // mixed:清单只记每块的表头形式,逐格判断不了,照实说,不断言这一格属于哪一种 + const text = y.mixed ? P.yearMixed + : y.source === 'human' ? P.yearHuman(y.signed_by || VERSIONS_TEXT.unsigned) + : P.yearPeriod((Array.isArray(y.cells) ? y.cells : []).join('、')) + return { key: 'year', kind: 'year', text } +} + +// --------------------------------------------------------------------------- +// 相关核对 +// --------------------------------------------------------------------------- + +/** 状态的颜色只用令牌:成立 / 一致是 done,不成立是 failed,未能核对、无法确定是 waiting,其余中性 */ +const TONE: Record = { + passed: 'var(--st-done)', + ok: 'var(--st-done)', + mismatch: 'var(--st-failed)', + unverifiable: 'var(--st-waiting)', + unknown: 'var(--st-waiting)', +} + +function ChecksPart({ checks }: { checks: ProvenanceCheck[] }) { + return ( + +
      + {checks.map((c, i) => )} +
    +
    + ) +} + +/** + * 一条核对:「编号 标题 — 这一行成立」(关系核对看行级结果,合计看格级结果,其余看本期结论)。本期结论不成立或未能 + * 核对时另起一行写出来,写过理由的接在后面:「本期不成立 · 已接受,理由:…」——行级成立、本期不成立(别的日子不成立、 + * 已接受)两件事都要看得到。口径不同(R2)不判行,本期结论就是「口径说明」,和别的核对同一种写法,扫读时对得齐 + */ +function CheckRow({ c }: { c: ProvenanceCheck }) { + const main: { code: string; text: string } | null = c.row_status + ? { code: c.row_status, text: P.row[c.row_status] ?? c.row_status } + : c.cell_status ? { code: c.cell_status, text: P.cellStatus[c.cell_status] ?? c.cell_status } + : null + const partText = P.partStatus[c.part_status] ?? c.part_status + // 没有行级、格级结果的(统计期、口径之外的核对)主行就写本期结论,理由接在同一行 + const lead = main ?? { code: c.part_status, text: partText } + const accepted = c.acceptance ? P.accepted(c.acceptance.reason, c.acceptance.signed_by) : '' + const showPart = !!main && (c.part_status === 'mismatch' || c.part_status === 'unverifiable') + const second = main ? [showPart ? partText : '', accepted].filter(Boolean).join(' · ') : '' + return ( +
  • +

    + {c.id} + {c.title} + {' — '} + {lead.text} + {!main && accepted && · {accepted}} +

    + {second &&

    {second}

    } + {c.detail &&

    {c.detail}

    } +
  • + ) +} diff --git a/frontend/src/run/__tests__/evidence-provenance.json b/frontend/src/run/__tests__/evidence-provenance.json new file mode 100644 index 00000000..6175aed9 --- /dev/null +++ b/frontend/src/run/__tests__/evidence-provenance.json @@ -0,0 +1,14118 @@ +{ + "_comment": "期 4「推断的来源」的前端夹具(P4-SPEC 4.4、6.1、7.6),WP-E 在提交 d3879a5 的代码上用真代码重新生成,生成脚本 /Users/yilunjiang/.claude/jobs/842f6f9a/tmp/excel/p4-golden/gen_provenance.py(仓库外,WP-0 的手工版是同目录的 gen_fixture.py)。文档由 compose_doc 真跑(自带 \"provenance\": 1),只把 run_id 换成夹具的 id、重算工件 id;segments 是真片段接口的答复(单元格查询步骤的 provenance: true 由服务端真给),手工改的只有两处:指标片段链上的查询步骤加 provenance: true(第 20 条,模拟提示放错位置),unsealed 那一格的 seal 改成未封存;provenance 是 api.evidence.segment_provenance 真跑的推断来源答复(封存范围取真运行的,unsealed 那一格用改成未封存的副本;tampered、chain、gone、unreadable 在算之前真改数据文件、登记哈希、文件位置、导入清单,算完还原;purged、revoked 经接口真清除原件、真作废接受),推断出的格子逐个与 openpyxl 读到的合成夹具对照过;按片段 id 取,manual、metric 两格没有(前端不该请求)。cases:场景 → 片段 id、片段文字(检查脚本按文字认片段)、覆盖 4.4 的哪几条、说明。errors:模拟出错的答复。artifacts:「查看导入清单」经 /api/artifacts/{id} 取的内容,只收了 wide(flow_demo)、list(list_demo)两个源的清单,其余清单 id 请求时回 404。只用假名、合成数据,没有调用真实模型。", + "run_id": "run-evidence-provenance", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0", + "doc": { + "blocks": [ + { + "id": "b0", + "level": 2, + "type": "heading", + "units": [ + { + "cites": [], + "id": "u0", + "kind": "heading", + "see": [], + "segments": [ + { + "id": "s0", + "kind": "structural", + "span": [ + 0, + 3 + ], + "state": "neutral", + "text": "## " + }, + { + "id": "s1", + "kind": "text", + "span": [ + 3, + 9 + ], + "state": "neutral", + "text": "推断来源演示" + } + ], + "span": [ + 3, + 9 + ] + } + ] + }, + { + "id": "b1", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q1" + ], + "id": "u1", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s2", + "kind": "structural", + "span": [ + 9, + 11 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s3", + "kind": "text", + "span": [ + 11, + 29 + ], + "state": "neutral", + "text": "2026-08-05 的全日客流为 " + }, + { + "cite": { + "alias": "Q1", + "eid": "ev:cell:0c83aa90260f0ffc", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q1.r0.全日客流", + "rendered": "9,470", + "role": "value", + "status": "resolved", + "value": 9470 + }, + "id": "s4", + "kind": "number", + "ref": "v:Q1.r0.全日客流", + "span": [ + 29, + 34 + ], + "state": "deterministic", + "text": "9,470" + }, + { + "id": "s5", + "kind": "text", + "span": [ + 34, + 38 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 11, + 38 + ] + } + ] + }, + { + "id": "b2", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q2" + ], + "id": "u2", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s6", + "kind": "structural", + "span": [ + 38, + 40 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s7", + "kind": "text", + "span": [ + 40, + 60 + ], + "state": "neutral", + "text": "2026-08-20 午间时段的客流为 " + }, + { + "cite": { + "alias": "Q2", + "eid": "ev:cell:e4f5cfbe3b513cc1", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q2.r0.客流", + "rendered": "791", + "role": "value", + "status": "resolved", + "value": 791 + }, + "id": "s8", + "kind": "number", + "ref": "v:Q2.r0.客流", + "span": [ + 60, + 63 + ], + "state": "deterministic", + "text": "791" + }, + { + "id": "s9", + "kind": "text", + "span": [ + 63, + 67 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 40, + 67 + ] + } + ] + }, + { + "id": "b3", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q3" + ], + "id": "u3", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s10", + "kind": "structural", + "span": [ + 67, + 69 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s11", + "kind": "text", + "span": [ + 69, + 89 + ], + "state": "neutral", + "text": "2026-08-10 晚间合计的客流为 " + }, + { + "cite": { + "alias": "Q3", + "eid": "ev:cell:4485a6e0424ffdf2", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q3.r0.客流", + "rendered": "2,417", + "role": "value", + "status": "resolved", + "value": 2417 + }, + "id": "s12", + "kind": "number", + "ref": "v:Q3.r0.客流", + "span": [ + 89, + 94 + ], + "state": "deterministic", + "text": "2,417" + }, + { + "id": "s13", + "kind": "text", + "span": [ + 94, + 98 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 69, + 98 + ] + } + ] + }, + { + "id": "b4", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q4" + ], + "id": "u4", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s14", + "kind": "structural", + "span": [ + 98, + 100 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s15", + "kind": "text", + "span": [ + 100, + 125 + ], + "state": "neutral", + "text": "按改名后的查询,2026-08-06 的分区乙为 " + }, + { + "cite": { + "alias": "Q4", + "eid": "ev:cell:5c6392c41c0ff80b", + "kind": "cell", + "locator": { + "column": "分区乙", + "row": 0 + }, + "ref": "Q4.r0.分区乙", + "rendered": "4,895", + "role": "value", + "status": "resolved", + "value": 4895 + }, + "id": "s16", + "kind": "number", + "ref": "v:Q4.r0.分区乙", + "span": [ + 125, + 130 + ], + "state": "deterministic", + "text": "4,895" + }, + { + "id": "s17", + "kind": "text", + "span": [ + 130, + 134 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 100, + 134 + ] + } + ] + }, + { + "id": "b5", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q5" + ], + "id": "u5", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s18", + "kind": "structural", + "span": [ + 134, + 136 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s19", + "kind": "text", + "span": [ + 136, + 162 + ], + "state": "neutral", + "text": "自连接查询得到 2026-08-07 的全日客流为 " + }, + { + "cite": { + "alias": "Q5", + "eid": "ev:cell:342109e8b74aac53", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q5.r0.全日客流", + "rendered": "13,202", + "role": "value", + "status": "resolved", + "value": 13202 + }, + "id": "s20", + "kind": "number", + "ref": "v:Q5.r0.全日客流", + "span": [ + 162, + 168 + ], + "state": "deterministic", + "text": "13,202" + }, + { + "id": "s21", + "kind": "text", + "span": [ + 168, + 172 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 136, + 172 + ] + } + ] + }, + { + "id": "b6", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q6" + ], + "id": "u6", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s22", + "kind": "structural", + "span": [ + 172, + 174 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s23", + "kind": "text", + "span": [ + 174, + 194 + ], + "state": "neutral", + "text": "2026-08-03 早间时段的客流为 " + }, + { + "cite": { + "alias": "Q6", + "eid": "ev:cell:257aca4bafc14be9", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q6.r0.客流", + "rendered": "—", + "role": "value", + "status": "resolved", + "value": null + }, + "id": "s24", + "kind": "value", + "ref": "v:Q6.r0.客流", + "span": [ + 194, + 195 + ], + "state": "deterministic", + "text": "—" + }, + { + "id": "s25", + "kind": "text", + "span": [ + 195, + 196 + ], + "state": "neutral", + "text": "。" + } + ], + "span": [ + 174, + 196 + ] + } + ] + }, + { + "id": "b7", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q7" + ], + "id": "u7", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s26", + "kind": "structural", + "span": [ + 196, + 198 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s27", + "kind": "text", + "span": [ + 198, + 208 + ], + "state": "neutral", + "text": "本月全日客流合计为 " + }, + { + "cite": { + "alias": "Q7", + "eid": "ev:cell:5ec076e7ead5931d", + "kind": "cell", + "locator": { + "column": "合计", + "row": 0 + }, + "ref": "Q7.r0.合计", + "rendered": "342,141", + "role": "value", + "status": "resolved", + "value": 342141 + }, + "id": "s28", + "kind": "number", + "ref": "v:Q7.r0.合计", + "span": [ + 208, + 215 + ], + "state": "deterministic", + "text": "342,141" + }, + { + "id": "s29", + "kind": "text", + "span": [ + 215, + 219 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 198, + 219 + ] + } + ] + }, + { + "id": "b8", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q8" + ], + "id": "u8", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s30", + "kind": "structural", + "span": [ + 219, + 221 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s31", + "kind": "text", + "span": [ + 221, + 239 + ], + "state": "neutral", + "text": "2026-08-08 的全日客流为 " + }, + { + "cite": { + "alias": "Q8", + "eid": "ev:cell:85f7023ad8e89ce5", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q8.r0.全日客流", + "rendered": "12,409", + "role": "value", + "status": "resolved", + "value": 12409 + }, + "id": "s32", + "kind": "number", + "ref": "v:Q8.r0.全日客流", + "span": [ + 239, + 245 + ], + "state": "deterministic", + "text": "12,409" + }, + { + "id": "s33", + "kind": "text", + "span": [ + 245, + 249 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 221, + 249 + ] + } + ] + }, + { + "id": "b9", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q9" + ], + "id": "u9", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s34", + "kind": "structural", + "span": [ + 249, + 251 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s35", + "kind": "text", + "span": [ + 251, + 269 + ], + "state": "neutral", + "text": "2026-08-09 的全日客流为 " + }, + { + "cite": { + "alias": "Q9", + "eid": "ev:cell:b84f7faa98731e0d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q9.r0.全日客流", + "rendered": "9,838", + "role": "value", + "status": "resolved", + "value": 9838 + }, + "id": "s36", + "kind": "number", + "ref": "v:Q9.r0.全日客流", + "span": [ + 269, + 274 + ], + "state": "deterministic", + "text": "9,838" + }, + { + "id": "s37", + "kind": "text", + "span": [ + 274, + 278 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 251, + 278 + ] + } + ] + }, + { + "id": "b10", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q10" + ], + "id": "u10", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s38", + "kind": "structural", + "span": [ + 278, + 280 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s39", + "kind": "text", + "span": [ + 280, + 298 + ], + "state": "neutral", + "text": "2026-08-11 的全日客流为 " + }, + { + "cite": { + "alias": "Q10", + "eid": "ev:cell:fc7c02fb13513fdf", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q10.r0.全日客流", + "rendered": "12,386", + "role": "value", + "status": "resolved", + "value": 12386 + }, + "id": "s40", + "kind": "number", + "ref": "v:Q10.r0.全日客流", + "span": [ + 298, + 304 + ], + "state": "deterministic", + "text": "12,386" + }, + { + "id": "s41", + "kind": "text", + "span": [ + 304, + 308 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 280, + 308 + ] + } + ] + }, + { + "id": "b11", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q11" + ], + "id": "u11", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s42", + "kind": "structural", + "span": [ + 308, + 310 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s43", + "kind": "text", + "span": [ + 310, + 328 + ], + "state": "neutral", + "text": "2026-08-12 的全日客流为 " + }, + { + "cite": { + "alias": "Q11", + "eid": "ev:cell:d2f14c41a7a28422", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q11.r0.全日客流", + "rendered": "12,368", + "role": "value", + "status": "resolved", + "value": 12368 + }, + "id": "s44", + "kind": "number", + "ref": "v:Q11.r0.全日客流", + "span": [ + 328, + 334 + ], + "state": "deterministic", + "text": "12,368" + }, + { + "id": "s45", + "kind": "text", + "span": [ + 334, + 338 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 310, + 338 + ] + } + ] + }, + { + "id": "b12", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q12" + ], + "id": "u12", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s46", + "kind": "structural", + "span": [ + 338, + 340 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s47", + "kind": "text", + "span": [ + 340, + 358 + ], + "state": "neutral", + "text": "2026-08-13 的全日客流为 " + }, + { + "cite": { + "alias": "Q12", + "eid": "ev:cell:e82232ea3feb6c6d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q12.r0.全日客流", + "rendered": "14,552", + "role": "value", + "status": "resolved", + "value": 14552 + }, + "id": "s48", + "kind": "number", + "ref": "v:Q12.r0.全日客流", + "span": [ + 358, + 364 + ], + "state": "deterministic", + "text": "14,552" + }, + { + "id": "s49", + "kind": "text", + "span": [ + 364, + 368 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 340, + 368 + ] + } + ] + }, + { + "id": "b13", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q13" + ], + "id": "u13", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s50", + "kind": "structural", + "span": [ + 368, + 370 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s51", + "kind": "text", + "span": [ + 370, + 388 + ], + "state": "neutral", + "text": "2026-08-14 的全日客流为 " + }, + { + "cite": { + "alias": "Q13", + "eid": "ev:cell:bdf1aecce9f0c69b", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q13.r0.全日客流", + "rendered": "9,594", + "role": "value", + "status": "resolved", + "value": 9594 + }, + "id": "s52", + "kind": "number", + "ref": "v:Q13.r0.全日客流", + "span": [ + 388, + 393 + ], + "state": "deterministic", + "text": "9,594" + }, + { + "id": "s53", + "kind": "text", + "span": [ + 393, + 397 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 370, + 397 + ] + } + ] + }, + { + "id": "b14", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q14" + ], + "id": "u14", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s54", + "kind": "structural", + "span": [ + 397, + 399 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s55", + "kind": "text", + "span": [ + 399, + 424 + ], + "state": "neutral", + "text": "设有遮罩的源里,2026-08-16 的分区甲为 " + }, + { + "cite": { + "alias": "Q14", + "eid": "ev:cell:33cfbe82dd2cf30e", + "kind": "cell", + "locator": { + "column": "分区甲", + "row": 0 + }, + "ref": "Q14.r0.分区甲", + "rendered": "8,269", + "role": "value", + "status": "resolved", + "value": 8269 + }, + "id": "s56", + "kind": "number", + "ref": "v:Q14.r0.分区甲", + "span": [ + 424, + 429 + ], + "state": "deterministic", + "text": "8,269" + }, + { + "id": "s57", + "kind": "text", + "span": [ + 429, + 433 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 399, + 433 + ] + } + ] + }, + { + "id": "b15", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q15" + ], + "id": "u15", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s58", + "kind": "structural", + "span": [ + 433, + 435 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s59", + "kind": "text", + "span": [ + 435, + 464 + ], + "state": "neutral", + "text": "按期累积的源里,2026-09-12 早间第二档的客流为 " + }, + { + "cite": { + "alias": "Q15", + "eid": "ev:cell:1abd195ad8d27c62", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q15.r0.客流", + "rendered": "476", + "role": "value", + "status": "resolved", + "value": 476 + }, + "id": "s60", + "kind": "number", + "ref": "v:Q15.r0.客流", + "span": [ + 464, + 467 + ], + "state": "deterministic", + "text": "476" + }, + { + "id": "s61", + "kind": "text", + "span": [ + 467, + 471 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 435, + 471 + ] + } + ] + }, + { + "id": "b16", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q15" + ], + "id": "u16", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s62", + "kind": "structural", + "span": [ + 471, + 473 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s63", + "kind": "text", + "span": [ + 473, + 487 + ], + "state": "neutral", + "text": "这一档在原表里的时段标签为 " + }, + { + "cite": { + "alias": "Q15", + "eid": "ev:cell:7616c88cfaca6d36", + "kind": "cell", + "locator": { + "column": "时段", + "row": 0 + }, + "ref": "Q15.r0.时段", + "rendered": "8-9", + "role": "value", + "status": "resolved", + "value": "8-9" + }, + "id": "s64", + "kind": "value", + "ref": "v:Q15.r0.时段", + "span": [ + 487, + 490 + ], + "state": "deterministic", + "text": "8-9" + }, + { + "id": "s65", + "kind": "text", + "span": [ + 490, + 491 + ], + "state": "neutral", + "text": "。" + } + ], + "span": [ + 473, + 491 + ] + } + ] + }, + { + "id": "b17", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q16" + ], + "id": "u17", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s66", + "kind": "structural", + "span": [ + 491, + 493 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s67", + "kind": "text", + "span": [ + 493, + 519 + ], + "state": "neutral", + "text": "四期累积的源里,2026-09-03 的全日客流为 " + }, + { + "cite": { + "alias": "Q16", + "eid": "ev:cell:ab4ea94e92a18648", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q16.r0.全日客流", + "rendered": "9,202", + "role": "value", + "status": "resolved", + "value": 9202 + }, + "id": "s68", + "kind": "number", + "ref": "v:Q16.r0.全日客流", + "span": [ + 519, + 524 + ], + "state": "deterministic", + "text": "9,202" + }, + { + "id": "s69", + "kind": "text", + "span": [ + 524, + 528 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 493, + 528 + ] + } + ] + }, + { + "id": "b18", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q17" + ], + "id": "u18", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s70", + "kind": "structural", + "span": [ + 528, + 530 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s71", + "kind": "text", + "span": [ + 530, + 548 + ], + "state": "neutral", + "text": "2026-09-10 的全日客流为 " + }, + { + "cite": { + "alias": "Q17", + "eid": "ev:cell:ca4e9acd8e6982d1", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q17.r0.全日客流", + "rendered": "14,427", + "role": "value", + "status": "resolved", + "value": 14427 + }, + "id": "s72", + "kind": "number", + "ref": "v:Q17.r0.全日客流", + "span": [ + 548, + 554 + ], + "state": "deterministic", + "text": "14,427" + }, + { + "id": "s73", + "kind": "text", + "span": [ + 554, + 558 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 530, + 558 + ] + } + ] + }, + { + "id": "b19", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q18" + ], + "id": "u19", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s74", + "kind": "structural", + "span": [ + 558, + 560 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s75", + "kind": "text", + "span": [ + 560, + 578 + ], + "state": "neutral", + "text": "2026-09-11 的全日客流为 " + }, + { + "cite": { + "alias": "Q18", + "eid": "ev:cell:d9b282518f3c924f", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q18.r0.全日客流", + "rendered": "12,026", + "role": "value", + "status": "resolved", + "value": 12026 + }, + "id": "s76", + "kind": "number", + "ref": "v:Q18.r0.全日客流", + "span": [ + 578, + 584 + ], + "state": "deterministic", + "text": "12,026" + }, + { + "id": "s77", + "kind": "text", + "span": [ + 584, + 588 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 560, + 588 + ] + } + ] + }, + { + "id": "b20", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q19" + ], + "id": "u20", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s78", + "kind": "structural", + "span": [ + 588, + 590 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s79", + "kind": "text", + "span": [ + 590, + 608 + ], + "state": "neutral", + "text": "2026-09-12 的全日客流为 " + }, + { + "cite": { + "alias": "Q19", + "eid": "ev:cell:3c81ec162adfc84c", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q19.r0.全日客流", + "rendered": "10,722", + "role": "value", + "status": "resolved", + "value": 10722 + }, + "id": "s80", + "kind": "number", + "ref": "v:Q19.r0.全日客流", + "span": [ + 608, + 614 + ], + "state": "deterministic", + "text": "10,722" + }, + { + "id": "s81", + "kind": "text", + "span": [ + 614, + 618 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 590, + 618 + ] + } + ] + }, + { + "id": "b21", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q20" + ], + "id": "u21", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s82", + "kind": "structural", + "span": [ + 618, + 620 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s83", + "kind": "text", + "span": [ + 620, + 649 + ], + "state": "neutral", + "text": "统计期人工录入的源里,2026-09-04 的全日客流为 " + }, + { + "cite": { + "alias": "Q20", + "eid": "ev:cell:64bc6695bee8019d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q20.r0.全日客流", + "rendered": "12,731", + "role": "value", + "status": "resolved", + "value": 12731 + }, + "id": "s84", + "kind": "number", + "ref": "v:Q20.r0.全日客流", + "span": [ + 649, + 655 + ], + "state": "deterministic", + "text": "12,731" + }, + { + "id": "s85", + "kind": "text", + "span": [ + 655, + 659 + ], + "state": "neutral", + "text": " 人次。" + } + ], + "span": [ + 620, + 659 + ] + } + ] + }, + { + "id": "b22", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q21" + ], + "id": "u22", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s86", + "kind": "structural", + "span": [ + 659, + 661 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s87", + "kind": "text", + "span": [ + 661, + 678 + ], + "state": "neutral", + "text": "列表形态的月报里,分区丙的金额为 " + }, + { + "cite": { + "alias": "Q21", + "eid": "ev:cell:1b53c8ba2aad7bae", + "kind": "cell", + "locator": { + "column": "金额", + "row": 0 + }, + "ref": "Q21.r0.金额", + "rendered": "80", + "role": "value", + "status": "resolved", + "value": 80 + }, + "id": "s88", + "kind": "number", + "ref": "v:Q21.r0.金额", + "span": [ + 678, + 680 + ], + "state": "deterministic", + "text": "80" + }, + { + "id": "s89", + "kind": "text", + "span": [ + 680, + 681 + ], + "state": "neutral", + "text": "。" + } + ], + "span": [ + 661, + 681 + ] + } + ] + }, + { + "id": "b23", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q22" + ], + "id": "u23", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s90", + "kind": "structural", + "span": [ + 681, + 683 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s91", + "kind": "text", + "span": [ + 683, + 703 + ], + "state": "neutral", + "text": "按表头行直接导入的表里,分区乙的金额为 " + }, + { + "cite": { + "alias": "Q22", + "eid": "ev:cell:f8f7f22b9c817ea5", + "kind": "cell", + "locator": { + "column": "金额", + "row": 0 + }, + "ref": "Q22.r0.金额", + "rendered": "27,093.25", + "role": "value", + "status": "resolved", + "value": 27093.25 + }, + "id": "s92", + "kind": "number", + "ref": "v:Q22.r0.金额", + "span": [ + 703, + 712 + ], + "state": "deterministic", + "text": "27,093.25" + }, + { + "id": "s93", + "kind": "text", + "span": [ + 712, + 713 + ], + "state": "neutral", + "text": "。" + } + ], + "span": [ + 683, + 713 + ] + } + ] + }, + { + "id": "b24", + "type": "paragraph", + "units": [ + { + "cites": [ + "Q23" + ], + "id": "u24", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s94", + "kind": "structural", + "span": [ + 713, + 715 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s95", + "kind": "text", + "span": [ + 715, + 731 + ], + "state": "neutral", + "text": "手工数据源里,分区丙的订单数为 " + }, + { + "cite": { + "alias": "Q23", + "eid": "ev:cell:9cdbeaf3b2daf015", + "kind": "cell", + "locator": { + "column": "订单数", + "row": 0 + }, + "ref": "Q23.r0.订单数", + "rendered": "301", + "role": "value", + "status": "resolved", + "value": 301 + }, + "id": "s96", + "kind": "number", + "ref": "v:Q23.r0.订单数", + "span": [ + 731, + 734 + ], + "state": "deterministic", + "text": "301" + }, + { + "id": "s97", + "kind": "text", + "span": [ + 734, + 737 + ], + "state": "neutral", + "text": " 单。" + } + ], + "span": [ + 715, + 737 + ] + } + ] + }, + { + "id": "b25", + "type": "paragraph", + "units": [ + { + "cites": [ + "m:day_total" + ], + "id": "u25", + "kind": "claim", + "see": [], + "segments": [ + { + "id": "s98", + "kind": "structural", + "span": [ + 737, + 739 + ], + "state": "neutral", + "text": "\n\n" + }, + { + "id": "s99", + "kind": "text", + "span": [ + 739, + 753 + ], + "state": "neutral", + "text": "口径卡记录的单日全日客流为 " + }, + { + "cite": { + "alias": "m:day_total", + "eid": "ev:metric:247b17af424701d7", + "kind": "metric", + "locator": { + "metric": "day_total" + }, + "ref": "day_total", + "rendered": "9,784人次", + "role": "value", + "status": "resolved", + "value": 9784 + }, + "id": "s100", + "kind": "number", + "ref": "m:day_total", + "span": [ + 753, + 760 + ], + "state": "deterministic", + "text": "9,784人次" + }, + { + "id": "s101", + "kind": "text", + "span": [ + 760, + 761 + ], + "state": "neutral", + "text": "。" + } + ], + "span": [ + 739, + 761 + ] + } + ] + } + ], + "catalog": { + "Q1": { + "alias": "Q1", + "artifact": "bd36f80fcde134894729d9ac6e5fd6672a8372b397f35895eded29103b53f214", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:461109692514ccea", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_wide", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "f66e44241e40558e9fcd43bc315314df5d63c2c1f959e40d55637873351c1420" + }, + "Q10": { + "alias": "Q10", + "artifact": "83a8efbdefb8caf41fca1ecd1cb3604dcff5bdf701aa52fe400a56dc5b93582e", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:68fe7b40d36c2838", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_purged", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "d930f2dafdd913399be7d185fe74433a5b2e66b264314d5feacfa57803b0c249" + }, + "Q11": { + "alias": "Q11", + "artifact": "fed1edcaaa5c49e27fd581973bbdea6253a042088e2761f1b06081a4c5d99b20", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:e42d8187f8707a6d", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_unreadable", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "3251fcd5386760ecbe4f11e205419748f1c13149912c36b65f0fc0258072ab9b" + }, + "Q12": { + "alias": "Q12", + "artifact": "8b9ea1c080e043372d401d0ba8df19f34865f9dbad07e1ceee5f83174285b4ff", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:b3ad9163d991dccc", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_gone", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "8a47f2de6885cba79f74ca16155b33a85da820705367ac136ea4413f5676521e" + }, + "Q13": { + "alias": "Q13", + "artifact": "4d916a46e3dfb26b760ad4b2caf9dc6fb3a6a3755a2542df6f6ba13307046540", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:1267b5018b529ede", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_unsealed", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "1874b5fe0fe180cf1930528a0d81c7e1c24e96070c11c31e0d0dd6a01a02eaaa" + }, + "Q14": { + "alias": "Q14", + "artifact": "911e42023db9fd9a187758820798d20fd295233c4b0b588b5025c330ee9973fd", + "columns": [ + "日期", + "分区甲" + ], + "eid": "ev:query:2045901016d7f09e", + "exec": 1, + "kind": "query", + "label": "db_query__flow_masked · 1 行", + "locator": {}, + "node_id": "q_masked", + "rows": 1, + "source": "flow_masked", + "tool": "db_query__flow_masked", + "truncated": false, + "via": "d70a5d1cdcaa43d2db7a10022480bab393bdd594d99297ecc0ac5fd7a8c044f6" + }, + "Q15": { + "alias": "Q15", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb", + "columns": [ + "日期", + "时段", + "客流" + ], + "eid": "ev:query:605e84d8f2407530", + "exec": 1, + "kind": "query", + "label": "db_query__flow_acc · 1 行", + "locator": {}, + "node_id": "q_union", + "rows": 1, + "source": "flow_acc", + "tool": "db_query__flow_acc", + "truncated": false, + "via": "e9a2ee7222a6e31ecbdea633e0d9a1e8fd33e74e0dd27bc44b617a0dd24754bf" + }, + "Q16": { + "alias": "Q16", + "artifact": "5edc5015fe959e59a2133d9b3e2d169da0113a1a7c1f255cc11648c51c8aa43c", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:8ea8e9ebfcbbe79b", + "exec": 1, + "kind": "query", + "label": "db_query__flow_many · 1 行", + "locator": {}, + "node_id": "q_many", + "rows": 1, + "source": "flow_many", + "tool": "db_query__flow_many", + "truncated": false, + "via": "296f6015550164820a800784ef8b3d08aadb24f88e49afad40317844e53e4ac2" + }, + "Q17": { + "alias": "Q17", + "artifact": "cedf48c0160bbbf2a17bec5905c94c27056f4a15ba0854927a0b569ba982a664", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:9e0ed1b61448b6f7", + "exec": 1, + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "locator": {}, + "node_id": "q_d26_bad", + "rows": 1, + "source": "flow_d26", + "tool": "db_query__flow_d26", + "truncated": false, + "via": "7c58bd14fe880854bb13682ad9fb975fe06d51c347aabd6207e5390cec8a35cc" + }, + "Q18": { + "alias": "Q18", + "artifact": "3332ffbb2a6c6839787ce53f9aed8587b14395c608fe13eaf87d09283a1dcb57", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:c569028918b691d3", + "exec": 1, + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "locator": {}, + "node_id": "q_d26_other", + "rows": 1, + "source": "flow_d26", + "tool": "db_query__flow_d26", + "truncated": false, + "via": "4196d0bfbbae7f801fd0a731d76d617090e3fb525bef45e255cbe87bc8a9572c" + }, + "Q19": { + "alias": "Q19", + "artifact": "bb0d464f15f1cb725b03abc3eeca500184512dcf3bac80123b5744a4fbf833d1", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:4f439a41386dfcba", + "exec": 1, + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "locator": {}, + "node_id": "q_revoked", + "rows": 1, + "source": "flow_d26", + "tool": "db_query__flow_d26", + "truncated": false, + "via": "87c3d18de56ad314bb736c13b866d791945b509c512db48a4b862b824ecc4d44" + }, + "Q2": { + "alias": "Q2", + "artifact": "e6a1d5c1d30a980bc01fce6bb06751f1ff2ff2eafb9268ea5dd88e216e1539fb", + "columns": [ + "日期", + "时段", + "客流" + ], + "eid": "ev:query:b9e00219bcef0461", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_long", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "83003cd6454557dfbae0a0aec6427ea152d43e6c55be00bf7ef6ec8a5c5657f6" + }, + "Q20": { + "alias": "Q20", + "artifact": "83773f6133ea865a319c5277f58e5631ee1507d8700a17c87ff20adf1615d232", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:7545c591d8356e06", + "exec": 1, + "kind": "query", + "label": "db_query__flow_human · 1 行", + "locator": {}, + "node_id": "q_human", + "rows": 1, + "source": "flow_human", + "tool": "db_query__flow_human", + "truncated": false, + "via": "a0b9ca2e47516712ff50034129bec5e19c15bdaac165c9eb5a12a20b802f352c" + }, + "Q21": { + "alias": "Q21", + "artifact": "0259c6e639da59e0346476e38c24f0f503aebb6c7f2b18ba07350ba9e7a59edc", + "columns": [ + "地区", + "金额" + ], + "eid": "ev:query:40dd19f3546c59a3", + "exec": 1, + "kind": "query", + "label": "db_query__list_demo · 1 行", + "locator": {}, + "node_id": "q_list", + "rows": 1, + "source": "list_demo", + "tool": "db_query__list_demo", + "truncated": false, + "via": "3a5b963926026eb982772a4952190b4146e099942db708e77f6536e68786b903" + }, + "Q22": { + "alias": "Q22", + "artifact": "c41e21a9be2a49971b36661e7426a3b42cdbe9eea8d1bccec9553bd7cff617e7", + "columns": [ + "地区", + "金额" + ], + "eid": "ev:query:4adde5025a04727a", + "exec": 1, + "kind": "query", + "label": "db_query__table_demo · 1 行", + "locator": {}, + "node_id": "q_simple", + "rows": 1, + "source": "table_demo", + "tool": "db_query__table_demo", + "truncated": false, + "via": "551d809090a80346bcab16626187aeef91e0325f4b5399a66d6e3d48f7133404" + }, + "Q23": { + "alias": "Q23", + "artifact": "2edcd3df50d82d8c103211ee0598ed137f44528313c826a09bce42570a7e9d80", + "columns": [ + "地区", + "订单数" + ], + "eid": "ev:query:6ee67a7ace8d8ce1", + "exec": 1, + "kind": "query", + "label": "db_query__manual_demo · 1 行", + "locator": {}, + "node_id": "q_manual", + "rows": 1, + "source": "manual_demo", + "tool": "db_query__manual_demo", + "truncated": false, + "via": "b0eb1ad69908f1f404102cac15da533ba42f7a28ac8e04fd6fc987f4bf9a2610" + }, + "Q24": { + "alias": "Q24", + "artifact": "8e72406258b1841c360b7a5ffc704def507eb31ff51a4db2ed4bd727096c85c8", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:cac211319df3ce53", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_metric", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "dfdb8400161660b537b9163bfa19ac5bcaa71fa33cca3a589907c6e8556ee666" + }, + "Q3": { + "alias": "Q3", + "artifact": "e456192e79f5afb57e1d1a4432a00a56b407923e516766672f2e79a5ceedff0d", + "columns": [ + "日期", + "合计项", + "客流" + ], + "eid": "ev:query:310a903ecb071cba", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_total", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "2794a3f4eef50b5b5b15a57a26a7a2e7f47ab0da535e8ee1d7ccc77a43da5bab" + }, + "Q4": { + "alias": "Q4", + "artifact": "fd85df479244c81bb02d928ffd569363ce30743163e7570ea2aa3bf2f507ecea", + "columns": [ + "日期", + "分区乙" + ], + "eid": "ev:query:bee79ac68a2e3e7e", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_alias", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "f3d63f561d592293388fd5ce1f4b482787f06aa8906426c4a8ebee776cbca059" + }, + "Q5": { + "alias": "Q5", + "artifact": "a93cd2e84dd654df207140bf10bf281801eb87b0fdb8b9fe6041e152b8615d11", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:80bf9d9b41d7948b", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_selfjoin", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "8706d9a38f2e835ba88b01e5e3b94b2c07c17d0e007dc97ae0034097a70c31c3" + }, + "Q6": { + "alias": "Q6", + "artifact": "d4faa6aa33e1ec0de37162a07b82c3ef2c26b1353ec84e02aa8e204790782c3c", + "columns": [ + "日期", + "时段", + "客流" + ], + "eid": "ev:query:53d9334b5bcc1dc7", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_null", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "00ae0a2ac87ca3cb33a4b670c355a969d142a08e94e791c42def5ef2d0b06905" + }, + "Q7": { + "alias": "Q7", + "artifact": "de8ac38b6749d0e459bde7eccf679f43fe79e1bd281cd09519e2a596296aaca3", + "columns": [ + "合计", + "天数" + ], + "eid": "ev:query:1193b2937bd20ef8", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_aggregate", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "eb1a3feeafb1c40a4f318d846574f01c2cae245aad150449ef8d7e182bfb27a6" + }, + "Q8": { + "alias": "Q8", + "artifact": "751ed12d5c97620b0c5ee6b0c27331f897f40f5934abf45cb80abceeb191b9ee", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:3da9e67f2198424d", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_tampered", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "ea4c64d89df76648c083e35235b8b0bc7ecb06092c3aef2fe2b911d6670a6850" + }, + "Q9": { + "alias": "Q9", + "artifact": "ebdf3b88d9111534279091ba729243b3bd8792ee812b7438309745509a645391", + "columns": [ + "日期", + "全日客流" + ], + "eid": "ev:query:c78fc45d0653d735", + "exec": 1, + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "locator": {}, + "node_id": "q_chain", + "rows": 1, + "source": "flow_demo", + "tool": "db_query__flow_demo", + "truncated": false, + "via": "5353ad8750374fb96351603649c70de05b564144f2fd42e97635de812dd58d31" + }, + "m:day_total": { + "alias": "m:day_total", + "artifact": "3cff2cf75142270256023d22841feca77d30e6604fdfa579ab0fec74e5194785", + "caliber": "客流口径", + "decimals": 0, + "eid": "ev:metric:247b17af424701d7", + "format": "thousands", + "kind": "metric", + "label": "单日全日客流 = 9,784人次", + "locator": { + "metric": "day_total" + }, + "name": "单日全日客流", + "node_id": "card", + "rendered": "9,784人次", + "status": "ok", + "unit": "人次", + "value": 9784, + "version": "v1" + } + }, + "entity_syntax": 2, + "markdown": "## 推断来源演示\n\n2026-08-05 的全日客流为 9,470 人次。\n\n2026-08-20 午间时段的客流为 791 人次。\n\n2026-08-10 晚间合计的客流为 2,417 人次。\n\n按改名后的查询,2026-08-06 的分区乙为 4,895 人次。\n\n自连接查询得到 2026-08-07 的全日客流为 13,202 人次。\n\n2026-08-03 早间时段的客流为 —。\n\n本月全日客流合计为 342,141 人次。\n\n2026-08-08 的全日客流为 12,409 人次。\n\n2026-08-09 的全日客流为 9,838 人次。\n\n2026-08-11 的全日客流为 12,386 人次。\n\n2026-08-12 的全日客流为 12,368 人次。\n\n2026-08-13 的全日客流为 14,552 人次。\n\n2026-08-14 的全日客流为 9,594 人次。\n\n设有遮罩的源里,2026-08-16 的分区甲为 8,269 人次。\n\n按期累积的源里,2026-09-12 早间第二档的客流为 476 人次。\n\n这一档在原表里的时段标签为 8-9。\n\n四期累积的源里,2026-09-03 的全日客流为 9,202 人次。\n\n2026-09-10 的全日客流为 14,427 人次。\n\n2026-09-11 的全日客流为 12,026 人次。\n\n2026-09-12 的全日客流为 10,722 人次。\n\n统计期人工录入的源里,2026-09-04 的全日客流为 12,731 人次。\n\n列表形态的月报里,分区丙的金额为 80。\n\n按表头行直接导入的表里,分区乙的金额为 27,093.25。\n\n手工数据源里,分区丙的订单数为 301 单。\n\n口径卡记录的单日全日客流为 9,784人次。", + "node_id": "write", + "provenance": 1, + "run_id": "run-evidence-provenance", + "schema": "agentlab.report/1", + "source": "## 推断来源演示\n\n2026-08-05 的全日客流为 [[v:Q1.r0.全日客流]] 人次。\n\n2026-08-20 午间时段的客流为 [[v:Q2.r0.客流]] 人次。\n\n2026-08-10 晚间合计的客流为 [[v:Q3.r0.客流]] 人次。\n\n按改名后的查询,2026-08-06 的分区乙为 [[v:Q4.r0.分区乙]] 人次。\n\n自连接查询得到 2026-08-07 的全日客流为 [[v:Q5.r0.全日客流]] 人次。\n\n2026-08-03 早间时段的客流为 [[v:Q6.r0.客流]]。\n\n本月全日客流合计为 [[v:Q7.r0.合计]] 人次。\n\n2026-08-08 的全日客流为 [[v:Q8.r0.全日客流]] 人次。\n\n2026-08-09 的全日客流为 [[v:Q9.r0.全日客流]] 人次。\n\n2026-08-11 的全日客流为 [[v:Q10.r0.全日客流]] 人次。\n\n2026-08-12 的全日客流为 [[v:Q11.r0.全日客流]] 人次。\n\n2026-08-13 的全日客流为 [[v:Q12.r0.全日客流]] 人次。\n\n2026-08-14 的全日客流为 [[v:Q13.r0.全日客流]] 人次。\n\n设有遮罩的源里,2026-08-16 的分区甲为 [[v:Q14.r0.分区甲]] 人次。\n\n按期累积的源里,2026-09-12 早间第二档的客流为 [[v:Q15.r0.客流]] 人次。\n\n这一档在原表里的时段标签为 [[v:Q15.r0.时段]]。\n\n四期累积的源里,2026-09-03 的全日客流为 [[v:Q16.r0.全日客流]] 人次。\n\n2026-09-10 的全日客流为 [[v:Q17.r0.全日客流]] 人次。\n\n2026-09-11 的全日客流为 [[v:Q18.r0.全日客流]] 人次。\n\n2026-09-12 的全日客流为 [[v:Q19.r0.全日客流]] 人次。\n\n统计期人工录入的源里,2026-09-04 的全日客流为 [[v:Q20.r0.全日客流]] 人次。\n\n列表形态的月报里,分区丙的金额为 [[v:Q21.r0.金额]]。\n\n按表头行直接导入的表里,分区乙的金额为 [[v:Q22.r0.金额]]。\n\n手工数据源里,分区丙的订单数为 [[v:Q23.r0.订单数]] 单。\n\n口径卡记录的单日全日客流为 [[m:day_total]]。", + "stats": { + "claims": 25, + "connective": 0, + "entities": 0, + "headings": 1, + "numbers": 23, + "numbers_cited": 23, + "quotes": 0, + "see": 0, + "segments": 102, + "uncited_claims": 0, + "uncited_numbers": 0, + "units": 26, + "unknown_entities": 0, + "unresolved": 0, + "unverified_entities": 0, + "values": 2, + "violations": 0 + }, + "violations": [] + }, + "graph": { + "run_id": "run-evidence-provenance", + "schema": "agentlab.evidence/1", + "seal": { + "sealed": true, + "ok": true, + "manifest_seq": 236, + "legacy": false + }, + "reports": [ + { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0", + "doc_sealed": true, + "hash_ok": true, + "ok": true, + "repairs": 0, + "fields": [ + "answer" + ], + "stats": { + "claims": 25, + "connective": 0, + "entities": 0, + "headings": 1, + "numbers": 23, + "numbers_cited": 23, + "quotes": 0, + "see": 0, + "segments": 102, + "uncited_claims": 0, + "uncited_numbers": 0, + "units": 26, + "unknown_entities": 0, + "unresolved": 0, + "unverified_entities": 0, + "values": 2, + "violations": 0 + }, + "claims": "off", + "judge": null, + "post_seal_verdicts": {}, + "judge_meta": null + } + ], + "evidence": [ + { + "alias": "Q1", + "eid": "ev:query:461109692514ccea", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_wide", + "artifact": "bd36f80fcde134894729d9ac6e5fd6672a8372b397f35895eded29103b53f214", + "sealed": true, + "cited_by": [ + "s4" + ], + "units": [ + "u1" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q10", + "eid": "ev:query:68fe7b40d36c2838", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_purged", + "artifact": "83a8efbdefb8caf41fca1ecd1cb3604dcff5bdf701aa52fe400a56dc5b93582e", + "sealed": true, + "cited_by": [ + "s40" + ], + "units": [ + "u10" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q11", + "eid": "ev:query:e42d8187f8707a6d", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_unreadable", + "artifact": "fed1edcaaa5c49e27fd581973bbdea6253a042088e2761f1b06081a4c5d99b20", + "sealed": true, + "cited_by": [ + "s44" + ], + "units": [ + "u11" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q12", + "eid": "ev:query:b3ad9163d991dccc", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_gone", + "artifact": "8b9ea1c080e043372d401d0ba8df19f34865f9dbad07e1ceee5f83174285b4ff", + "sealed": true, + "cited_by": [ + "s48" + ], + "units": [ + "u12" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q13", + "eid": "ev:query:1267b5018b529ede", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_unsealed", + "artifact": "4d916a46e3dfb26b760ad4b2caf9dc6fb3a6a3755a2542df6f6ba13307046540", + "sealed": true, + "cited_by": [ + "s52" + ], + "units": [ + "u13" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q14", + "eid": "ev:query:2045901016d7f09e", + "kind": "query", + "label": "db_query__flow_masked · 1 行", + "node_id": "q_masked", + "artifact": "911e42023db9fd9a187758820798d20fd295233c4b0b588b5025c330ee9973fd", + "sealed": true, + "cited_by": [ + "s56" + ], + "units": [ + "u14" + ], + "report": "write", + "tool": "db_query__flow_masked", + "source": "flow_masked", + "columns": [ + "日期", + "分区甲" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q15", + "eid": "ev:query:605e84d8f2407530", + "kind": "query", + "label": "db_query__flow_acc · 1 行", + "node_id": "q_union", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb", + "sealed": true, + "cited_by": [ + "s60", + "s64" + ], + "units": [ + "u15", + "u16" + ], + "report": "write", + "tool": "db_query__flow_acc", + "source": "flow_acc", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q16", + "eid": "ev:query:8ea8e9ebfcbbe79b", + "kind": "query", + "label": "db_query__flow_many · 1 行", + "node_id": "q_many", + "artifact": "5edc5015fe959e59a2133d9b3e2d169da0113a1a7c1f255cc11648c51c8aa43c", + "sealed": true, + "cited_by": [ + "s68" + ], + "units": [ + "u17" + ], + "report": "write", + "tool": "db_query__flow_many", + "source": "flow_many", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q17", + "eid": "ev:query:9e0ed1b61448b6f7", + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "node_id": "q_d26_bad", + "artifact": "cedf48c0160bbbf2a17bec5905c94c27056f4a15ba0854927a0b569ba982a664", + "sealed": true, + "cited_by": [ + "s72" + ], + "units": [ + "u18" + ], + "report": "write", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q18", + "eid": "ev:query:c569028918b691d3", + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "node_id": "q_d26_other", + "artifact": "3332ffbb2a6c6839787ce53f9aed8587b14395c608fe13eaf87d09283a1dcb57", + "sealed": true, + "cited_by": [ + "s76" + ], + "units": [ + "u19" + ], + "report": "write", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q19", + "eid": "ev:query:4f439a41386dfcba", + "kind": "query", + "label": "db_query__flow_d26 · 1 行", + "node_id": "q_revoked", + "artifact": "bb0d464f15f1cb725b03abc3eeca500184512dcf3bac80123b5744a4fbf833d1", + "sealed": true, + "cited_by": [ + "s80" + ], + "units": [ + "u20" + ], + "report": "write", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q2", + "eid": "ev:query:b9e00219bcef0461", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_long", + "artifact": "e6a1d5c1d30a980bc01fce6bb06751f1ff2ff2eafb9268ea5dd88e216e1539fb", + "sealed": true, + "cited_by": [ + "s8" + ], + "units": [ + "u2" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q20", + "eid": "ev:query:7545c591d8356e06", + "kind": "query", + "label": "db_query__flow_human · 1 行", + "node_id": "q_human", + "artifact": "83773f6133ea865a319c5277f58e5631ee1507d8700a17c87ff20adf1615d232", + "sealed": true, + "cited_by": [ + "s84" + ], + "units": [ + "u21" + ], + "report": "write", + "tool": "db_query__flow_human", + "source": "flow_human", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q21", + "eid": "ev:query:40dd19f3546c59a3", + "kind": "query", + "label": "db_query__list_demo · 1 行", + "node_id": "q_list", + "artifact": "0259c6e639da59e0346476e38c24f0f503aebb6c7f2b18ba07350ba9e7a59edc", + "sealed": true, + "cited_by": [ + "s88" + ], + "units": [ + "u22" + ], + "report": "write", + "tool": "db_query__list_demo", + "source": "list_demo", + "columns": [ + "地区", + "金额" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q22", + "eid": "ev:query:4adde5025a04727a", + "kind": "query", + "label": "db_query__table_demo · 1 行", + "node_id": "q_simple", + "artifact": "c41e21a9be2a49971b36661e7426a3b42cdbe9eea8d1bccec9553bd7cff617e7", + "sealed": true, + "cited_by": [ + "s92" + ], + "units": [ + "u23" + ], + "report": "write", + "tool": "db_query__table_demo", + "source": "table_demo", + "columns": [ + "地区", + "金额" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q23", + "eid": "ev:query:6ee67a7ace8d8ce1", + "kind": "query", + "label": "db_query__manual_demo · 1 行", + "node_id": "q_manual", + "artifact": "2edcd3df50d82d8c103211ee0598ed137f44528313c826a09bce42570a7e9d80", + "sealed": true, + "cited_by": [ + "s96" + ], + "units": [ + "u24" + ], + "report": "write", + "tool": "db_query__manual_demo", + "source": "manual_demo", + "columns": [ + "地区", + "订单数" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q24", + "eid": "ev:query:cac211319df3ce53", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_metric", + "artifact": "8e72406258b1841c360b7a5ffc704def507eb31ff51a4db2ed4bd727096c85c8", + "sealed": true, + "cited_by": [], + "units": [], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q3", + "eid": "ev:query:310a903ecb071cba", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_total", + "artifact": "e456192e79f5afb57e1d1a4432a00a56b407923e516766672f2e79a5ceedff0d", + "sealed": true, + "cited_by": [ + "s12" + ], + "units": [ + "u3" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "合计项", + "客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q4", + "eid": "ev:query:bee79ac68a2e3e7e", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_alias", + "artifact": "fd85df479244c81bb02d928ffd569363ce30743163e7570ea2aa3bf2f507ecea", + "sealed": true, + "cited_by": [ + "s16" + ], + "units": [ + "u4" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "分区乙" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q5", + "eid": "ev:query:80bf9d9b41d7948b", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_selfjoin", + "artifact": "a93cd2e84dd654df207140bf10bf281801eb87b0fdb8b9fe6041e152b8615d11", + "sealed": true, + "cited_by": [ + "s20" + ], + "units": [ + "u5" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q6", + "eid": "ev:query:53d9334b5bcc1dc7", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_null", + "artifact": "d4faa6aa33e1ec0de37162a07b82c3ef2c26b1353ec84e02aa8e204790782c3c", + "sealed": true, + "cited_by": [ + "s24" + ], + "units": [ + "u6" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q7", + "eid": "ev:query:1193b2937bd20ef8", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_aggregate", + "artifact": "de8ac38b6749d0e459bde7eccf679f43fe79e1bd281cd09519e2a596296aaca3", + "sealed": true, + "cited_by": [ + "s28" + ], + "units": [ + "u7" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "合计", + "天数" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q8", + "eid": "ev:query:3da9e67f2198424d", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_tampered", + "artifact": "751ed12d5c97620b0c5ee6b0c27331f897f40f5934abf45cb80abceeb191b9ee", + "sealed": true, + "cited_by": [ + "s32" + ], + "units": [ + "u8" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "Q9", + "eid": "ev:query:c78fc45d0653d735", + "kind": "query", + "label": "db_query__flow_demo · 1 行", + "node_id": "q_chain", + "artifact": "ebdf3b88d9111534279091ba729243b3bd8792ee812b7438309745509a645391", + "sealed": true, + "cited_by": [ + "s36" + ], + "units": [ + "u9" + ], + "report": "write", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "columns": [ + "日期", + "全日客流" + ], + "rows": 1, + "truncated": false + }, + { + "alias": "m:day_total", + "eid": "ev:metric:247b17af424701d7", + "kind": "metric", + "label": "单日全日客流 = 9,784人次", + "node_id": "card", + "artifact": "3cff2cf75142270256023d22841feca77d30e6604fdfa579ab0fec74e5194785", + "sealed": true, + "cited_by": [ + "s100" + ], + "units": [ + "u25" + ], + "report": "write" + } + ], + "edges": [ + { + "from": "s4", + "to": "Q1", + "rel": "cites", + "report": "write" + }, + { + "from": "s8", + "to": "Q2", + "rel": "cites", + "report": "write" + }, + { + "from": "s12", + "to": "Q3", + "rel": "cites", + "report": "write" + }, + { + "from": "s16", + "to": "Q4", + "rel": "cites", + "report": "write" + }, + { + "from": "s20", + "to": "Q5", + "rel": "cites", + "report": "write" + }, + { + "from": "s24", + "to": "Q6", + "rel": "cites", + "report": "write" + }, + { + "from": "s28", + "to": "Q7", + "rel": "cites", + "report": "write" + }, + { + "from": "s32", + "to": "Q8", + "rel": "cites", + "report": "write" + }, + { + "from": "s36", + "to": "Q9", + "rel": "cites", + "report": "write" + }, + { + "from": "s40", + "to": "Q10", + "rel": "cites", + "report": "write" + }, + { + "from": "s44", + "to": "Q11", + "rel": "cites", + "report": "write" + }, + { + "from": "s48", + "to": "Q12", + "rel": "cites", + "report": "write" + }, + { + "from": "s52", + "to": "Q13", + "rel": "cites", + "report": "write" + }, + { + "from": "s56", + "to": "Q14", + "rel": "cites", + "report": "write" + }, + { + "from": "s60", + "to": "Q15", + "rel": "cites", + "report": "write" + }, + { + "from": "s64", + "to": "Q15", + "rel": "cites", + "report": "write" + }, + { + "from": "s68", + "to": "Q16", + "rel": "cites", + "report": "write" + }, + { + "from": "s72", + "to": "Q17", + "rel": "cites", + "report": "write" + }, + { + "from": "s76", + "to": "Q18", + "rel": "cites", + "report": "write" + }, + { + "from": "s80", + "to": "Q19", + "rel": "cites", + "report": "write" + }, + { + "from": "s84", + "to": "Q20", + "rel": "cites", + "report": "write" + }, + { + "from": "s88", + "to": "Q21", + "rel": "cites", + "report": "write" + }, + { + "from": "s92", + "to": "Q22", + "rel": "cites", + "report": "write" + }, + { + "from": "s96", + "to": "Q23", + "rel": "cites", + "report": "write" + }, + { + "from": "s100", + "to": "m:day_total", + "rel": "cites", + "report": "write" + }, + { + "from": "m:day_total", + "to": "cell(nodes.q_metric, 0, '全日客流')", + "rel": "input", + "node_id": "q_metric", + "report": "write", + "cell": "Q24.r0.全日客流" + }, + { + "from": "Q1.r0.全日客流", + "to": "Q1", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q2.r0.客流", + "to": "Q2", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q3.r0.客流", + "to": "Q3", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q4.r0.分区乙", + "to": "Q4", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q5.r0.全日客流", + "to": "Q5", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q6.r0.客流", + "to": "Q6", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q7.r0.合计", + "to": "Q7", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q8.r0.全日客流", + "to": "Q8", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q9.r0.全日客流", + "to": "Q9", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q10.r0.全日客流", + "to": "Q10", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q11.r0.全日客流", + "to": "Q11", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q12.r0.全日客流", + "to": "Q12", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q13.r0.全日客流", + "to": "Q13", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q14.r0.分区甲", + "to": "Q14", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q15.r0.客流", + "to": "Q15", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q15.r0.时段", + "to": "Q15", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q16.r0.全日客流", + "to": "Q16", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q17.r0.全日客流", + "to": "Q17", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q18.r0.全日客流", + "to": "Q18", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q19.r0.全日客流", + "to": "Q19", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q20.r0.全日客流", + "to": "Q20", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q21.r0.金额", + "to": "Q21", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q22.r0.金额", + "to": "Q22", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q23.r0.订单数", + "to": "Q23", + "rel": "cell_of", + "report": "write" + }, + { + "from": "Q24.r0.全日客流", + "to": "Q24", + "rel": "cell_of", + "report": "write" + } + ], + "mode": "cited" + }, + "output": { + "answer": "## 推断来源演示\n\n2026-08-05 的全日客流为 9,470 人次。\n\n2026-08-20 午间时段的客流为 791 人次。\n\n2026-08-10 晚间合计的客流为 2,417 人次。\n\n按改名后的查询,2026-08-06 的分区乙为 4,895 人次。\n\n自连接查询得到 2026-08-07 的全日客流为 13,202 人次。\n\n2026-08-03 早间时段的客流为 —。\n\n本月全日客流合计为 342,141 人次。\n\n2026-08-08 的全日客流为 12,409 人次。\n\n2026-08-09 的全日客流为 9,838 人次。\n\n2026-08-11 的全日客流为 12,386 人次。\n\n2026-08-12 的全日客流为 12,368 人次。\n\n2026-08-13 的全日客流为 14,552 人次。\n\n2026-08-14 的全日客流为 9,594 人次。\n\n设有遮罩的源里,2026-08-16 的分区甲为 8,269 人次。\n\n按期累积的源里,2026-09-12 早间第二档的客流为 476 人次。\n\n这一档在原表里的时段标签为 8-9。\n\n四期累积的源里,2026-09-03 的全日客流为 9,202 人次。\n\n2026-09-10 的全日客流为 14,427 人次。\n\n2026-09-11 的全日客流为 12,026 人次。\n\n2026-09-12 的全日客流为 10,722 人次。\n\n统计期人工录入的源里,2026-09-04 的全日客流为 12,731 人次。\n\n列表形态的月报里,分区丙的金额为 80。\n\n按表头行直接导入的表里,分区乙的金额为 27,093.25。\n\n手工数据源里,分区丙的订单数为 301 单。\n\n口径卡记录的单日全日客流为 9,784人次。", + "_evidence": { + "report_node": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0", + "fields": [ + "answer" + ] + } + }, + "report_checked": { + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0", + "ok": true, + "stats": { + "units": 26, + "segments": 102, + "claims": 25, + "connective": 0, + "headings": 1, + "numbers": 23, + "numbers_cited": 23, + "values": 2, + "entities": 0, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [], + "repairs": 0, + "on_violation": "flag", + "failed": false, + "claims": "off" + }, + "cases": { + "wide": { + "segment": "s4", + "text": "9,470", + "covers": [ + 1, + 16, + 17, + 18, + 19, + 24, + 27, + 28 + ], + "note": "宽表的一格:格子、日期表头、年份取自统计期、指标名所在的行标签;R1 这一行成立。键盘、窄栏、缓存、失败、加载态、查看导入清单各条都在这一格上做" + }, + "long": { + "segment": "s8", + "text": "791", + "covers": [ + 2 + ], + "note": "交叉表逆透视后的长表:分段标题按配方中的标题定位(text 为 null,locate_title 是配方文字)" + }, + "total": { + "segment": "s12", + "text": "2,417", + "covers": [ + 3 + ], + "note": "原表写明的合计:合计标签取 DerivedItem.label_raw,K1、G1 这一格一致;被引用的是值列,合计标签原文与规范写法不同,canonical 照样给(A8):「18-22 时合计」→「18-22时合计」" + }, + "alias": { + "segment": "s16", + "text": "4,895", + "covers": [ + 5 + ], + "note": "改名:table_only · alias" + }, + "selfjoin": { + "segment": "s20", + "text": "13,202", + "covers": [ + 6 + ], + "note": "自连接:table_only · multi_table(detail join)" + }, + "null": { + "segment": "s24", + "text": "—", + "covers": [ + 7 + ], + "note": "占位符那一行存成空值:table_only · null_value;片段显示「—」" + }, + "aggregate": { + "segment": "s28", + "text": "342,141", + "covers": [ + 8 + ], + "note": "聚合:table_only · expression;version.tables 列出涉及的表" + }, + "tampered": { + "segment": "s32", + "text": "12,409", + "covers": [ + 9 + ], + "note": "数据文件被改(真改了一个字节):table_only · db_tampered(红),version 照给" + }, + "chain": { + "segment": "s36", + "text": "9,838", + "covers": [ + 10 + ], + "note": "登记哈希与清单不符(R13,真改了 source_snapshots.db_sha256):table_only · chain_mismatch(红),version 照给" + }, + "purged": { + "segment": "s40", + "text": "12,386", + "covers": [ + 11 + ], + "note": "原件已清除(经接口 purge-raw):仍是 inferred,raw_state purged、purged 带理由,cell_source.raw_purged" + }, + "unreadable": { + "segment": "s44", + "text": "12,368", + "covers": [ + 21 + ], + "note": "清单无法读取(真改坏了导入清单一个字节):none · manifest_unreadable(红),version 为 null" + }, + "gone": { + "segment": "s48", + "text": "14,552", + "covers": [ + 22 + ], + "note": "数据文件不在(真把文件挪走):table_only · snapshot_gone,不标红" + }, + "unsealed": { + "segment": "s52", + "text": "9,594", + "covers": [ + 25 + ], + "note": "运行未封存:sealed false,照常 inferred,没有 alert;片段答复的封存状态同样改成未封存" + }, + "masked": { + "segment": "s56", + "text": "8,269", + "covers": [ + 23 + ], + "note": "遮罩:table_only · masked;version.manifest_view 为 false" + }, + "union": { + "segment": "s60", + "text": "476", + "covers": [ + 4 + ], + "note": "8 月加 9 月(D24w)按期累积:两期都列出,第 2 期 has_row,格子是 9 月文件的坐标;行标签原文是全角的,canonical 照样给(A8 的口径,被引用的是值列也给):「8-9」→「8-9」" + }, + "fullwidth": { + "segment": "s64", + "text": "8-9", + "covers": [ + 2 + ], + "note": "D24w(全角时段标签)那一期的维度列:被引用的就是行标签格,canonical 是「8-9」→「8-9」" + }, + "many": { + "segment": "s68", + "text": "9,202", + "covers": [ + 27 + ], + "note": "四期按期累积:多于 3 期时「另有 n 期」折叠,has_row 在第 2 期" + }, + "d26_bad": { + "segment": "s72", + "text": "14,427", + "covers": [ + 26 + ], + "note": "D26 不成立的那一天:R1 本期不成立、这一行不成立,带接受理由" + }, + "d26_other": { + "segment": "s76", + "text": "12,026", + "covers": [ + 26 + ], + "note": "D26 的另一天:R1 本期不成立(已接受)、这一行成立" + }, + "revoked": { + "segment": "s80", + "text": "10,722", + "covers": [ + 12 + ], + "note": "作废接受(经接口 revoke-acceptance,回滚到 D00 那一版)之后再点开固定在 D26 版本上的运行:parts[0].revoked 非空,接受理由照旧在" + }, + "human": { + "segment": "s84", + "text": "12,731", + "covers": [], + "note": "统计期人工录入:year.source human,署名「录入员甲」" + }, + "list": { + "segment": "s88", + "text": "80", + "covers": [], + "note": "列表形态(lab.c01):每列都有溯源,附带列表头 col_header" + }, + "simple": { + "segment": "s92", + "text": "27,093.25", + "covers": [ + 13 + ], + "note": "简单导入:none · simple_upload,只有一句原因" + }, + "manual": { + "segment": "s96", + "text": "301", + "covers": [ + 15 + ], + "note": "手工源:查询步骤没有 provenance 键,前端不发请求(provenance 里没有这一格)" + }, + "metric": { + "segment": "s100", + "text": "9,784人次", + "covers": [ + 20 + ], + "note": "指标片段:它的查询步骤手工带了 provenance: true,前端也不该请求(provenance 里没有这一格)" + } + }, + "segments": { + "s4": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s4", + "text": "9,470", + "kind": "number", + "state": "deterministic", + "span": [ + 29, + 34 + ], + "unit": "u1", + "ref": "v:Q1.r0.全日客流", + "cite": { + "alias": "Q1", + "eid": "ev:cell:0c83aa90260f0ffc", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q1.r0.全日客流", + "rendered": "9,470", + "role": "value", + "status": "resolved", + "value": 9470 + } + }, + "unit": { + "id": "u1", + "kind": "claim", + "text": "2026-08-05 的全日客流为 9,470 人次。", + "span": [ + 11, + 38 + ], + "cites": [ + "Q1" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b1", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q1", + "artifact": "bd36f80fcde134894729d9ac6e5fd6672a8372b397f35895eded29103b53f214", + "node_id": "q_wide", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-05'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-05", + 9470 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s8": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s8", + "text": "791", + "kind": "number", + "state": "deterministic", + "span": [ + 60, + 63 + ], + "unit": "u2", + "ref": "v:Q2.r0.客流", + "cite": { + "alias": "Q2", + "eid": "ev:cell:e4f5cfbe3b513cc1", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q2.r0.客流", + "rendered": "791", + "role": "value", + "status": "resolved", + "value": 791 + } + }, + "unit": { + "id": "u2", + "kind": "claim", + "text": "2026-08-20 午间时段的客流为 791 人次。", + "span": [ + 40, + 67 + ], + "cites": [ + "Q2" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b2", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q2", + "artifact": "e6a1d5c1d30a980bc01fce6bb06751f1ff2ff2eafb9268ea5dd88e216e1539fb", + "node_id": "q_long", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-08-20' AND \"时段\" = '13-14'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-08-20", + "13-14", + 791 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "客流" + ], + "cells": [ + [ + 0, + "客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s12": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s12", + "text": "2,417", + "kind": "number", + "state": "deterministic", + "span": [ + 89, + 94 + ], + "unit": "u3", + "ref": "v:Q3.r0.客流", + "cite": { + "alias": "Q3", + "eid": "ev:cell:4485a6e0424ffdf2", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q3.r0.客流", + "rendered": "2,417", + "role": "value", + "status": "resolved", + "value": 2417 + } + }, + "unit": { + "id": "u3", + "kind": "claim", + "text": "2026-08-10 晚间合计的客流为 2,417 人次。", + "span": [ + 69, + 98 + ], + "cites": [ + "Q3" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b3", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q3", + "artifact": "e456192e79f5afb57e1d1a4432a00a56b407923e516766672f2e79a5ceedff0d", + "node_id": "q_total", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"合计项\", \"客流\" FROM \"时段客流_表内合计\" WHERE \"日期\" = '2026-08-10' AND \"合计项\" = '18-22时合计'", + "columns": [ + "日期", + "合计项", + "客流" + ], + "rows": [ + [ + "2026-08-10", + "18-22时合计", + 2417 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "客流" + ], + "cells": [ + [ + 0, + "客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "合计项": "text", + "客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s16": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s16", + "text": "4,895", + "kind": "number", + "state": "deterministic", + "span": [ + 125, + 130 + ], + "unit": "u4", + "ref": "v:Q4.r0.分区乙", + "cite": { + "alias": "Q4", + "eid": "ev:cell:5c6392c41c0ff80b", + "kind": "cell", + "locator": { + "column": "分区乙", + "row": 0 + }, + "ref": "Q4.r0.分区乙", + "rendered": "4,895", + "role": "value", + "status": "resolved", + "value": 4895 + } + }, + "unit": { + "id": "u4", + "kind": "claim", + "text": "按改名后的查询,2026-08-06 的分区乙为 4,895 人次。", + "span": [ + 100, + 134 + ], + "cites": [ + "Q4" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b4", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q4", + "artifact": "fd85df479244c81bb02d928ffd569363ce30743163e7570ea2aa3bf2f507ecea", + "node_id": "q_alias", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"分区甲\" AS \"分区乙\" FROM \"日客流\" WHERE \"日期\" = '2026-08-06'", + "columns": [ + "日期", + "分区乙" + ], + "rows": [ + [ + "2026-08-06", + 4895 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "分区乙" + ], + "cells": [ + [ + 0, + "分区乙" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "分区乙": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s20": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s20", + "text": "13,202", + "kind": "number", + "state": "deterministic", + "span": [ + 162, + 168 + ], + "unit": "u5", + "ref": "v:Q5.r0.全日客流", + "cite": { + "alias": "Q5", + "eid": "ev:cell:342109e8b74aac53", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q5.r0.全日客流", + "rendered": "13,202", + "role": "value", + "status": "resolved", + "value": 13202 + } + }, + "unit": { + "id": "u5", + "kind": "claim", + "text": "自连接查询得到 2026-08-07 的全日客流为 13,202 人次。", + "span": [ + 136, + 172 + ], + "cites": [ + "Q5" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b5", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q5", + "artifact": "a93cd2e84dd654df207140bf10bf281801eb87b0fdb8b9fe6041e152b8615d11", + "node_id": "q_selfjoin", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT a.\"日期\", b.\"全日客流\" FROM \"日客流\" a JOIN \"日客流\" b ON a.\"日期\" = b.\"日期\" WHERE a.\"日期\" = '2026-08-07'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-07", + 13202 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s24": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s24", + "text": "—", + "kind": "value", + "state": "deterministic", + "span": [ + 194, + 195 + ], + "unit": "u6", + "ref": "v:Q6.r0.客流", + "cite": { + "alias": "Q6", + "eid": "ev:cell:257aca4bafc14be9", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q6.r0.客流", + "rendered": "—", + "role": "value", + "status": "resolved", + "value": null + } + }, + "unit": { + "id": "u6", + "kind": "claim", + "text": "2026-08-03 早间时段的客流为 —。", + "span": [ + 174, + 196 + ], + "cites": [ + "Q6" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b6", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q6", + "artifact": "d4faa6aa33e1ec0de37162a07b82c3ef2c26b1353ec84e02aa8e204790782c3c", + "node_id": "q_null", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"时段\" = '7-8' AND \"日期\" = '2026-08-03'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-08-03", + "7-8", + null + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "客流" + ], + "cells": [ + [ + 0, + "客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "日期": "text", + "时段": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s28": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s28", + "text": "342,141", + "kind": "number", + "state": "deterministic", + "span": [ + 208, + 215 + ], + "unit": "u7", + "ref": "v:Q7.r0.合计", + "cite": { + "alias": "Q7", + "eid": "ev:cell:5ec076e7ead5931d", + "kind": "cell", + "locator": { + "column": "合计", + "row": 0 + }, + "ref": "Q7.r0.合计", + "rendered": "342,141", + "role": "value", + "status": "resolved", + "value": 342141 + } + }, + "unit": { + "id": "u7", + "kind": "claim", + "text": "本月全日客流合计为 342,141 人次。", + "span": [ + 198, + 219 + ], + "cites": [ + "Q7" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b7", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q7", + "artifact": "de8ac38b6749d0e459bde7eccf679f43fe79e1bd281cd09519e2a596296aaca3", + "node_id": "q_aggregate", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT SUM(\"全日客流\") AS \"合计\", COUNT(*) AS \"天数\" FROM \"日客流\"", + "columns": [ + "合计", + "天数" + ], + "rows": [ + [ + 342141, + 31 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "合计" + ], + "cells": [ + [ + 0, + "合计" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "合计": "number", + "天数": "number" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s32": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s32", + "text": "12,409", + "kind": "number", + "state": "deterministic", + "span": [ + 239, + 245 + ], + "unit": "u8", + "ref": "v:Q8.r0.全日客流", + "cite": { + "alias": "Q8", + "eid": "ev:cell:85f7023ad8e89ce5", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q8.r0.全日客流", + "rendered": "12,409", + "role": "value", + "status": "resolved", + "value": 12409 + } + }, + "unit": { + "id": "u8", + "kind": "claim", + "text": "2026-08-08 的全日客流为 12,409 人次。", + "span": [ + 221, + 249 + ], + "cites": [ + "Q8" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b8", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q8", + "artifact": "751ed12d5c97620b0c5ee6b0c27331f897f40f5934abf45cb80abceeb191b9ee", + "node_id": "q_tampered", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-08'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-08", + 12409 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s36": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s36", + "text": "9,838", + "kind": "number", + "state": "deterministic", + "span": [ + 269, + 274 + ], + "unit": "u9", + "ref": "v:Q9.r0.全日客流", + "cite": { + "alias": "Q9", + "eid": "ev:cell:b84f7faa98731e0d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q9.r0.全日客流", + "rendered": "9,838", + "role": "value", + "status": "resolved", + "value": 9838 + } + }, + "unit": { + "id": "u9", + "kind": "claim", + "text": "2026-08-09 的全日客流为 9,838 人次。", + "span": [ + 251, + 278 + ], + "cites": [ + "Q9" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b9", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q9", + "artifact": "ebdf3b88d9111534279091ba729243b3bd8792ee812b7438309745509a645391", + "node_id": "q_chain", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-09'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-09", + 9838 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s40": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s40", + "text": "12,386", + "kind": "number", + "state": "deterministic", + "span": [ + 298, + 304 + ], + "unit": "u10", + "ref": "v:Q10.r0.全日客流", + "cite": { + "alias": "Q10", + "eid": "ev:cell:fc7c02fb13513fdf", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q10.r0.全日客流", + "rendered": "12,386", + "role": "value", + "status": "resolved", + "value": 12386 + } + }, + "unit": { + "id": "u10", + "kind": "claim", + "text": "2026-08-11 的全日客流为 12,386 人次。", + "span": [ + 280, + 308 + ], + "cites": [ + "Q10" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b10", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q10", + "artifact": "83a8efbdefb8caf41fca1ecd1cb3604dcff5bdf701aa52fe400a56dc5b93582e", + "node_id": "q_purged", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-11'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-11", + 12386 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s44": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s44", + "text": "12,368", + "kind": "number", + "state": "deterministic", + "span": [ + 328, + 334 + ], + "unit": "u11", + "ref": "v:Q11.r0.全日客流", + "cite": { + "alias": "Q11", + "eid": "ev:cell:d2f14c41a7a28422", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q11.r0.全日客流", + "rendered": "12,368", + "role": "value", + "status": "resolved", + "value": 12368 + } + }, + "unit": { + "id": "u11", + "kind": "claim", + "text": "2026-08-12 的全日客流为 12,368 人次。", + "span": [ + 310, + 338 + ], + "cites": [ + "Q11" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b11", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q11", + "artifact": "fed1edcaaa5c49e27fd581973bbdea6253a042088e2761f1b06081a4c5d99b20", + "node_id": "q_unreadable", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-12'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-12", + 12368 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s48": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s48", + "text": "14,552", + "kind": "number", + "state": "deterministic", + "span": [ + 358, + 364 + ], + "unit": "u12", + "ref": "v:Q12.r0.全日客流", + "cite": { + "alias": "Q12", + "eid": "ev:cell:e82232ea3feb6c6d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q12.r0.全日客流", + "rendered": "14,552", + "role": "value", + "status": "resolved", + "value": 14552 + } + }, + "unit": { + "id": "u12", + "kind": "claim", + "text": "2026-08-13 的全日客流为 14,552 人次。", + "span": [ + 340, + 368 + ], + "cites": [ + "Q12" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b12", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q12", + "artifact": "8b9ea1c080e043372d401d0ba8df19f34865f9dbad07e1ceee5f83174285b4ff", + "node_id": "q_gone", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-13'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-13", + 14552 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s52": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s52", + "text": "9,594", + "kind": "number", + "state": "deterministic", + "span": [ + 388, + 393 + ], + "unit": "u13", + "ref": "v:Q13.r0.全日客流", + "cite": { + "alias": "Q13", + "eid": "ev:cell:bdf1aecce9f0c69b", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q13.r0.全日客流", + "rendered": "9,594", + "role": "value", + "status": "resolved", + "value": 9594 + } + }, + "unit": { + "id": "u13", + "kind": "claim", + "text": "2026-08-14 的全日客流为 9,594 人次。", + "span": [ + 370, + 397 + ], + "cites": [ + "Q13" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b13", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q13", + "artifact": "4d916a46e3dfb26b760ad4b2caf9dc6fb3a6a3755a2542df6f6ba13307046540", + "node_id": "q_unsealed", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-14'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-14", + 9594 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": false, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": false, + "ok": null, + "covered": false + }, + "redacted": { + "columns": [] + } + }, + "s56": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s56", + "text": "8,269", + "kind": "number", + "state": "deterministic", + "span": [ + 424, + 429 + ], + "unit": "u14", + "ref": "v:Q14.r0.分区甲", + "cite": { + "alias": "Q14", + "eid": "ev:cell:33cfbe82dd2cf30e", + "kind": "cell", + "locator": { + "column": "分区甲", + "row": 0 + }, + "ref": "Q14.r0.分区甲", + "rendered": "8,269", + "role": "value", + "status": "resolved", + "value": 8269 + } + }, + "unit": { + "id": "u14", + "kind": "claim", + "text": "设有遮罩的源里,2026-08-16 的分区甲为 8,269 人次。", + "span": [ + 399, + 433 + ], + "cites": [ + "Q14" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b14", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q14", + "artifact": "911e42023db9fd9a187758820798d20fd295233c4b0b588b5025c330ee9973fd", + "node_id": "q_masked", + "tool": "db_query__flow_masked", + "source": "flow_masked", + "sql": "SELECT \"日期\", \"分区甲\" FROM \"日客流\" WHERE \"日期\" = '2026-08-16'", + "columns": [ + "日期", + "分区甲" + ], + "rows": [ + [ + "2026-08-16", + "已遮罩" + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "分区甲" + ], + "cells": [ + [ + 0, + "分区甲" + ] + ] + }, + "masked": [ + "分区甲" + ], + "hash_ok": true, + "sealed": true, + "column_types": { + "分区甲": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [ + "分区甲" + ], + "note": "这些列在数据源中设置了遮罩,面板上不显示原值。遮罩只减少暴露,不是安全边界" + } + }, + "s60": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s60", + "text": "476", + "kind": "number", + "state": "deterministic", + "span": [ + 464, + 467 + ], + "unit": "u15", + "ref": "v:Q15.r0.客流", + "cite": { + "alias": "Q15", + "eid": "ev:cell:1abd195ad8d27c62", + "kind": "cell", + "locator": { + "column": "客流", + "row": 0 + }, + "ref": "Q15.r0.客流", + "rendered": "476", + "role": "value", + "status": "resolved", + "value": 476 + } + }, + "unit": { + "id": "u15", + "kind": "claim", + "text": "按期累积的源里,2026-09-12 早间第二档的客流为 476 人次。", + "span": [ + 435, + 471 + ], + "cites": [ + "Q15" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b15", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q15", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb", + "node_id": "q_union", + "tool": "db_query__flow_acc", + "source": "flow_acc", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-09-12' AND \"时段\" = '8-9'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-09-12", + "8-9", + 476 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "客流" + ], + "cells": [ + [ + 0, + "客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s64": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s64", + "text": "8-9", + "kind": "value", + "state": "deterministic", + "span": [ + 487, + 490 + ], + "unit": "u16", + "ref": "v:Q15.r0.时段", + "cite": { + "alias": "Q15", + "eid": "ev:cell:7616c88cfaca6d36", + "kind": "cell", + "locator": { + "column": "时段", + "row": 0 + }, + "ref": "Q15.r0.时段", + "rendered": "8-9", + "role": "value", + "status": "resolved", + "value": "8-9" + } + }, + "unit": { + "id": "u16", + "kind": "claim", + "text": "这一档在原表里的时段标签为 8-9。", + "span": [ + 473, + 491 + ], + "cites": [ + "Q15" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b16", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q15", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb", + "node_id": "q_union", + "tool": "db_query__flow_acc", + "source": "flow_acc", + "sql": "SELECT \"日期\", \"时段\", \"客流\" FROM \"时段客流\" WHERE \"日期\" = '2026-09-12' AND \"时段\" = '8-9'", + "columns": [ + "日期", + "时段", + "客流" + ], + "rows": [ + [ + "2026-09-12", + "8-9", + 476 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "时段" + ], + "cells": [ + [ + 0, + "时段" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "客流": "number", + "日期": "text", + "时段": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s68": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s68", + "text": "9,202", + "kind": "number", + "state": "deterministic", + "span": [ + 519, + 524 + ], + "unit": "u17", + "ref": "v:Q16.r0.全日客流", + "cite": { + "alias": "Q16", + "eid": "ev:cell:ab4ea94e92a18648", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q16.r0.全日客流", + "rendered": "9,202", + "role": "value", + "status": "resolved", + "value": 9202 + } + }, + "unit": { + "id": "u17", + "kind": "claim", + "text": "四期累积的源里,2026-09-03 的全日客流为 9,202 人次。", + "span": [ + 493, + 528 + ], + "cites": [ + "Q16" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b17", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q16", + "artifact": "5edc5015fe959e59a2133d9b3e2d169da0113a1a7c1f255cc11648c51c8aa43c", + "node_id": "q_many", + "tool": "db_query__flow_many", + "source": "flow_many", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-09-03'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-09-03", + 9202 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s72": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s72", + "text": "14,427", + "kind": "number", + "state": "deterministic", + "span": [ + 548, + 554 + ], + "unit": "u18", + "ref": "v:Q17.r0.全日客流", + "cite": { + "alias": "Q17", + "eid": "ev:cell:ca4e9acd8e6982d1", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q17.r0.全日客流", + "rendered": "14,427", + "role": "value", + "status": "resolved", + "value": 14427 + } + }, + "unit": { + "id": "u18", + "kind": "claim", + "text": "2026-09-10 的全日客流为 14,427 人次。", + "span": [ + 530, + 558 + ], + "cites": [ + "Q17" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b18", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q17", + "artifact": "cedf48c0160bbbf2a17bec5905c94c27056f4a15ba0854927a0b569ba982a664", + "node_id": "q_d26_bad", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-09-10'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-09-10", + 14427 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s76": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s76", + "text": "12,026", + "kind": "number", + "state": "deterministic", + "span": [ + 578, + 584 + ], + "unit": "u19", + "ref": "v:Q18.r0.全日客流", + "cite": { + "alias": "Q18", + "eid": "ev:cell:d9b282518f3c924f", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q18.r0.全日客流", + "rendered": "12,026", + "role": "value", + "status": "resolved", + "value": 12026 + } + }, + "unit": { + "id": "u19", + "kind": "claim", + "text": "2026-09-11 的全日客流为 12,026 人次。", + "span": [ + 560, + 588 + ], + "cites": [ + "Q18" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b19", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q18", + "artifact": "3332ffbb2a6c6839787ce53f9aed8587b14395c608fe13eaf87d09283a1dcb57", + "node_id": "q_d26_other", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-09-11'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-09-11", + 12026 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s80": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s80", + "text": "10,722", + "kind": "number", + "state": "deterministic", + "span": [ + 608, + 614 + ], + "unit": "u20", + "ref": "v:Q19.r0.全日客流", + "cite": { + "alias": "Q19", + "eid": "ev:cell:3c81ec162adfc84c", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q19.r0.全日客流", + "rendered": "10,722", + "role": "value", + "status": "resolved", + "value": 10722 + } + }, + "unit": { + "id": "u20", + "kind": "claim", + "text": "2026-09-12 的全日客流为 10,722 人次。", + "span": [ + 590, + 618 + ], + "cites": [ + "Q19" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b20", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q19", + "artifact": "bb0d464f15f1cb725b03abc3eeca500184512dcf3bac80123b5744a4fbf833d1", + "node_id": "q_revoked", + "tool": "db_query__flow_d26", + "source": "flow_d26", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-09-12'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-09-12", + 10722 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s84": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s84", + "text": "12,731", + "kind": "number", + "state": "deterministic", + "span": [ + 649, + 655 + ], + "unit": "u21", + "ref": "v:Q20.r0.全日客流", + "cite": { + "alias": "Q20", + "eid": "ev:cell:64bc6695bee8019d", + "kind": "cell", + "locator": { + "column": "全日客流", + "row": 0 + }, + "ref": "Q20.r0.全日客流", + "rendered": "12,731", + "role": "value", + "status": "resolved", + "value": 12731 + } + }, + "unit": { + "id": "u21", + "kind": "claim", + "text": "统计期人工录入的源里,2026-09-04 的全日客流为 12,731 人次。", + "span": [ + 620, + 659 + ], + "cites": [ + "Q20" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b21", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q20", + "artifact": "83773f6133ea865a319c5277f58e5631ee1507d8700a17c87ff20adf1615d232", + "node_id": "q_human", + "tool": "db_query__flow_human", + "source": "flow_human", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-09-04'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-09-04", + 12731 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s88": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s88", + "text": "80", + "kind": "number", + "state": "deterministic", + "span": [ + 678, + 680 + ], + "unit": "u22", + "ref": "v:Q21.r0.金额", + "cite": { + "alias": "Q21", + "eid": "ev:cell:1b53c8ba2aad7bae", + "kind": "cell", + "locator": { + "column": "金额", + "row": 0 + }, + "ref": "Q21.r0.金额", + "rendered": "80", + "role": "value", + "status": "resolved", + "value": 80 + } + }, + "unit": { + "id": "u22", + "kind": "claim", + "text": "列表形态的月报里,分区丙的金额为 80。", + "span": [ + 661, + 681 + ], + "cites": [ + "Q21" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b22", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q21", + "artifact": "0259c6e639da59e0346476e38c24f0f503aebb6c7f2b18ba07350ba9e7a59edc", + "node_id": "q_list", + "tool": "db_query__list_demo", + "source": "list_demo", + "sql": "SELECT \"地区\", \"金额\" FROM \"月报\" WHERE \"地区\" = '分区丙'", + "columns": [ + "地区", + "金额" + ], + "rows": [ + [ + "分区丙", + 80 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "金额" + ], + "cells": [ + [ + 0, + "金额" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "金额": "number" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s92": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s92", + "text": "27,093.25", + "kind": "number", + "state": "deterministic", + "span": [ + 703, + 712 + ], + "unit": "u23", + "ref": "v:Q22.r0.金额", + "cite": { + "alias": "Q22", + "eid": "ev:cell:f8f7f22b9c817ea5", + "kind": "cell", + "locator": { + "column": "金额", + "row": 0 + }, + "ref": "Q22.r0.金额", + "rendered": "27,093.25", + "role": "value", + "status": "resolved", + "value": 27093.25 + } + }, + "unit": { + "id": "u23", + "kind": "claim", + "text": "按表头行直接导入的表里,分区乙的金额为 27,093.25。", + "span": [ + 683, + 713 + ], + "cites": [ + "Q22" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b23", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q22", + "artifact": "c41e21a9be2a49971b36661e7426a3b42cdbe9eea8d1bccec9553bd7cff617e7", + "node_id": "q_simple", + "tool": "db_query__table_demo", + "source": "table_demo", + "sql": "SELECT \"地区\", \"金额\" FROM \"地区汇总\" WHERE \"地区\" = '分区乙'", + "columns": [ + "地区", + "金额" + ], + "rows": [ + [ + "分区乙", + 27093.25 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "金额" + ], + "cells": [ + [ + 0, + "金额" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "金额": "number" + }, + "provenance": true + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s96": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s96", + "text": "301", + "kind": "number", + "state": "deterministic", + "span": [ + 731, + 734 + ], + "unit": "u24", + "ref": "v:Q23.r0.订单数", + "cite": { + "alias": "Q23", + "eid": "ev:cell:9cdbeaf3b2daf015", + "kind": "cell", + "locator": { + "column": "订单数", + "row": 0 + }, + "ref": "Q23.r0.订单数", + "rendered": "301", + "role": "value", + "status": "resolved", + "value": 301 + } + }, + "unit": { + "id": "u24", + "kind": "claim", + "text": "手工数据源里,分区丙的订单数为 301 单。", + "span": [ + 715, + 737 + ], + "cites": [ + "Q23" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b24", + "type": "paragraph" + }, + "chain": [ + { + "step": "query", + "alias": "Q23", + "artifact": "2edcd3df50d82d8c103211ee0598ed137f44528313c826a09bce42570a7e9d80", + "node_id": "q_manual", + "tool": "db_query__manual_demo", + "source": "manual_demo", + "sql": "SELECT \"地区\", \"订单数\" FROM \"订单汇总\" WHERE \"地区\" = '分区丙'", + "columns": [ + "地区", + "订单数" + ], + "rows": [ + [ + "分区丙", + 301 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "订单数" + ], + "cells": [ + [ + 0, + "订单数" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "地区": "text", + "订单数": "number" + } + } + ], + "note": "该值由系统从查询快照中取出、按固定规则显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + }, + "s100": { + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": { + "id": "s100", + "text": "9,784人次", + "kind": "number", + "state": "deterministic", + "span": [ + 753, + 760 + ], + "unit": "u25", + "ref": "m:day_total", + "cite": { + "alias": "m:day_total", + "eid": "ev:metric:247b17af424701d7", + "kind": "metric", + "locator": { + "metric": "day_total" + }, + "ref": "day_total", + "rendered": "9,784人次", + "role": "value", + "status": "resolved", + "value": 9784 + } + }, + "unit": { + "id": "u25", + "kind": "claim", + "text": "口径卡记录的单日全日客流为 9,784人次。", + "span": [ + 739, + 761 + ], + "cites": [ + "m:day_total" + ], + "on_demand": { + "available": true, + "reason": null, + "message": null + } + }, + "block": { + "id": "b25", + "type": "paragraph" + }, + "chain": [ + { + "step": "metric", + "alias": "m:day_total", + "metric": "day_total", + "name": "单日全日客流", + "value": 9784, + "unit": "人次", + "decimals": 0, + "format": "thousands", + "conv": null, + "rendered": "9,784人次", + "caliber": "客流口径", + "version": "v1", + "expression": "cell(nodes.q_metric, 0, '全日客流')", + "substituted": "9784", + "recompute_ok": true, + "status": "ok", + "node_id": "card", + "artifact": "3cff2cf75142270256023d22841feca77d30e6604fdfa579ab0fec74e5194785", + "eid": "ev:metric:247b17af424701d7", + "eid_ok": true, + "hash_ok": true, + "render_ok": true, + "sealed": true, + "source": null, + "caliber_upgrade": null + }, + { + "step": "input", + "artifact": "8e72406258b1841c360b7a5ffc704def507eb31ff51a4db2ed4bd727096c85c8", + "eid": "ev:cell:fde6c896881f44e3", + "locator": { + "column": "全日客流", + "row": 0 + }, + "node_id": "q_metric", + "path": "cell(nodes.q_metric, 0, '全日客流')", + "status": "verified", + "value": 9784, + "via": "tool_cell", + "query": "Q24", + "cell": "Q24.r0.全日客流" + }, + { + "step": "query", + "alias": "Q24", + "artifact": "8e72406258b1841c360b7a5ffc704def507eb31ff51a4db2ed4bd727096c85c8", + "node_id": "q_metric", + "tool": "db_query__flow_demo", + "source": "flow_demo", + "sql": "SELECT \"日期\", \"全日客流\" FROM \"日客流\" WHERE \"日期\" = '2026-08-15'", + "columns": [ + "日期", + "全日客流" + ], + "rows": [ + [ + "2026-08-15", + 9784 + ] + ], + "row_offset": 0, + "row_index": [ + 0 + ], + "total_rows": 1, + "truncated": false, + "highlight": { + "rows": [ + 0 + ], + "cols": [ + "全日客流" + ], + "cells": [ + [ + 0, + "全日客流" + ] + ] + }, + "masked": [], + "hash_ok": true, + "sealed": true, + "column_types": { + "全日客流": "number", + "日期": "text" + }, + "provenance": true + } + ], + "note": "数字由系统从证据中取出、按口径卡的格式显示,未经模型转写", + "violations": [], + "seal": { + "sealed": true, + "ok": true, + "covered": true + }, + "redacted": { + "columns": [] + } + } + }, + "provenance": { + "s4": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s4", + "cell": { + "alias": "Q1", + "row": 0, + "column": "全日客流", + "artifact": "bd36f80fcde134894729d9ac6e5fd6672a8372b397f35895eded29103b53f214" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-08-05" + }, + "rowid": 5, + "part_seq": 1, + "part_rowid": 5, + "sheet": "客流汇总", + "cell": "G5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "G4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-08-05" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "passed", + "row_status": "passed", + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s8": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s8", + "cell": { + "alias": "Q2", + "row": 0, + "column": "客流", + "artifact": "e6a1d5c1d30a980bc01fce6bb06751f1ff2ff2eafb9268ea5dd88e216e1539fb" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "时段客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "时段客流", + "column": "客流", + "column_role": "value", + "kind": "data", + "pk": { + "日期": "2026-08-20", + "时段": "13-14" + }, + "rowid": 206, + "part_seq": 1, + "part_rowid": 206, + "sheet": "客流汇总", + "cell": "V16", + "header": null, + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "V4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": "时段", + "sheet": "客流汇总", + "cell": "B16", + "text": "13-14", + "locate_title": null + }, + { + "role": "section_title", + "column": "时段类别", + "sheet": "客流汇总", + "cell": "B9", + "text": null, + "locate_title": "日间时段客流(人次)" + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"客流\" FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段\" = ?", + "params": [ + "2026-08-20", + "13-14" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s12": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s12", + "cell": { + "alias": "Q3", + "row": 0, + "column": "客流", + "artifact": "e456192e79f5afb57e1d1a4432a00a56b407923e516766672f2e79a5ceedff0d" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "时段客流_表内合计", + "kind": "reported_total" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "时段客流_表内合计", + "column": "客流", + "column_role": "value", + "kind": "reported_total", + "pk": { + "日期": "2026-08-10", + "合计项": "18-22时合计" + }, + "rowid": 10, + "part_seq": 1, + "part_rowid": 10, + "sheet": "客流汇总", + "cell": "L28", + "header": null, + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "L4", + "text": null, + "locate_title": null + }, + { + "role": "total_label", + "column": "合计项", + "sheet": "客流汇总", + "cell": "B28", + "text": "18-22 时合计", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": { + "raw": "18-22 时合计", + "canonical": "18-22时合计" + }, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"客流\" FROM \"时段客流_表内合计\" WHERE \"日期\" = ? AND \"合计项\" = ?", + "params": [ + "2026-08-10", + "18-22时合计" + ], + "ok": true + } + }, + "checks": [ + { + "id": "K1", + "kind": "derived_sum", + "title": "合计按明细重算(分段「夜间合计」)", + "part_status": "passed", + "row_status": null, + "cell_status": "ok", + "acceptance": null, + "detail": null + }, + { + "id": "G1", + "kind": "formula_refs", + "title": "合计公式引用(分段「夜间合计」)", + "part_status": "passed", + "row_status": null, + "cell_status": "ok", + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s16": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s16", + "cell": { + "alias": "Q4", + "row": 0, + "column": "分区乙", + "artifact": "fd85df479244c81bb02d928ffd569363ce30743163e7570ea2aa3bf2f507ecea" + }, + "status": "table_only", + "reason": { + "code": "alias", + "detail": "alias", + "text": "查询给列改了名,无法确认这一格对应哪一列,只给出表级来历" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s20": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s20", + "cell": { + "alias": "Q5", + "row": 0, + "column": "全日客流", + "artifact": "a93cd2e84dd654df207140bf10bf281801eb87b0fdb8b9fe6041e152b8615d11" + }, + "status": "table_only", + "reason": { + "code": "multi_table", + "detail": "join", + "text": "查询涉及多张表、同一张表多次,或含子查询、公用表表达式、集合运算,只给出表级来历" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s24": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s24", + "cell": { + "alias": "Q6", + "row": 0, + "column": "客流", + "artifact": "d4faa6aa33e1ec0de37162a07b82c3ef2c26b1353ec84e02aa8e204790782c3c" + }, + "status": "table_only", + "reason": { + "code": "null_value", + "detail": "", + "text": "这一格是空值,不推断来源" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "时段客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s28": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s28", + "cell": { + "alias": "Q7", + "row": 0, + "column": "合计", + "artifact": "de8ac38b6749d0e459bde7eccf679f43fe79e1bd281cd09519e2a596296aaca3" + }, + "status": "table_only", + "reason": { + "code": "expression", + "detail": "expression", + "text": "这一格是计算、聚合或改写的结果,只给出表级来历" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s56": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s56", + "cell": { + "alias": "Q14", + "row": 0, + "column": "分区甲", + "artifact": "911e42023db9fd9a187758820798d20fd295233c4b0b588b5025c330ee9973fd" + }, + "status": "table_only", + "reason": { + "code": "masked", + "detail": "", + "text": "这一格涉及设置了遮罩的列,不推断来源" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_masked", + "snapshot_id": "b5aeb656894790fdcd51e0b529415fc176ce1e3b6944d372f29365ef29f40b46", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": false, + "parts": [ + { + "import_id": "cff145427f314f5085d3915b205dd820", + "seq": 1, + "manifest": "1a6411147dc5b16e2f00d6758a86509493a46fb2591e82d32d333bd34b73f649", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "4d08797642625ac71da116b86dbff1a9a5112387b2c7890eaf1bdb027b4cddbb", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.094561+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s60": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s60", + "cell": { + "alias": "Q15", + "row": 0, + "column": "客流", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_acc", + "snapshot_id": "b731b61325dbd48f45d0eb43cbcc6083636bebec65eaf1cbd4e93668f52e352e", + "mode": "accumulate", + "union": true, + "tables": [ + { + "name": "时段客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "f40ba74cbbc84f4680caae47562259cf", + "seq": 1, + "manifest": "defb2137f263e1e0a94ac6e634fa17ce3c5604117cf3098cd72fe4cc73a3a6c0", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "567083c3eac7a82673a0a2b34cb66247f0e12e19b209e57f0b47581b0be311a8", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.284051+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + }, + { + "import_id": "f5d0e98a72684c5b8d25b2f2d1769f8e", + "seq": 2, + "manifest": "66ce60bb83a25f980fc011cc299eaa0cca99c1bef12c5f7bad9b13ab2366d140", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "88c71184ccba3df89a436dd7ce06228d0c654dc387271a2529b67d9a9036651d", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.396246+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "时段客流", + "column": "客流", + "column_role": "value", + "kind": "data", + "pk": { + "日期": "2026-09-12", + "时段": "8-9" + }, + "rowid": 569, + "part_seq": 2, + "part_rowid": 42, + "sheet": "客流汇总", + "cell": "N11", + "header": null, + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "N4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": "时段", + "sheet": "客流汇总", + "cell": "B11", + "text": "8-9", + "locate_title": null + }, + { + "role": "section_title", + "column": "时段类别", + "sheet": "客流汇总", + "cell": "B9", + "text": null, + "locate_title": "日间时段客流(人次)" + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": { + "raw": "8-9", + "canonical": "8-9" + }, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"客流\" FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段\" = ?", + "params": [ + "2026-09-12", + "8-9" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s64": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s64", + "cell": { + "alias": "Q15", + "row": 0, + "column": "时段", + "artifact": "7a48375730b7213db8473b75f89910aab8dc3c0462cf606d0ecd6a4ce49fb5cb" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_acc", + "snapshot_id": "b731b61325dbd48f45d0eb43cbcc6083636bebec65eaf1cbd4e93668f52e352e", + "mode": "accumulate", + "union": true, + "tables": [ + { + "name": "时段客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "f40ba74cbbc84f4680caae47562259cf", + "seq": 1, + "manifest": "defb2137f263e1e0a94ac6e634fa17ce3c5604117cf3098cd72fe4cc73a3a6c0", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "567083c3eac7a82673a0a2b34cb66247f0e12e19b209e57f0b47581b0be311a8", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.284051+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + }, + { + "import_id": "f5d0e98a72684c5b8d25b2f2d1769f8e", + "seq": 2, + "manifest": "66ce60bb83a25f980fc011cc299eaa0cca99c1bef12c5f7bad9b13ab2366d140", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "88c71184ccba3df89a436dd7ce06228d0c654dc387271a2529b67d9a9036651d", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.396246+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "时段客流", + "column": "时段", + "column_role": "dim", + "kind": "data", + "pk": { + "日期": "2026-09-12", + "时段": "8-9" + }, + "rowid": 569, + "part_seq": 2, + "part_rowid": 42, + "sheet": "客流汇总", + "cell": "B11", + "header": null, + "unit": null, + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "N4", + "text": null, + "locate_title": null + }, + { + "role": "section_title", + "column": "时段类别", + "sheet": "客流汇总", + "cell": "B9", + "text": null, + "locate_title": "日间时段客流(人次)" + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": { + "raw": "8-9", + "canonical": "8-9" + }, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"时段\" FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段\" = ?", + "params": [ + "2026-09-12", + "8-9" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s68": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s68", + "cell": { + "alias": "Q16", + "row": 0, + "column": "全日客流", + "artifact": "5edc5015fe959e59a2133d9b3e2d169da0113a1a7c1f255cc11648c51c8aa43c" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_many", + "snapshot_id": "f5c3257fec1db59a9131acb6388e726148f93383e5e291b456fa2209dc6c44a7", + "mode": "accumulate", + "union": true, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "80442a3834bb45b8ae98e5a37d5068f4", + "seq": 1, + "manifest": "2066f2a4e9e047d661be8a5a738a52c35c2f6f1daaabca786be79baf47545e07", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "c1b351c9f889536c87330b54a63b6a4e3e9519d95d0dac450bdb51d2cfb68cad", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.602455+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + }, + { + "import_id": "2306bfef0d9241868151faa953b107ce", + "seq": 2, + "manifest": "2896ac84161192c7ce280c9b0f7debef8adcd6fd9d0a72bfbc3c142bb46ab846", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "ac953010df2156845a570356a2f0ebe40f4cf3e915e728912b443affe3fe4e72", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.713436+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + }, + { + "import_id": "cb0851190d0b42b1a8263dd6b75b0449", + "seq": 3, + "manifest": "82df87c71a814136324efdfa24af09f01decaafc6bcbaeba5dfee242b5e65735", + "period": { + "start": "2026-10-01", + "end": "2026-10-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-10-01_2026-10-31.xlsx", + "raw_sha256": "b0da6c3684ab5d8dd4c2bd009d98f1b48ea211ecb91ea3914b7a653114867b50", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.833943+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + }, + { + "import_id": "04bd22ff9faf41b9949b1eeb2d933efa", + "seq": 4, + "manifest": "ad1ecd1db0b4914f36e2c40395b44d46c833a99e9258612d2835c5367b05d393", + "period": { + "start": "2026-11-01", + "end": "2026-11-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-11-01_2026-11-30.xlsx", + "raw_sha256": "efb6a88109643be9b0ea4d171a80e6bbd93144f8926a63399503f93ed4575b71", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "6f614510239309fd0d7cbfc6857c1b9170fa2a2d708e2f28d54f24a8a7fa3ad9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:22.959515+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-09-03" + }, + "rowid": 34, + "part_seq": 2, + "part_rowid": 3, + "sheet": "客流汇总", + "cell": "E5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "E4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-09-03" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "passed", + "row_status": "passed", + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s72": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s72", + "cell": { + "alias": "Q17", + "row": 0, + "column": "全日客流", + "artifact": "cedf48c0160bbbf2a17bec5905c94c27056f4a15ba0854927a0b569ba982a664" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_d26", + "snapshot_id": "3b41f5eab9b79313938d725ee0637e3bbb311137d9f86e05300406f94a3663f4", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "b31efefef4bd4f5a8cb0eeb79b7d0c84", + "seq": 2, + "manifest": "d6fb3d2c9635f7e4b5ccdd27f23c72f44d97acb8cb9bac3efbcfa5ee15201d29", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "e235c4d115dc44209d619a8f50c084c3780e2581e25dd6dd0a396b97d8403e83", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:23.245602+00:00", + "signed_by": null, + "acceptances": [ + { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + } + ], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-09-10" + }, + "rowid": 10, + "part_seq": 2, + "part_rowid": 10, + "sheet": "客流汇总", + "cell": "L5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "L4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-09-10" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "mismatch", + "row_status": "mismatch", + "cell_status": null, + "acceptance": { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + }, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s76": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s76", + "cell": { + "alias": "Q18", + "row": 0, + "column": "全日客流", + "artifact": "3332ffbb2a6c6839787ce53f9aed8587b14395c608fe13eaf87d09283a1dcb57" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_d26", + "snapshot_id": "3b41f5eab9b79313938d725ee0637e3bbb311137d9f86e05300406f94a3663f4", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "b31efefef4bd4f5a8cb0eeb79b7d0c84", + "seq": 2, + "manifest": "d6fb3d2c9635f7e4b5ccdd27f23c72f44d97acb8cb9bac3efbcfa5ee15201d29", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "e235c4d115dc44209d619a8f50c084c3780e2581e25dd6dd0a396b97d8403e83", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:23.245602+00:00", + "signed_by": null, + "acceptances": [ + { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + } + ], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-09-11" + }, + "rowid": 11, + "part_seq": 2, + "part_rowid": 11, + "sheet": "客流汇总", + "cell": "M5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "M4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-09-11" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "mismatch", + "row_status": "passed", + "cell_status": null, + "acceptance": { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + }, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s84": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s84", + "cell": { + "alias": "Q20", + "row": 0, + "column": "全日客流", + "artifact": "83773f6133ea865a319c5277f58e5631ee1507d8700a17c87ff20adf1615d232" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_human", + "snapshot_id": "6595b3469f286d9ae791f7342ae5a491343e44d54dc4ca5d0b64ffeeb3e0fe44", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "f2413c0de07147e0b6a473e99f912768", + "seq": 2, + "manifest": "56880c9a9827552211fd68b1ad5ef123f59e473ec9470352b6c596b8f63466d8", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "human", + "cells": [], + "signed_by": "录入员甲" + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "ba2e07268b7e6ba0a1094eb42311f3c30db24836db6fc6b39148bda548658bfe", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:23.549066+00:00", + "signed_by": "录入员甲", + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-09-04" + }, + "rowid": 4, + "part_seq": 2, + "part_rowid": 4, + "sheet": "客流汇总", + "cell": "F5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "F4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "human", + "cells": [], + "signed_by": "录入员甲", + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-09-04" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "passed", + "row_status": "passed", + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s88": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s88", + "cell": { + "alias": "Q21", + "row": 0, + "column": "金额", + "artifact": "0259c6e639da59e0346476e38c24f0f503aebb6c7f2b18ba07350ba9e7a59edc" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "list_demo", + "snapshot_id": "4f5487e6d54b9d7d634561f837654c8d218ab362dceaf6d25705e20aadf6d3d2", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "月报", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "e8b4fe17027748b9ac9ba6b07cb8acb2", + "seq": 1, + "manifest": "fc3ee2fe27dd49ac5f0be5e2583ac3b2a7bfe2bb166720bbbd9447f085a82250", + "period": null, + "file_name": "c01_literal.xlsx", + "raw_sha256": "87cdb3af4e5429d618c32a4d6f1c98ca897c506e01cd00249befc171f556c1be", + "region": "月报!C5:F9", + "excluded_rows": 0, + "recipe_sha256": "7b04cb1fbf11bec0d4e06782cd486b02dfc8a63b8c1dcaf04be45f7966b53fc9", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:23.591945+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "月报", + "column": "金额", + "column_role": "measure", + "kind": "data", + "pk": { + "地区": "分区丙" + }, + "rowid": 3, + "part_seq": 1, + "part_rowid": 3, + "sheet": "月报", + "cell": "F8", + "header": "金额", + "unit": null, + "from": [ + { + "role": "col_header", + "column": null, + "sheet": "月报", + "cell": "F5", + "text": "金额", + "locate_title": null + } + ], + "year": null, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"金额\" FROM \"月报\" WHERE \"地区\" = ?", + "params": [ + "分区丙" + ], + "ok": true + } + }, + "checks": [] + }, + "s92": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s92", + "cell": { + "alias": "Q22", + "row": 0, + "column": "金额", + "artifact": "c41e21a9be2a49971b36661e7426a3b42cdbe9eea8d1bccec9553bd7cff617e7" + }, + "status": "none", + "reason": { + "code": "simple_upload", + "detail": "", + "text": "这份表格按表头行直接导入,没有记录原表格子的溯源" + }, + "alert": null, + "sealed": true, + "version": null, + "cell_source": null, + "checks": [] + }, + "s52": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s52", + "cell": { + "alias": "Q13", + "row": 0, + "column": "全日客流", + "artifact": "4d916a46e3dfb26b760ad4b2caf9dc6fb3a6a3755a2542df6f6ba13307046540" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": false, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-08-14" + }, + "rowid": 14, + "part_seq": 1, + "part_rowid": 14, + "sheet": "客流汇总", + "cell": "P5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "P4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-08-14" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "passed", + "row_status": "passed", + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s32": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s32", + "cell": { + "alias": "Q8", + "row": 0, + "column": "全日客流", + "artifact": "751ed12d5c97620b0c5ee6b0c27331f897f40f5934abf45cb80abceeb191b9ee" + }, + "status": "table_only", + "reason": { + "code": "db_tampered", + "detail": "", + "text": "数据文件与登记的版本不一致,可能被修改过:只显示导入清单记录的来历" + }, + "alert": { + "code": "db_tampered", + "text": "数据文件与登记的版本不一致,可能被修改过:只显示导入清单记录的来历" + }, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s36": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s36", + "cell": { + "alias": "Q9", + "row": 0, + "column": "全日客流", + "artifact": "ebdf3b88d9111534279091ba729243b3bd8792ee812b7438309745509a645391" + }, + "status": "table_only", + "reason": { + "code": "chain_mismatch", + "detail": "", + "text": "数据文件的登记与导入清单不一致,可能被修改过" + }, + "alert": { + "code": "chain_mismatch", + "text": "数据文件的登记与导入清单不一致,可能被修改过" + }, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s48": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s48", + "cell": { + "alias": "Q12", + "row": 0, + "column": "全日客流", + "artifact": "8b9ea1c080e043372d401d0ba8df19f34865f9dbad07e1ceee5f83174285b4ff" + }, + "status": "table_only", + "reason": { + "code": "snapshot_gone", + "detail": "", + "text": "这一版的数据文件已回收或不存在,无法按主键回查,只显示导入清单记录的来历" + }, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "kept", + "purged": null, + "revoked": null, + "has_row": false + } + ] + }, + "cell_source": null, + "checks": [] + }, + "s44": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s44", + "cell": { + "alias": "Q11", + "row": 0, + "column": "全日客流", + "artifact": "fed1edcaaa5c49e27fd581973bbdea6253a042088e2761f1b06081a4c5d99b20" + }, + "status": "none", + "reason": { + "code": "manifest_unreadable", + "detail": "hash_mismatch:094761dce4fe", + "text": "导入清单无法读取或与哈希不一致,无法给出来历" + }, + "alert": { + "code": "manifest_unreadable", + "text": "导入清单无法读取或与哈希不一致,无法给出来历" + }, + "sealed": true, + "version": null, + "cell_source": null, + "checks": [] + }, + "s40": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s40", + "cell": { + "alias": "Q10", + "row": 0, + "column": "全日客流", + "artifact": "83a8efbdefb8caf41fca1ecd1cb3604dcff5bdf701aa52fe400a56dc5b93582e" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_demo", + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "seq": 1, + "manifest": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "period": { + "start": "2026-08-01", + "end": "2026-08-31", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "region": "客流汇总!B4:AG30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:21.941854+00:00", + "signed_by": null, + "acceptances": [], + "raw_state": "purged", + "purged": { + "at": "2026-10-02T05:09:26.660157+00:00", + "signed_by": "录入员甲", + "reason": "合成理由:原件不再保留" + }, + "revoked": null, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-08-11" + }, + "rowid": 11, + "part_seq": 1, + "part_rowid": 11, + "sheet": "客流汇总", + "cell": "M5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "M4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": true, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-08-11" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "passed", + "row_status": "passed", + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + }, + "s80": { + "schema": "agentlab.provenance/1", + "report": { + "node_id": "write", + "doc_artifact": "81c90984d094f9a5b34173271d50edf262a9b8e92f4bf47189f7ad236d470df0" + }, + "segment": "s80", + "cell": { + "alias": "Q19", + "row": 0, + "column": "全日客流", + "artifact": "bb0d464f15f1cb725b03abc3eeca500184512dcf3bac80123b5744a4fbf833d1" + }, + "status": "inferred", + "reason": null, + "alert": null, + "sealed": true, + "version": { + "source": "flow_d26", + "snapshot_id": "3b41f5eab9b79313938d725ee0637e3bbb311137d9f86e05300406f94a3663f4", + "mode": "replace", + "union": false, + "tables": [ + { + "name": "日客流", + "kind": "data" + } + ], + "manifest_view": true, + "parts": [ + { + "import_id": "b31efefef4bd4f5a8cb0eeb79b7d0c84", + "seq": 2, + "manifest": "d6fb3d2c9635f7e4b5ccdd27f23c72f44d97acb8cb9bac3efbcfa5ee15201d29", + "period": { + "start": "2026-09-01", + "end": "2026-09-30", + "source": "cells", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null + }, + "file_name": "月报导出_2026-09-01_2026-09-30.xlsx", + "raw_sha256": "e235c4d115dc44209d619a8f50c084c3780e2581e25dd6dd0a396b97d8403e83", + "region": "客流汇总!B4:AF30", + "excluded_rows": 0, + "recipe_sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4", + "recipe_seq": 1, + "committed_at": "2026-10-02T05:09:23.245602+00:00", + "signed_by": null, + "acceptances": [ + { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + } + ], + "raw_state": "kept", + "purged": null, + "revoked": { + "at": "2026-10-02T05:09:26.678068+00:00", + "signed_by": null, + "reason": "合成理由:接受依据有误" + }, + "has_row": true + } + ] + }, + "cell_source": { + "table": "日客流", + "column": "全日客流", + "column_role": "measure", + "kind": "data", + "pk": { + "日期": "2026-09-12" + }, + "rowid": 12, + "part_seq": 2, + "part_rowid": 12, + "sheet": "客流汇总", + "cell": "N5", + "header": "全日客流(人次)", + "unit": "人次", + "from": [ + { + "role": "axis_header", + "column": "日期", + "sheet": "客流汇总", + "cell": "N4", + "text": null, + "locate_title": null + }, + { + "role": "row_label", + "column": null, + "sheet": "客流汇总", + "cell": "B5", + "text": "全日客流(人次)", + "locate_title": null + } + ], + "year": { + "source": "period", + "cells": [ + "客流汇总!B2" + ], + "signed_by": null, + "mixed": false + }, + "canonical": null, + "raw_purged": false, + "merged_fill": false, + "recheck": { + "sql": "SELECT rowid, \"全日客流\" FROM \"日客流\" WHERE \"日期\" = ?", + "params": [ + "2026-09-12" + ], + "ok": true + } + }, + "checks": [ + { + "id": "R1", + "kind": "relation_sum_eq", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "part_status": "mismatch", + "row_status": "passed", + "cell_status": null, + "acceptance": { + "check_id": "R1", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "kind": "override", + "reason": "合成理由:分区合计口径调整", + "signed_by": null, + "at": "2026-10-02T05:09:23.238480+00:00" + }, + "detail": null + }, + { + "id": "R2", + "kind": "relation_not_comparable", + "title": "口径不同:「时段客流」与「日客流」", + "part_status": "info", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C1", + "kind": "context_agree", + "title": "统计期多处一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + }, + { + "id": "C2", + "kind": "filename_period", + "title": "文件名中的日期与统计期一致", + "part_status": "passed", + "row_status": null, + "cell_status": null, + "acceptance": null, + "detail": null + } + ] + } + }, + "errors": { + "server": { + "status": 500, + "json": { + "detail": "服务端出错" + } + }, + "old_server": { + "status": 404, + "json": { + "detail": "Not Found" + } + }, + "unexpected": { + "status": 418, + "json": { + "code": "harness_unexpected_provenance" + } + } + }, + "artifacts": { + "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d": { + "id": "094761dce4fea6211cec3eb2d511eb516c898c84055d4894460ced7815e0b77d", + "content": { + "acceptances": { + "overrides": [], + "waivers": [] + }, + "ai": { + "consents": [], + "usage": [] + }, + "build_id": "8a19e1da1d8d992ae29bc42559abe8972f67169f52ac55d58ac91699de45c623", + "build_reused": false, + "checks": [ + { + "acceptable": false, + "category": "data_quality", + "cells": [ + "客流汇总!B2" + ], + "checked": 1, + "details": [ + "客流汇总!B2:2026-08-01 至 2026-08-31" + ], + "failed": 0, + "id": "C1", + "kind": "context_agree", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "统计期多处一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 1, + "details": [], + "failed": 0, + "id": "C2", + "kind": "filename_period", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "文件名中的日期与统计期一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 93, + "details": [ + "93 格全部一致" + ], + "failed": 0, + "id": "K1", + "kind": "derived_sum", + "params": [ + "2026-08-01", + "夜间", + 18, + 22 + ], + "reasons": {}, + "sql": "SELECT TOTAL(\"客流\"), COUNT(\"客流\"), COUNT(*) FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段类别\" = ? AND \"起始小时\" >= ? AND \"结束小时\" <= ?", + "status": "passed", + "title": "合计按明细重算(分段「夜间合计」)", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 93, + "details": [ + "93 格全部一致" + ], + "failed": 0, + "id": "G1", + "kind": "formula_refs", + "params": [ + "2026-08-01", + "夜间", + 18, + 22 + ], + "reasons": {}, + "sql": "SELECT rowid FROM \"时段客流\" WHERE \"日期\" = ? AND \"时段类别\" = ? AND \"起始小时\" >= ? AND \"结束小时\" <= ?", + "status": "passed", + "title": "合计公式引用(分段「夜间合计」)", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 31, + "details": [ + "31 行全部成立" + ], + "failed": 0, + "id": "R1", + "kind": "relation_sum_eq", + "params": [], + "reasons": {}, + "sql": "SELECT rowid, \"日期\", \"全日客流\", (\"分区甲\" + \"分区乙\") FROM \"日客流\" WHERE \"全日客流\" IS NOT NULL AND \"分区甲\" IS NOT NULL AND \"分区乙\" IS NOT NULL AND \"全日客流\" <> \"分区甲\" + \"分区乙\" ORDER BY rowid", + "status": "passed", + "title": "「日客流」:全日客流 = 分区甲 + 分区乙", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "info", + "cells": [], + "checked": 31, + "details": [ + "各时段之和与全日客流:31 天中 0 天相等" + ], + "failed": 31, + "id": "R2", + "kind": "relation_not_comparable", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT \"日期\", TOTAL(\"客流\") AS __sum FROM \"时段客流\" GROUP BY \"日期\") a JOIN \"日客流\" b ON a.\"日期\" = b.\"日期\" WHERE a.__sum = b.\"全日客流\"", + "status": "info", + "title": "口径不同:「时段客流」与「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "31 行,与格子账一致" + ], + "failed": 0, + "id": "N1", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"日客流\"", + "status": "passed", + "title": "行数:「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "527 行,与格子账一致" + ], + "failed": 0, + "id": "N2", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"时段客流\"", + "status": "passed", + "title": "行数:「时段客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "93 行,与格子账一致" + ], + "failed": 0, + "id": "N3", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"时段客流_表内合计\"", + "status": "passed", + "title": "行数:「时段客流_表内合计」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P1", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"日客流\" GROUP BY \"日期\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「日客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P2", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"时段客流\" GROUP BY \"日期\", \"时段\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「时段客流」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P3", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"时段客流_表内合计\" GROUP BY \"日期\", \"合计项\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「时段客流_表内合计」", + "unverifiable": 0 + } + ], + "confirmations": [ + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "placeholder:·", + "label": "「·」存为空值(表示无数据,本期 31 格)" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "derived:夜间合计", + "label": "第 28–30 行改作核对,原值另存表「时段客流_表内合计」" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "year_from:交叉表", + "label": "「交叉表」的日期年份取自统计期" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "relation:R1", + "label": "每期核对「全日客流 = 分区甲 + 分区乙」(表「日客流」)" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "relation:R2", + "label": "表「时段客流」与表「日客流」口径不同,不互相推算或相加" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "unit:日客流.全日客流", + "label": "「全日客流(人次)」→ 表「日客流」的列「全日客流」,单位 人次" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "unit:日客流.分区甲", + "label": "「分区甲(人次)」→ 表「日客流」的列「分区甲」,单位 人次" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "unit:日客流.分区乙", + "label": "「分区乙(人次)」→ 表「日客流」的列「分区乙」,单位 人次" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "unit:时段客流.客流", + "label": "表「时段客流」的列「客流」,单位 人次" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "unit:时段客流_表内合计.客流", + "label": "表「时段客流_表内合计」的列「客流」,单位 人次" + }, + { + "at": "2026-10-02T05:09:21.933117+00:00", + "id": "mode", + "label": "导入模式:每期替换" + } + ], + "created_at": "2026-10-02T05:09:21.941854+00:00", + "db_sha256": "c387b56920113e49f351af5683cdb0d73ec7af35daf16d5f10bcf011896d58d2", + "edits": [], + "engine_ver": "recipe/1", + "file": { + "name": "月报导出_2026-08-01_2026-08-31.xlsx", + "raw_sha256": "97dc0a1d8199c7c9f2e2cbf2ff7f78015cb0dd7b9a05c2c20563e1e5ab8db878", + "size": 9760 + }, + "format": "agentlab-import-manifest/1", + "import_id": "6dda41b1fa97487b97916fdc2d1b8a1f", + "kind": "first", + "notes": { + "rendered": { + "日客流": { + "columns": { + "全日客流": "单位:人次", + "分区乙": "单位:人次", + "分区甲": "单位:人次", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期。全日客流 等于 分区甲 与 分区乙 之和(导入时已逐日核对):求合计只用 全日客流,不要把这几列相加。与 时段客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加" + }, + "时段客流": { + "columns": { + "客流": "单位:人次。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期的每个时段。与 日客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加。时段 按规范写法存储:起始小时-结束小时,不带单位。时段类别 取自原表的分段标题。原表的合计在 时段客流_表内合计,不要与本表相加" + }, + "时段客流_表内合计": { + "columns": { + "客流": "单位:人次", + "日期": "格式 YYYY-MM-DD,年份按统计期补全" + }, + "comment": "粒度:每个日期的每个合计项。原表写明的合计,已按明细重算核对一致。各合计项覆盖的时段可能互相重叠:不要彼此相加,也不要与 时段客流 相加" + } + }, + "templates": { + "日客流": "粒度:每个{列:日期}。{列:全日客流} 等于 {列:分区甲} 与 {列:分区乙} 之和(导入时已逐日核对):求合计只用 {列:全日客流},不要把这几列相加。与 {表:时段客流} 口径不同:各{列:时段}之和不等于 {列:全日客流},不能互相推算或相加", + "日客流.全日客流": "单位:{单位:人次}", + "日客流.分区乙": "单位:{单位:人次}", + "日客流.分区甲": "单位:{单位:人次}", + "日客流.日期": "格式 YYYY-MM-DD,年份按统计期补全", + "时段客流": "粒度:每个{列:日期}的每个{列:时段}。与 {表:日客流} 口径不同:各{列:时段}之和不等于 {列:全日客流},不能互相推算或相加。{列:时段} 按规范写法存储:{列:起始小时}-{列:结束小时},不带单位。{列:时段类别} 取自原表的分段标题。原表的合计在 {表:时段客流_表内合计},不要与本表相加", + "时段客流.客流": "单位:{单位:人次}。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数", + "时段客流.日期": "格式 YYYY-MM-DD,年份按统计期补全", + "时段客流_表内合计": "粒度:每个{列:日期}的每个{列:合计项}。原表写明的合计,已按明细重算核对一致。各{列:合计项}覆盖的时段可能互相重叠:不要彼此相加,也不要与 {表:时段客流} 相加", + "时段客流_表内合计.客流": "单位:{单位:人次}", + "时段客流_表内合计.日期": "格式 YYYY-MM-DD,年份按统计期补全" + } + }, + "parser_ver": "parsers/2", + "period": { + "annotated": {}, + "cells": [ + "客流汇总!B2" + ], + "end": "2026-08-31", + "signed_by": null, + "source": "cells", + "start": "2026-08-01", + "texts": { + "客流汇总!B2": "统计时间范围:2026年8月1日至2026年8月31日" + } + }, + "receipt": { + "axes": [ + { + "block": "交叉表", + "count": 31, + "first": "2026-08-01", + "form": "text", + "last": "2026-08-31", + "row": 4 + } + ], + "blank_rows_skipped": 0, + "block_order": { + "客流汇总": [ + "日客流", + "日间", + "夜间", + "夜间合计" + ] + }, + "canonicalized": [], + "context_checks": [ + { + "acceptable": false, + "category": "data_quality", + "cells": [ + "客流汇总!B2" + ], + "checked": 1, + "details": [ + "客流汇总!B2:2026-08-01 至 2026-08-31" + ], + "failed": 0, + "id": "C1", + "kind": "context_agree", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "统计期多处一致", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "data_quality", + "cells": [], + "checked": 1, + "details": [], + "failed": 0, + "id": "C2", + "kind": "filename_period", + "params": [], + "reasons": {}, + "sql": null, + "status": "passed", + "title": "文件名中的日期与统计期一致", + "unverifiable": 0 + } + ], + "derived": [ + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(C22:C25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 342, + 373, + 404, + 435 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2063 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(D22:D25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 343, + 374, + 405, + 436 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2128 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(E22:E25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 344, + 375, + 406, + 437 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1343 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(F22:F25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 345, + 376, + 407, + 438 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2282 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(G22:G25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 346, + 377, + 408, + 439 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1184 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(H22:H25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 347, + 378, + 409, + 440 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 913 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(I22:I25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 348, + 379, + 410, + 441 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2138 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(J22:J25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 349, + 380, + 411, + 442 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1993 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(K22:K25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 350, + 381, + 412, + 443 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2748 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(L22:L25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 351, + 382, + 413, + 444 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2417 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(M22:M25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 352, + 383, + 414, + 445 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2125 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(N22:N25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 353, + 384, + 415, + 446 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2291 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(O22:O25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 354, + 385, + 416, + 447 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2264 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(P22:P25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 355, + 386, + 417, + 448 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1907 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Q22:Q25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 356, + 387, + 418, + 449 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 976 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(R22:R25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 357, + 388, + 419, + 450 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2160 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(S22:S25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 358, + 389, + 420, + 451 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2162 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(T22:T25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 359, + 390, + 421, + 452 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 923 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(U22:U25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 360, + 391, + 422, + 453 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2550 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(V22:V25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 361, + 392, + 423, + 454 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1600 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(W22:W25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 362, + 393, + 424, + 455 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1357 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(X22:X25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 363, + 394, + 425, + 456 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1286 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Y22:Y25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 364, + 395, + 426, + 457 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2366 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(Z22:Z25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 365, + 396, + 427, + 458 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2233 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AA22:AA25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 366, + 397, + 428, + 459 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2764 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AB22:AB25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 367, + 398, + 429, + 460 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1308 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AC22:AC25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 368, + 399, + 430, + 461 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2084 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AD22:AD25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 369, + 400, + 431, + 462 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1533 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AE22:AE25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 370, + 401, + 432, + 463 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1887 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AF22:AF25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 371, + 402, + 433, + 464 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 893 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG28", + "end": 22, + "end_col": "结束小时", + "formula": "=SUM(AG22:AG25)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-22时合计", + "label_raw": "18-22 时合计", + "ref_problem": null, + "ref_rowids": [ + 372, + 403, + 434, + 465 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1738 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(C26:C27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 466, + 497 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 702 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(D26:D27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 467, + 498 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1069 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(E26:E27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 468, + 499 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1305 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(F26:F27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 469, + 500 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 396 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(G26:G27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 470, + 501 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 511 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(H26:H27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 471, + 502 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 900 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(I26:I27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 472, + 503 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 879 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(J26:J27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 473, + 504 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1134 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(K26:K27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 474, + 505 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1273 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(L26:L27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 475, + 506 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 910 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(M26:M27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 476, + 507 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 751 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(N26:N27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 477, + 508 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1356 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(O26:O27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 478, + 509 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1334 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(P26:P27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 479, + 510 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 640 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Q26:Q27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 480, + 511 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1243 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(R26:R27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 481, + 512 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 778 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(S26:S27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 482, + 513 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1438 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(T26:T27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 483, + 514 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 797 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(U26:U27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 484, + 515 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 372 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(V26:V27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 485, + 516 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 984 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(W26:W27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 486, + 517 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 561 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(X26:X27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 487, + 518 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 756 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Y26:Y27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 488, + 519 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1698 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Z26:Z27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 489, + 520 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1097 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AA26:AA27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 490, + 521 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1064 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AB26:AB27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 491, + 522 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1147 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AC26:AC27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 492, + 523 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 687 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AD26:AD27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 493, + 524 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1439 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AE26:AE27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 494, + 525 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 575 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AF26:AF27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 495, + 526 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 1138 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG29", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AG26:AG27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "22-24时合计", + "label_raw": "22-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 496, + 527 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 22, + "start_col": "起始小时", + "value": 662 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!C30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(C22:C27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-01", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 342, + 373, + 404, + 435, + 466, + 497 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2765 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!D30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(D22:D27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-02", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 343, + 374, + 405, + 436, + 467, + 498 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3197 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!E30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(E22:E27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-03", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 344, + 375, + 406, + 437, + 468, + 499 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2648 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!F30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(F22:F27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-04", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 345, + 376, + 407, + 438, + 469, + 500 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2678 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!G30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(G22:G27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-05", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 346, + 377, + 408, + 439, + 470, + 501 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1695 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!H30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(H22:H27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-06", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 347, + 378, + 409, + 440, + 471, + 502 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1813 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!I30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(I22:I27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-07", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 348, + 379, + 410, + 441, + 472, + 503 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3017 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!J30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(J22:J27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-08", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 349, + 380, + 411, + 442, + 473, + 504 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3127 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!K30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(K22:K27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-09", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 350, + 381, + 412, + 443, + 474, + 505 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 4021 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!L30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(L22:L27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-10", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 351, + 382, + 413, + 444, + 475, + 506 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3327 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!M30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(M22:M27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-11", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 352, + 383, + 414, + 445, + 476, + 507 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2876 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!N30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(N22:N27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-12", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 353, + 384, + 415, + 446, + 477, + 508 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3647 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!O30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(O22:O27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-13", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 354, + 385, + 416, + 447, + 478, + 509 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3598 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!P30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(P22:P27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-14", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 355, + 386, + 417, + 448, + 479, + 510 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2547 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Q30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Q22:Q27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-15", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 356, + 387, + 418, + 449, + 480, + 511 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2219 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!R30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(R22:R27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-16", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 357, + 388, + 419, + 450, + 481, + 512 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2938 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!S30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(S22:S27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-17", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 358, + 389, + 420, + 451, + 482, + 513 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3600 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!T30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(T22:T27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-18", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 359, + 390, + 421, + 452, + 483, + 514 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1720 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!U30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(U22:U27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-19", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 360, + 391, + 422, + 453, + 484, + 515 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2922 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!V30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(V22:V27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-20", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 361, + 392, + 423, + 454, + 485, + 516 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2584 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!W30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(W22:W27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-21", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 362, + 393, + 424, + 455, + 486, + 517 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 1918 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!X30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(X22:X27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-22", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 363, + 394, + 425, + 456, + 487, + 518 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2042 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Y30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Y22:Y27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-23", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 364, + 395, + 426, + 457, + 488, + 519 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 4064 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!Z30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(Z22:Z27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-24", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 365, + 396, + 427, + 458, + 489, + 520 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3330 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AA30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AA22:AA27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-25", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 366, + 397, + 428, + 459, + 490, + 521 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 3828 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AB30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AB22:AB27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-26", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 367, + 398, + 429, + 460, + 491, + 522 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2455 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AC30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AC22:AC27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-27", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 368, + 399, + 430, + 461, + 492, + 523 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2771 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AD30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AD22:AD27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-28", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 369, + 400, + 431, + 462, + 493, + 524 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2972 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AE30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AE22:AE27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-29", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 370, + 401, + 432, + 463, + 494, + 525 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2462 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AF30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AF22:AF27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-30", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 371, + 402, + 433, + 464, + 495, + 526 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2031 + }, + { + "base_table": "时段客流", + "base_value": "客流", + "cell": "客流汇总!AG30", + "end": 24, + "end_col": "结束小时", + "formula": "=SUM(AG22:AG27)", + "func": "SUM", + "hidden_in_range": false, + "is_formula": true, + "keep_table": "时段客流_表内合计", + "key": { + "日期": "2026-08-31", + "时段类别": "夜间" + }, + "kind": "label_range_sum", + "label": "18-24时合计", + "label_raw": "18-24 时合计", + "ref_problem": null, + "ref_rowids": [ + 372, + 403, + 434, + 465, + 496, + 527 + ], + "rowid_first": null, + "rowid_last": null, + "segment": "夜间合计", + "sheet": "客流汇总", + "start": 18, + "start_col": "起始小时", + "value": 2400 + } + ], + "derived_form": { + "夜间合计": "formula" + }, + "expected_rows": { + "日客流": 31, + "时段客流": 527, + "时段客流_表内合计": 93 + }, + "formula_cells_accepted": 0, + "full_calc_on_load": false, + "hidden": {}, + "ignored_columns": {}, + "labels": [ + { + "canonical": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ], + "raw": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ], + "rows": [ + 5, + 6, + 7 + ], + "segment": "日客流" + }, + { + "canonical": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ], + "raw": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ], + "rows": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "segment": "日间" + }, + { + "canonical": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ], + "raw": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ], + "rows": [ + 22, + 23, + 24, + 25, + 26, + 27 + ], + "segment": "夜间" + }, + { + "canonical": [ + "18-22时合计", + "22-24时合计", + "18-24时合计" + ], + "raw": [ + "18-22 时合计", + "22-24 时合计", + "18-24 时合计" + ], + "rows": [ + 28, + 29, + 30 + ], + "segment": "夜间合计" + } + ], + "ledger": [ + { + "nonempty_read": 771, + "nonempty_scan": 771, + "roles": { + "col_header": 31, + "context": 1, + "derived_label": 3, + "derived_value": 93, + "outside_text": 1, + "row_label": 20, + "section_title": 2, + "value": 620 + }, + "sheet": "客流汇总", + "unclaimed": 0 + } + ], + "lineage": { + "日客流": { + "全日客流": [ + [ + 1, + "客流汇总", + "C5", + 31, + "right" + ] + ], + "分区乙": [ + [ + 1, + "客流汇总", + "C7", + 31, + "right" + ] + ], + "分区甲": [ + [ + 1, + "客流汇总", + "C6", + 31, + "right" + ] + ] + }, + "时段客流": { + "客流": [ + [ + 1, + "客流汇总", + "C10", + 31, + "right" + ], + [ + 32, + "客流汇总", + "C11", + 31, + "right" + ], + [ + 63, + "客流汇总", + "C12", + 31, + "right" + ], + [ + 94, + "客流汇总", + "C13", + 31, + "right" + ], + [ + 125, + "客流汇总", + "C14", + 31, + "right" + ], + [ + 156, + "客流汇总", + "C15", + 31, + "right" + ], + [ + 187, + "客流汇总", + "C16", + 31, + "right" + ], + [ + 218, + "客流汇总", + "C17", + 31, + "right" + ], + [ + 249, + "客流汇总", + "C18", + 31, + "right" + ], + [ + 280, + "客流汇总", + "C19", + 31, + "right" + ], + [ + 311, + "客流汇总", + "C20", + 31, + "right" + ], + [ + 342, + "客流汇总", + "C22", + 31, + "right" + ], + [ + 373, + "客流汇总", + "C23", + 31, + "right" + ], + [ + 404, + "客流汇总", + "C24", + 31, + "right" + ], + [ + 435, + "客流汇总", + "C25", + 31, + "right" + ], + [ + 466, + "客流汇总", + "C26", + 31, + "right" + ], + [ + 497, + "客流汇总", + "C27", + 31, + "right" + ] + ] + }, + "时段客流_表内合计": { + "客流": [ + [ + 1, + "客流汇总", + "C28", + 31, + "right" + ], + [ + 32, + "客流汇总", + "C29", + 31, + "right" + ], + [ + 63, + "客流汇总", + "C30", + 31, + "right" + ] + ] + } + }, + "ok": true, + "outside_text": [ + { + "cell": "客流汇总!B3", + "hidden": false, + "kind": "text", + "period_source": false, + "sheet": "客流汇总", + "text": "客流汇总表" + } + ], + "partial": false, + "period": { + "annotated": {}, + "cells": [ + "客流汇总!B2" + ], + "end": "2026-08-31", + "signed_by": null, + "source": "cells", + "start": "2026-08-01", + "texts": { + "客流汇总!B2": "统计时间范围:2026年8月1日至2026年8月31日" + } + }, + "placeholders": { + "·": 31 + }, + "regions": [ + { + "block": null, + "ref": "B2", + "role": "context", + "sheet": "客流汇总" + }, + { + "block": null, + "ref": "B3", + "role": "outside_text", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C4:AG4", + "role": "col_header", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B5:B7", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C5:AG7", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B9", + "role": "section_title", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B10:B20", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C10:AG20", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B21", + "role": "section_title", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B22:B27", + "role": "row_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C22:AG27", + "role": "value", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "B28:B30", + "role": "derived_label", + "sheet": "客流汇总" + }, + { + "block": "交叉表", + "ref": "C28:AG30", + "role": "derived_value", + "sheet": "客流汇总" + } + ], + "rows_excluded": [], + "sheets": { + "matched": { + "s1": "客流汇总" + }, + "other_visible": [], + "renamed": {}, + "skipped_hidden": [] + }, + "table_hashes": { + "日客流": "2ee5aeb5f8786f614f769c1c0402b3df5a80ef2629ac5052285f9c961ab56c8a", + "时段客流": "408d019b158a13bff0f2dfb8924b12f688bc9c04f4061def893b6145fde4da63", + "时段客流_表内合计": "dcc7a5cda810b865b88b4f5ed2a655b767eb7dd915363af3ebfa54e17658a4e2" + }, + "tables": [ + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": "全日客流(人次)", + "name": "全日客流", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + }, + { + "header": "分区甲(人次)", + "name": "分区甲", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + }, + { + "header": "分区乙(人次)", + "name": "分区乙", + "role": "measure", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期" + ], + "kind": "data", + "name": "日客流", + "rows": 31, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/日客流" + ] + }, + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "时段", + "role": "dim", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "时段类别", + "role": "const", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "起始小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "结束小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "客流", + "role": "value", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期", + "时段" + ], + "kind": "data", + "name": "时段客流", + "rows": 527, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/日间", + "s1/交叉表/夜间" + ] + }, + { + "columns": [ + { + "header": null, + "name": "日期", + "role": "axis", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "合计项", + "role": "dim", + "type": "TEXT", + "unit": null + }, + { + "header": null, + "name": "起始小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "结束小时", + "role": "derive", + "type": "INTEGER", + "unit": null + }, + { + "header": null, + "name": "客流", + "role": "value", + "type": "INTEGER", + "unit": "人次" + } + ], + "grain": [ + "日期", + "合计项" + ], + "kind": "reported_total", + "name": "时段客流_表内合计", + "rows": 93, + "sheet": "客流汇总", + "sources": [ + "s1/交叉表/夜间合计" + ] + } + ], + "timings": { + "extract": 0.0017, + "match": 0.0005, + "read_locate": 0.0083, + "receipt": 0.0, + "total": 0.0131, + "write": 0.0027 + } + }, + "recipe": { + "canonical": { + "recipe_format": "agentlab-recipe/2", + "relations": [ + { + "claims": "F1", + "id": "R1", + "kind": "sum_eq", + "parts": [ + "分区甲", + "分区乙" + ], + "table": "日客流", + "total": "全日客流" + }, + { + "a": { + "table": "时段客流", + "value": "客流" + }, + "b": { + "table": "日客流", + "value": "全日客流" + }, + "by": "日期", + "claims": "F2", + "id": "R2", + "kind": "not_comparable" + } + ], + "sheets": [ + { + "blocks": [ + { + "id": "交叉表", + "layout": "crosstab", + "segments": [ + { + "id": "日客流", + "labels": { + "expect": [ + "全日客流(人次)", + "分区甲(人次)", + "分区乙(人次)" + ] + }, + "locate": { + "by": "labels" + }, + "measures": { + "全日客流(人次)": "全日客流", + "分区乙(人次)": "分区乙", + "分区甲(人次)": "分区甲" + }, + "role": "measures", + "table": "日客流" + }, + { + "const": { + "时段类别": { + "pick": "日间" + } + }, + "dim": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "name": "时段", + "parser": "hour_range" + }, + "id": "日间", + "labels": { + "expect": [ + "7-8", + "8-9", + "9-10", + "10-11", + "11-12", + "12-13", + "13-14", + "14-15", + "15-16", + "16-17", + "17-18" + ] + }, + "locate": { + "by": "section_title", + "title": "日间时段客流(人次)" + }, + "role": "dimension", + "table": "时段客流", + "value": "客流" + }, + { + "const": { + "时段类别": { + "pick": "夜间" + } + }, + "dim": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "name": "时段", + "parser": "hour_range" + }, + "id": "夜间", + "labels": { + "expect": [ + "18-19", + "19-20", + "20-21", + "21-22", + "22-23", + "23-24" + ] + }, + "locate": { + "by": "section_title", + "title": "夜间时段客流(人次)" + }, + "role": "dimension", + "stop_parser": "hour_range_total", + "table": "时段客流", + "value": "客流" + }, + { + "id": "夜间合计", + "keep_as": { + "derive": { + "结束小时": "end", + "起始小时": "start" + }, + "dim": "合计项", + "table": "时段客流_表内合计", + "value": "客流" + }, + "labels": { + "expect": [ + "18-22 时合计", + "22-24 时合计", + "18-24 时合计" + ] + }, + "locate": { + "by": "after", + "segment": "夜间" + }, + "role": "derived", + "verify": { + "against_table": "时段客流", + "value": "客流" + } + } + ], + "values": { + "placeholders": [ + { + "text": "·" + } + ] + } + } + ], + "context": [ + { + "prefer_prefix": "统计时间范围" + } + ], + "id": "s1", + "match": { + "name": "客流汇总" + } + } + ], + "tables": [ + { + "grain": [ + "日期" + ], + "name": "日客流", + "units": { + "全日客流": "人次", + "分区乙": "人次", + "分区甲": "人次" + } + }, + { + "grain": [ + "日期", + "时段" + ], + "name": "时段客流", + "units": { + "客流": "人次" + } + }, + { + "grain": [ + "日期", + "合计项" + ], + "kind": "reported_total", + "name": "时段客流_表内合计", + "units": { + "客流": "人次" + } + } + ] + }, + "id": "6fba9007aded444298f561f68c0e0f79", + "origin": "manual", + "sha256": "971a8eba5ac606bd0552c43ff83b5c361d134c77d471bfe2d28e440ebfcc07b4" + }, + "seq": 1, + "signed_by": { + "name": null, + "verified": false + }, + "snapshot_id": "55cd35daf18a56a2316b0955fda7bac76666de49127d2bb677aaece6efa158b5", + "source_id": "0a6474ffe4ea4ea795bd4bcc09328470", + "staging_id": "df86ade23b9a49489ffa7fe335703851", + "table_hashes": { + "日客流": "2ee5aeb5f8786f614f769c1c0402b3df5a80ef2629ac5052285f9c961ab56c8a", + "时段客流": "408d019b158a13bff0f2dfb8924b12f688bc9c04f4061def893b6145fde4da63", + "时段客流_表内合计": "dcc7a5cda810b865b88b4f5ed2a655b767eb7dd915363af3ebfa54e17658a4e2" + }, + "trial_db_sha256": "c387b56920113e49f351af5683cdb0d73ec7af35daf16d5f10bcf011896d58d2" + } + }, + "fc3ee2fe27dd49ac5f0be5e2583ac3b2a7bfe2bb166720bbbd9447f085a82250": { + "id": "fc3ee2fe27dd49ac5f0be5e2583ac3b2a7bfe2bb166720bbbd9447f085a82250", + "content": { + "acceptances": { + "overrides": [], + "waivers": [] + }, + "ai": { + "consents": [], + "usage": [] + }, + "build_id": "9962c7a24df5c361e23c4f72ed4f3e28c6ad84ecf8b1a016a875d680f438c10d", + "build_reused": false, + "checks": [ + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 2, + "details": [ + "2 格全部一致" + ], + "failed": 0, + "id": "T1", + "kind": "column_sum", + "params": [ + 1, + 3 + ], + "reasons": {}, + "sql": "SELECT TOTAL(\"销量\"), COUNT(\"销量\"), COUNT(*) FROM \"月报\" WHERE rowid BETWEEN ? AND ?", + "status": "passed", + "title": "合计行按列求和(块「列表1」)", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "3 行,与格子账一致" + ], + "failed": 0, + "id": "N1", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"月报\"", + "status": "passed", + "title": "行数:「月报」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "1 行,与格子账一致" + ], + "failed": 0, + "id": "N2", + "kind": "row_count", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM \"月报_表内合计\"", + "status": "passed", + "title": "行数:「月报_表内合计」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P1", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"月报\" GROUP BY \"地区\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「月报」", + "unverifiable": 0 + }, + { + "acceptable": false, + "category": "structure", + "cells": [], + "checked": 1, + "details": [ + "主键没有重复" + ], + "failed": 0, + "id": "P2", + "kind": "pk_unique", + "params": [], + "reasons": {}, + "sql": "SELECT COUNT(*) FROM (SELECT 1 FROM \"月报_表内合计\" GROUP BY \"合计项\" HAVING COUNT(*) > 1)", + "status": "passed", + "title": "主键唯一:「月报_表内合计」", + "unverifiable": 0 + } + ], + "confirmations": [ + { + "at": "2026-10-02T05:09:23.588034+00:00", + "id": "total_row:列表1", + "label": "「列表1」的合计行(第 9 行)改作核对,原值另存表「月报_表内合计」" + }, + { + "at": "2026-10-02T05:09:23.588034+00:00", + "id": "mode", + "label": "导入模式:每期替换" + }, + { + "at": "2026-10-02T05:09:23.588034+00:00", + "id": "outside_digits:月报!C1", + "label": "区域外有含数字的文字「2026年8月 销售月报」" + }, + { + "at": "2026-10-02T05:09:23.588034+00:00", + "id": "outside_digits:月报!C3", + "label": "区域外有含数字的文字「编制:财务部 日期:2026-09-05」" + } + ], + "created_at": "2026-10-02T05:09:23.591945+00:00", + "db_sha256": "4d549b6198ea5564ea0a47e6a10cba044304d68b200b99e0c0593876a7ff991a", + "edits": [], + "engine_ver": "recipe/1", + "file": { + "name": "c01_literal.xlsx", + "raw_sha256": "87cdb3af4e5429d618c32a4d6f1c98ca897c506e01cd00249befc171f556c1be", + "size": 5248 + }, + "format": "agentlab-import-manifest/1", + "import_id": "e8b4fe17027748b9ac9ba6b07cb8acb2", + "kind": "first", + "notes": { + "rendered": { + "月报": { + "columns": {}, + "comment": "粒度:每个地区。原表的合计在 月报_表内合计,不要与本表相加。本期原表附有说明文字(可能涉及口径),内容见证据面板" + }, + "月报_表内合计": { + "columns": {}, + "comment": "粒度:每个合计项。原表合计行的值,已按各列明细求和核对一致:不要与 月报 相加。本期原表附有说明文字(可能涉及口径),内容见证据面板" + } + }, + "templates": { + "月报": "粒度:每个{列:地区}。原表的合计在 {表:月报_表内合计},不要与本表相加。本期原表附有说明文字(可能涉及口径),内容见证据面板", + "月报_表内合计": "粒度:每个{列:合计项}。原表合计行的值,已按各列明细求和核对一致:不要与 {表:月报} 相加。本期原表附有说明文字(可能涉及口径),内容见证据面板" + } + }, + "parser_ver": "parsers/2", + "period": null, + "receipt": { + "axes": [], + "blank_rows_skipped": 0, + "block_order": { + "月报": [ + "列表1" + ] + }, + "canonicalized": [], + "context_checks": [], + "derived": [ + { + "base_table": "月报", + "base_value": "销量", + "cell": "月报!E9", + "end": null, + "end_col": null, + "formula": null, + "func": null, + "hidden_in_range": false, + "is_formula": false, + "keep_table": "月报_表内合计", + "key": {}, + "kind": "column_sum", + "label": "合计", + "label_raw": "合计", + "ref_problem": null, + "ref_rowids": null, + "rowid_first": 1, + "rowid_last": 3, + "segment": "列表1", + "sheet": "月报", + "start": null, + "start_col": null, + "value": 45 + }, + { + "base_table": "月报", + "base_value": "金额", + "cell": "月报!F9", + "end": null, + "end_col": null, + "formula": null, + "func": null, + "hidden_in_range": false, + "is_formula": false, + "keep_table": "月报_表内合计", + "key": {}, + "kind": "column_sum", + "label": "合计", + "label_raw": "合计", + "ref_problem": null, + "ref_rowids": null, + "rowid_first": 1, + "rowid_last": 3, + "segment": "列表1", + "sheet": "月报", + "start": null, + "start_col": null, + "value": 300 + } + ], + "derived_form": {}, + "expected_rows": { + "月报": 3, + "月报_表内合计": 1 + }, + "formula_cells_accepted": 0, + "full_calc_on_load": false, + "hidden": {}, + "ignored_columns": {}, + "labels": [], + "ledger": [ + { + "nonempty_read": 24, + "nonempty_scan": 24, + "roles": { + "col_header": 4, + "outside_text": 5, + "total_label": 1, + "total_value": 2, + "value": 12 + }, + "sheet": "月报", + "unclaimed": 0 + } + ], + "lineage": { + "月报": { + "产品": [ + [ + 1, + "月报", + "D6", + 3, + "down" + ] + ], + "地区": [ + [ + 1, + "月报", + "C6", + 3, + "down" + ] + ], + "金额": [ + [ + 1, + "月报", + "F6", + 3, + "down" + ] + ], + "销量": [ + [ + 1, + "月报", + "E6", + 3, + "down" + ] + ] + }, + "月报_表内合计": { + "金额": [ + [ + 1, + "月报", + "F9", + 1, + "right" + ] + ], + "销量": [ + [ + 1, + "月报", + "E9", + 1, + "right" + ] + ] + } + }, + "ok": true, + "outside_text": [ + { + "cell": "月报!C1", + "hidden": false, + "kind": "text_digits", + "period_source": false, + "sheet": "月报", + "text": "2026年8月 销售月报" + }, + { + "cell": "月报!C2", + "hidden": false, + "kind": "text", + "period_source": false, + "sheet": "月报", + "text": "单位:万元" + }, + { + "cell": "月报!C3", + "hidden": false, + "kind": "text_digits", + "period_source": false, + "sheet": "月报", + "text": "编制:财务部 日期:2026-09-05" + }, + { + "cell": "月报!C11", + "hidden": false, + "kind": "text", + "period_source": false, + "sheet": "月报", + "text": "注:数据来源于业务系统,金额含税。" + }, + { + "cell": "月报!C12", + "hidden": false, + "kind": "text", + "period_source": false, + "sheet": "月报", + "text": "制表人:经办甲" + } + ], + "partial": false, + "period": null, + "placeholders": {}, + "regions": [ + { + "block": null, + "ref": "C1:C3", + "role": "outside_text", + "sheet": "月报" + }, + { + "block": "列表1", + "ref": "C5:F5", + "role": "col_header", + "sheet": "月报" + }, + { + "block": "列表1", + "ref": "C6:F8", + "role": "value", + "sheet": "月报" + }, + { + "block": "列表1", + "ref": "C9", + "role": "total_label", + "sheet": "月报" + }, + { + "block": "列表1", + "ref": "E9:F9", + "role": "total_value", + "sheet": "月报" + }, + { + "block": null, + "ref": "C11:C12", + "role": "outside_text", + "sheet": "月报" + } + ], + "rows_excluded": [], + "sheets": { + "matched": { + "s1": "月报" + }, + "other_visible": [], + "renamed": {}, + "skipped_hidden": [] + }, + "table_hashes": { + "月报": "aa71f9e8e0067b7dd2fad6be255cdcf018809129528b1140eb3990ca546be944", + "月报_表内合计": "8927e0627bd7e470a33413adaa7c7dd4ea6e0e2307263e4a3857454d4e92b33c" + }, + "tables": [ + { + "columns": [ + { + "header": "地区", + "name": "地区", + "role": "text", + "type": "TEXT", + "unit": null + }, + { + "header": "产品", + "name": "产品", + "role": "text", + "type": "TEXT", + "unit": null + }, + { + "header": "销量", + "name": "销量", + "role": "measure", + "type": "INTEGER", + "unit": null + }, + { + "header": "金额", + "name": "金额", + "role": "measure", + "type": "INTEGER", + "unit": null + } + ], + "grain": [ + "地区" + ], + "kind": "data", + "name": "月报", + "rows": 3, + "sheet": "月报", + "sources": [ + "s1/列表1" + ] + }, + { + "columns": [ + { + "header": null, + "name": "合计项", + "role": "dim", + "type": "TEXT", + "unit": null + }, + { + "header": "销量", + "name": "销量", + "role": "value", + "type": "INTEGER", + "unit": null + }, + { + "header": "金额", + "name": "金额", + "role": "value", + "type": "INTEGER", + "unit": null + } + ], + "grain": [ + "合计项" + ], + "kind": "reported_total", + "name": "月报_表内合计", + "rows": 1, + "sheet": "月报", + "sources": [ + "s1/列表1" + ] + } + ], + "timings": { + "extract": 0.0001, + "match": 0.0004, + "read_locate": 0.0015, + "receipt": 0.0, + "total": 0.0024, + "write": 0.0005 + } + }, + "recipe": { + "canonical": { + "recipe_format": "agentlab-recipe/2", + "sheets": [ + { + "blocks": [ + { + "columns": [ + { + "header": "地区", + "name": "地区", + "type": "TEXT" + }, + { + "header": "产品", + "name": "产品", + "type": "TEXT" + }, + { + "header": "销量", + "name": "销量", + "type": "INTEGER" + }, + { + "header": "金额", + "name": "金额", + "type": "INTEGER" + } + ], + "id": "列表1", + "layout": "list", + "rows": { + "total_row": { + "keep_as": "月报_表内合计", + "label_column": "地区", + "pick": "合计" + } + }, + "table": "月报" + } + ], + "id": "s1", + "match": { + "name": "月报" + } + } + ], + "tables": [ + { + "grain": [ + "地区" + ], + "name": "月报" + }, + { + "grain": [ + "合计项" + ], + "kind": "reported_total", + "name": "月报_表内合计" + } + ] + }, + "id": "b6107c4effe64f8f96e9f86f4966ee33", + "origin": "rules", + "sha256": "7b04cb1fbf11bec0d4e06782cd486b02dfc8a63b8c1dcaf04be45f7966b53fc9" + }, + "seq": 1, + "signed_by": { + "name": null, + "verified": false + }, + "snapshot_id": "4f5487e6d54b9d7d634561f837654c8d218ab362dceaf6d25705e20aadf6d3d2", + "source_id": "66fd11acbc5f4e1d92feebbc2389a0e8", + "staging_id": "8469835650424e4d9bb71d465d88b209", + "table_hashes": { + "月报": "aa71f9e8e0067b7dd2fad6be255cdcf018809129528b1140eb3990ca546be944", + "月报_表内合计": "8927e0627bd7e470a33413adaa7c7dd4ea6e0e2307263e4a3857454d4e92b33c" + }, + "trial_db_sha256": "4d549b6198ea5564ea0a47e6a10cba044304d68b200b99e0c0593876a7ff991a" + } + } + } +} diff --git a/frontend/src/store/evidence.ts b/frontend/src/store/evidence.ts index d34ce56e..48b78a32 100644 --- a/frontend/src/store/evidence.ts +++ b/frontend/src/store/evidence.ts @@ -3,7 +3,8 @@ import { create } from 'zustand' import { ApiError, api } from '../api/client' import { graphVerdicts, judgedVerdicts, sqlOf } from '../lib/evidence' import type { - EvidenceDocData, EvidenceGraph, EvidenceJudgeResult, EvidenceSegmentDetail, EvidenceStats, EvidenceVerdict, + EvidenceDocData, EvidenceGraph, EvidenceJudgeResult, EvidenceProvenance, EvidenceSegmentDetail, EvidenceStats, + EvidenceVerdict, } from '../types' /** @@ -20,6 +21,11 @@ import type { * 四期:封存之后按需追加的结论句判定按 `runId@报告` 记在 judged 里(按需裁判接口、片段接口叠上的 * 判定都记进来),正文的句末徽标、横幅的结论句计数据此盖过文档里的「未裁判 · 按需」。封存的文档本身 * 一个字不改。同一句正在判的不重复发;判过的(判定不是未裁判)不再给按钮,也就不会再发 + * + * Excel 导入期 4:推断的来源按片段的缓存键记在 provenance 里,但**只在面板开着期间有效**。回答里有当前状态 + * (原件已清除、接受已作废)和取决于数据文件现状的结论(被修改过、已回收),在版本页清除、作废、回滚之后, + * 关掉面板再打开就要看到新结论。所以面板挂载时 holdProvenance,最后一个面板关掉(或换了运行)时清掉这次 + * 运行的槽;同一次打开里来回点同一片段只取一次 */ export interface Slot { @@ -69,6 +75,14 @@ interface EvidenceState { asks: Record> /** 运行类别(formal / exploratory):没开裁判的文档靠它认探索运行,按需取一次 */ runClasses: Record> + /** 推断的来源(Excel 导入期 4),键同 segmentKey。只在面板开着期间有效,见 holdProvenance */ + provenance: Record> + loadProvenance: (runId: string, segmentId: string, report?: string) => Promise + /** + * 证据面板挂载时调用,返回释放函数(面板卸载、换了运行时调)。这次运行最后一个面板释放后,清掉它的推断来源槽: + * 关掉面板再打开会重新取 + */ + holdProvenance: (runId: string) => () => void /** 记下几句封存后追加的判定(片段接口叠上的、证据图带来的) */ noteVerdicts: (runId: string, report: string | undefined, verdicts: Record) => void /** 请模型判断这几句(探索运行)。判定记进 judged;答复(含触顶、出错)记进 asks */ @@ -101,12 +115,17 @@ export class NotADocError extends Error { } } +/** + * 每次运行开着几个证据面板。放在模块里不放进 store:它只决定什么时候清缓存,界面不读它,变了也不该重渲染 + */ +const provenanceHolds = new Map() + export const useEvidence = create((set, get) => { /** * 取一次、记进 table[key];正在取或已经取到的不再发。取的途中这一格被清掉或换了(dropLatest 之后 * 又起了一次),回来的结果作废:不能让早先发出的请求把后来那份盖掉 */ - async function fill( + async function fill( table: K, key: string, fetcher: () => Promise>, force = false, ) { const cur = get()[table][key] @@ -190,6 +209,33 @@ export const useEvidence = create((set, get) => { mark({ status: 'error', error }) } }, + provenance: {}, + loadProvenance: (runId, segmentId, report) => + fill('provenance', segmentKey(runId, segmentId, report), () => api.evidence.provenance(runId, segmentId, { report })), + holdProvenance: (runId) => { + provenanceHolds.set(runId, (provenanceHolds.get(runId) ?? 0) + 1) + let released = false + return () => { + if (released) return + released = true + const left = (provenanceHolds.get(runId) ?? 1) - 1 + if (left > 0) { + provenanceHolds.set(runId, left) + return + } + provenanceHolds.delete(runId) + // 推到这一轮之后再清:开发期 StrictMode 把挂载的 effect 先清理、紧接着再跑一遍(又 hold 回来), + // 同一次提交里一个面板关、另一个面板开也是这样——这两种都不算「面板关了」 + queueMicrotask(() => { + if (provenanceHolds.has(runId)) return + const prefix = `${runId}:` + const cur = get().provenance + if (!Object.keys(cur).some((k) => k.startsWith(prefix))) return + // 正在取的也清掉:fill 只在槽还是它自己放的那一份时才落结果,晚到的回包作废 + set({ provenance: Object.fromEntries(Object.entries(cur).filter(([k]) => !k.startsWith(prefix))) }) + }) + } + }, loadRunClass: (runId) => { const cur = get().runClasses[runId] if (cur && cur.status !== 'error') return Promise.resolve() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 602e7c70..eff735f7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -550,6 +550,11 @@ export interface OutsideText { text: string kind: 'text' | 'text_digits' | (string & {}) period_source?: boolean + /** + * 期 4:所在行或列是否被隐藏(执行器写入)。true 在隐藏行列里;false 可见;null 或没有这个键:导入时没有记录 + * (期 4 之前的清单)。裁判摘录只送 false 的项,界面照常显示全部区域外文字 + */ + hidden?: boolean | null } /** 试运行回执(Extraction 去掉问题和核对之后给界面看的部分) */ @@ -2117,6 +2122,11 @@ export interface EvidenceStep extends Omit { note?: string /** 数据源改名或删掉了、按查询当时记下的遮罩处理时的那句说明。行照样有 */ mask_note?: string + /** + * 期 4:可以请求推断来源(GET …/segments/{sid}/provenance)。只有带文档标记的报告、单元格片段的查询步骤、 + * 查询快照来自上传的表格时才有,且恒为 true;其余一个键都不加。前端只在 seg.cite?.kind === 'cell' 且它为 true 时请求 + */ + provenance?: boolean // ---- entity:表或字段(三期)---- /** entity:table / column */ kind?: string @@ -2237,3 +2247,227 @@ export interface EvidenceSegmentDetail { /** 可疑实体:最接近的已知名字(最多 3 个),给「是不是想写…」 */ closest?: (string | { alias?: string; name?: string; table?: string; kind?: string })[] } + +// ------------------------------------------------------------------------- +// 期 4:推断的来源(GET /runs/{id}/evidence/segments/{sid}/provenance?report=…) +// 契约照 backend/app/data/provenance_types.py(asdict 之后的 JSON,P4-SPEC 2.8.1)。这是期 4 新加的接口, +// 答复里每个键都在(值可以是 null),所以这里不写成可选;老服务端没有这个接口时回 404 且没有 code,三节都不画。 +// ------------------------------------------------------------------------- + +/** inferred 给出格子;table_only 只给表级来历(version 非空);none 不画,或只画一句原因 / 红色提示 */ +export type ProvenanceStatus = 'inferred' | 'table_only' | 'none' + +/** 不下钻的原因(provenance_types.ReasonCode)。界面不按它拼字,原样显示服务端给的 reason.text */ +export type ProvenanceReasonCode = + | 'legacy_doc' | 'not_cell' | 'not_sealed' | 'not_upload' | 'simple_upload' | 'manifest_unreadable' + | 'chain_mismatch' | 'expression' | 'alias' | 'multi_table' | 'unparsed' | 'no_pk' | 'pk_missing' | 'masked' + | 'null_value' | 'null_pk' | 'snapshot_gone' | 'db_tampered' | 'recheck_missing' | 'recheck_multiple' + | 'recheck_mismatch' | 'no_lineage' + +/** 标红的提示:出现时 reason.code 与它相同,界面只画提示 */ +export type ProvenanceAlertCode = 'db_tampered' | 'chain_mismatch' | 'manifest_unreadable' + +/** 附带的格:日期表头格、行标签、分段标题、列表头、合计标签 */ +export type ProvenanceFromRole = 'axis_header' | 'row_label' | 'section_title' | 'col_header' | 'total_label' + +/** 本期结论(照搬清单里 CheckResult.status) */ +export type ProvenancePartStatus = 'passed' | 'mismatch' | 'unverifiable' | 'info' +/** 这一行的关系核对结果(只给关系核对) */ +export type ProvenanceRowStatus = 'passed' | 'mismatch' | 'unverifiable' +/** 这一格的合计核对结果(只给 K、G、T) */ +export type ProvenanceCellStatus = 'ok' | 'unverifiable' | 'unknown' | 'not_formula' + +export interface ProvenanceReportRef { + node_id: string + doc_artifact: string +} + +/** 片段本身是单元格引用就给(与 status 无关),不是时为 null */ +export interface ProvenanceCellRef { + /** 目录里的查询编号(Q3) */ + alias: string + row: number + column: string + /** 查询快照的工件 id */ + artifact: string | null +} + +export interface ProvenanceReason { + code: ProvenanceReasonCode + /** 细分,给测试和日志用,界面不显示 */ + detail: string + /** 界面原样显示 */ + text: string +} + +export interface ProvenanceAlert { + code: ProvenanceAlertCode + text: string +} + +export interface ProvenanceTableRef { + name: string + kind: 'data' | 'reported_total' +} + +export interface ProvenancePeriod { + start: string + end: string + source: 'cells' | 'human' + /** 解析出统计期的格子,带工作表名(「客流汇总!B2」) */ + cells: string[] + /** 人工录入时的署名(未认证) */ + signed_by: string | null +} + +/** 一条接受理由(导入清单里的,内容寻址,是当时的事实) */ +export interface ProvenanceAcceptance { + check_id: string + title: string | null + kind: 'override' | 'waiver' + reason: string + signed_by: string | null + at: string | null +} + +/** 导入记录上的当前状态(清除原件、作废接受) */ +export interface ProvenanceStateNote { + at: string | null + signed_by: string | null + reason: string | null +} + +/** 数据版本里的一期(按统计期升序)。raw_state、purged、revoked 是当前状态,界面标「当前状态」 */ +export interface ProvenancePart { + import_id: string + seq: number + /** 导入清单的工件 id:「查看导入清单」按它经 /api/artifacts/{id} 取 */ + manifest: string + /** 没有统计期时 null(「统计期未记录」) */ + period: ProvenancePeriod | null + file_name: string | null + raw_sha256: string | null + /** 区域(「客流汇总!B4:AG30」,多张表用「、」连);取不到为 null */ + region: string | null + /** 排除的行数之和;期 3 之前的回执没有记录时为 null */ + excluded_rows: number | null + recipe_sha256: string | null + /** 配方第几版(当前状态);取不到为 null */ + recipe_seq: number | null + /** 导入时间(清单的 created_at) */ + committed_at: string | null + /** 署名(未认证) */ + signed_by: string | null + acceptances: ProvenanceAcceptance[] + raw_state: 'kept' | 'purged' | 'absent' | null + purged: ProvenanceStateNote | null + revoked: ProvenanceStateNote | null + /** 这一格所在的一期:inferred 时恰好一项为 true */ + has_row: boolean +} + +/** 数据版本(表级来历),来自封存链上的清单,不加「推断」标注 */ +export interface ProvenanceVersion { + source: string + snapshot_id: string + mode: 'replace' | 'accumulate' + union: boolean + tables: ProvenanceTableRef[] + /** false:数据源设有遮罩,不出「查看导入清单」 */ + manifest_view: boolean + parts: ProvenancePart[] +} + +export interface ProvenanceFromCell { + role: ProvenanceFromRole + /** 这一格给出的是哪一列的值;宽表指标列的行标签(指标名取自哪一格)为 null */ + column: string | null + sheet: string + /** 不带工作表名的坐标(「G4」) */ + cell: string + /** 格子原文;拿不到为 null */ + text: string | null + /** 只给分段标题:配方中的定位文字 */ + locate_title: string | null +} + +export interface ProvenanceYear { + source: 'period' | 'human' + cells: string[] + signed_by: string | null + /** 这一块的日期表头有的是日期格、有的是文本,无法确定这一格属于哪一种 */ + mixed: boolean +} + +/** + * 这一行标签列(交叉表的维度、合计表的合计项)的原文与数据库里的规范写法不同时给出。被引用的是值列也给: + * 合计表那一格是「18-22 时合计」→「18-22时合计」,全角标签那一期的值格是「8-9」→「8-9」 + */ +export interface ProvenanceCanonical { + raw: string + canonical: string +} + +export interface ProvenanceRecheck { + /** 占位符一律是 ?,按主键的顺序与 params 一一对应 */ + sql: string + params: unknown[] + ok: boolean +} + +/** 推断出的格子(只在 inferred 时有) */ +export interface ProvenanceCellSource { + table: string + column: string + column_role: 'axis' | 'dim' | 'derive' | 'const' | 'measure' | 'value' | 'text' | (string & {}) + kind: 'data' | 'reported_total' + pk: Record + /** 快照库的 rowid(累积时是合并后的行号) */ + rowid: number + part_seq: number + part_rowid: number + sheet: string + /** 不带工作表名的坐标(「G5」) */ + cell: string + header: string | null + unit: string | null + from: ProvenanceFromCell[] + year: ProvenanceYear | null + canonical: ProvenanceCanonical | null + raw_purged: boolean + /** 列表这一块按合并单元格的左上格填充 */ + merged_fill: boolean + recheck: ProvenanceRecheck +} + +/** 相关核对的一条(只在 inferred 时有) */ +export interface ProvenanceCheck { + id: string + kind: string + title: string + part_status: ProvenancePartStatus + row_status: ProvenanceRowStatus | null + cell_status: ProvenanceCellStatus | null + acceptance: ProvenanceAcceptance | null + /** K 无法核对时的原因摘要(可能带数字,系统呈现) */ + detail: string | null +} + +/** GET /runs/{id}/evidence/segments/{sid}/provenance:推断的来源 */ +export interface EvidenceProvenance { + schema: 'agentlab.provenance/1' | (string & {}) + report: ProvenanceReportRef + segment: string + cell: ProvenanceCellRef | null + status: ProvenanceStatus + /** status 不是 inferred 时必有 */ + reason: ProvenanceReason | null + /** 标红;有它时不再画 reason.text */ + alert: ProvenanceAlert | null + /** 运行已封存且封存核对通过;false 时数据版本一节加一句未封存 */ + sealed: boolean + /** status 为 none 时为 null */ + version: ProvenanceVersion | null + cell_source: ProvenanceCellSource | null + checks: ProvenanceCheck[] +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index e7f22937..874c893c 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -85,7 +85,7 @@ const ORDER = [ // 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测 const ESTIMATE = { stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, - evidence: 85, versions: 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, + evidence: 91, versions: 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 3425f526..6e40bca0 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -37,6 +37,11 @@ const fxj = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence- // 文档由 compose_doc 真跑、证据链由 api/evidence.py 的 _chain 真跑;判定和表名实体步骤的 fields 照前后端接口约定写 const fxv = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-verdict.json`, 'utf8')) const JUDGE_SETS = [fxj.formal, fxj.explore, fxj.plain, fxv.formal, fxv.explore] +// Excel 导入期 4:推断的来源。文档由 compose_doc 真跑(带文档标记),片段答复由 _chain 真跑,推断来源的答复按契约 +// (backend/app/data/provenance_types.py)拼;cases 写明每个场景是哪个片段、片段文字、覆盖 P4-SPEC 4.4 的哪几条 +const fxp = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-provenance.json`, 'utf8')) +/** 推断来源夹具里各期导入清单的工件 id:夹具只收了两份清单,其余的按「工件不存在」答,不落到真服务端 */ +const FXP_MANIFESTS = new Set(Object.values(fxp.provenance).flatMap((a) => a.version?.parts ?? []).map((p) => p.manifest)) /** 夹具里某段文字是哪个片段:检查按文字认片段,重新生成夹具时编号变了也不用改 */ const segOf = (doc, text) => doc.blocks.flatMap((b) => b.units.flatMap((u) => u.segments)).find((s) => s.text === text)?.id @@ -75,12 +80,18 @@ function judgeReply(set, body) { /** * 伪造证据相关的 GET;片段请求记账(缓存:同一个片段点两次只取一次)。四期:按需裁判的 POST 也在这里认 * (记进 hits 的 judge:<运行>:<句子>),别的非 GET 一律拦掉。judge 给了就用它答(改坏验证、触顶、出错); - * segPatch 给了就改片段接口的答复(比如 on_demand 说运行还没封存) + * segPatch 给了就改片段接口的答复(比如 on_demand 说运行还没封存)。 + * + * Excel 导入期 4:推断来源接口(…/segments//provenance)在片段正则之前单独认,不分运行、不分夹具,一律记成 + * prov:<运行>:<片段>——片段正则以 $ 结尾认不出它,落到 r.continue() 会得到一个没有机读码的 404,界面按「老服务端」 + * 三节都不画,只看 DOM 的断言不论前端发没发请求都会过。推断来源夹具的运行按 provenance[<片段>] 作答,其余一律回 + * 可识别的哨兵(418,harness_unexpected_provenance),界面会画出「未能获取推断的来源」。prov 给了就由它改答复 + * (prov(运行, 片段, 默认答复) → {status?, json} 或它的 Promise:延迟、出错、老服务端) */ -async function routed(page, { judge = null, segPatch = null } = {}) { +async function routed(page, { judge = null, segPatch = null, prov = null } = {}) { const hits = [] const judged = new Set() - await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => { + await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (r) => { const req = r.request() const url = new URL(req.url()) const jset = JUDGE_SETS.find((x) => url.pathname.includes(`/runs/${x.run_id}/`) || url.pathname.endsWith(`/runs/${x.run_id}`)) @@ -92,6 +103,33 @@ async function routed(page, { judge = null, segPatch = null } = {}) { return r.fulfill({ status: reply.status ?? 200, json: reply.json }) } if (req.method() !== 'GET') return r.abort() + const pm = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)\/provenance$/) + if (pm) { + const [run, sid] = [decodeURIComponent(pm[1]), decodeURIComponent(pm[2])] + hits.push(`prov:${run}:${sid}`) + const own = run === fxp.run_id && fxp.provenance[sid] + let reply = own ? { json: structuredClone(own) } : fxp.errors.unexpected + if (prov) reply = await prov(run, sid, reply) + return r.fulfill({ status: reply.status ?? 200, json: reply.json }) + } + if (url.pathname.includes(`/runs/${fxp.run_id}/`)) { + const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/) + if (seg) { + const id = decodeURIComponent(seg[1]) + hits.push(`p:${id}`) + const body = fxp.segments[id] + return body ? r.fulfill({ json: structuredClone(body) }) + : r.fulfill({ status: 404, json: { detail: '报告中没有这个片段', code: 'evidence_segment_not_found' } }) + } + if (/\/evidence$/.test(url.pathname)) return r.fulfill({ json: fxp.graph }) + } + // 「查看导入清单」按清单的工件 id 取(内容寻址) + const art = url.pathname.match(/^\/api\/artifacts\/([^/]+)$/)?.[1] + if (art && fxp.artifacts[art]) { + hits.push(`artifact:P:${art}`) + return r.fulfill({ json: fxp.artifacts[art] }) + } + if (art && FXP_MANIFESTS.has(art)) return r.fulfill({ status: 404, json: { detail: '工件不存在' } }) // 四期的报告工件:问数据页的答案(Output → EvidenceField)按工件 id 取文档 const jart = JUDGE_SETS.find((x) => url.pathname === `/api/artifacts/${x.doc_artifact}`) if (jart) return r.fulfill({ json: { id: jart.doc_artifact, content: jart.doc } }) @@ -144,14 +182,14 @@ async function routed(page, { judge = null, segPatch = null } = {}) { return hits } -async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null, judge = null, segPatch = null } = {}) { +async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null, judge = null, segPatch = null, prov = null } = {}) { const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) }) const page = await ctx.newPage() // 找不到元素时 8 秒就报,不等默认的 30 秒:改坏验证时一节里十几处都找不到 page.setDefaultTimeout(8000) const errors = [] page.on('pageerror', (e) => errors.push(e.message)) - const hits = await routed(page, { judge, segPatch }) + const hits = await routed(page, { judge, segPatch, prov }) // 预览页直接 import 第二期夹具(vite 把 JSON 当模块给):要一份改过的文档时把那个模块换掉 if (patchQuery) { await page.route((u) => new URL(u).pathname.endsWith('/run/__tests__/evidence-query.json'), (r) => @@ -742,6 +780,12 @@ await section('query', '查询步骤:SQL 带复制、被引用的行和格高 return !!note && !!seal && !!(seal.compareDocumentPosition(note) & Node.DOCUMENT_POSITION_FOLLOWING) })) check('查询快照复验通过时不多说一句', await panel(page).locator('[data-ev-integrity]').count() === 0) + // Excel 导入期 4 之前的报告:查询步骤不带推断来源的提示,前端一个请求都不多发、三节都不画(P4-SPEC 4.4 第 14 条)。 + // 断言请求记账,不只看 DOM:老服务端那种 404 也会让三节不画 + check('期 4 之前的报告(查询步骤没有提示):不请求推断的来源,面板里没有数据版本、推断的来源', + !harness.hits.some((h) => h.startsWith('prov:')) + && await panel(page).locator('[data-ev-provenance], [data-ev-prov-version], [data-ev-prov-checks]').count() === 0, + harness.hits.filter((h) => h.startsWith('prov:')).join(',')) const aria = await page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).getAttribute('aria-label') check('单元格的 aria-label 写明查询、第几行、哪一列', aria === '1,288,有出处:查询 Q3 · 第 6 行 · amount', aria) @@ -2494,6 +2538,419 @@ await section('jr-banner', '裁判拆档:出具横幅的结论句计数带上 } }) +// --------------------------------------------------------------------------- +// Excel 导入期 4:推断的来源(夹具 evidence-provenance.json,P4-SPEC 4.4) +// --------------------------------------------------------------------------- +const PV = '#evidence-provenance' +const PVN = '#evidence-provenance-narrow' +/** 场景的片段 id:按夹具 cases 里记的片段文字认,重新生成夹具后编号挪了也不用改 */ +const pseg = (name) => segOf(fxp.doc, fxp.cases[name].text) +/** 这个场景的片段请求过几次推断来源 */ +const provHits = (hits, name) => hits.filter((h) => h === `prov:${fxp.run_id}:${pseg(name)}`).length +/** 点开一个场景,等推断的来源那一节定下来(不再是「正在推断」);不该有这一节的场景改等查询步骤、指标步骤 */ +async function openP(p, name, { box = PV, wait = '[data-ev-provenance]:not([data-ev-provenance="loading"])' } = {}) { + await p.locator(`${box} [data-seg="${pseg(name)}"]`).click() + await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {}) + await p.waitForTimeout(120) +} +const pv = (p, sel = '') => p.locator(`[data-evidence-panel] ${sel}`) +/** 元素的文字(空白合成一个空格);没有这个元素时是空串 */ +const textOf = async (loc) => ((await loc.count()) ? (await loc.first().innerText()).replace(/\s+/g, ' ').trim() : '') +const fromOf = (p) => p.locator('[data-evidence-panel] [data-ev-provenance] [data-ev-prov-from] li').allInnerTexts() +const partsOf = (p) => p.locator('[data-evidence-panel] [data-ev-prov-version] [data-ev-prov-part]') + .evaluateAll((els) => els.map((e) => `${e.getAttribute('data-ev-prov-part')}${e.hasAttribute('data-ev-prov-has-row') ? '*' : ''}`)) +/** 只给表级来历的场景:table_only、一句原因、没有格子和相关核对、数据版本照样有 */ +async function tableOnly(p, name, want, what) { + await openP(p, name) + const reason = await textOf(pv(p, '[data-ev-provenance="table_only"] [data-ev-prov-reason]')) + check(what, (typeof want === 'string' ? reason === want : want.test(reason)) + && await pv(p, '[data-ev-prov-cell], [data-ev-prov-checks], [data-ev-prov-alert]').count() === 0 + && await pv(p, '[data-ev-prov-version]').count() === 1, reason) +} +const noSections = async (p) => (await pv(p, '[data-ev-provenance], [data-ev-prov-version], [data-ev-prov-checks]').count()) === 0 +const activeHas = (p, attr) => p.evaluate((a) => document.activeElement?.hasAttribute(a) ?? false, attr) + +await section('provenance', '期 4:推断的来源——数据版本、格子与逐条、相关核对,只在上传表格的单元格上请求', async () => { + const ids = Object.fromEntries(Object.keys(fxp.cases).map((k) => [k, pseg(k)])) + check('夹具的场景都能按片段文字认出来(和 cases 记的编号一致)', Object.entries(fxp.cases).every(([k, c]) => ids[k] === c.segment), + JSON.stringify(ids)) + // 前面各节点开的都是期 4 之前的报告、没有提示的查询步骤:一个推断来源请求都不该有(4.4 第 14 条的全局版) + const stray = harness.hits.filter((h) => h.startsWith('prov:')) + check('其他夹具(期 4 之前的报告)点开过的片段一个推断来源请求都没发', stray.length === 0, stray.join(',')) + + // ---- 1. 宽表的一格:格子、逐条、相关核对、位置、标题 + await openP(page, 'wide') + const sec = pv(page, '[data-ev-provenance="inferred"]') + check('满足条件:有「推断的来源」一节(inferred)', await sec.count() === 1, String(await pv(page, '[data-ev-provenance]').getAttribute('data-ev-provenance').catch(() => null))) + check('……标题「推断的来源」,旁边标「推断,不属于已封存的证据」', await textOf(sec.locator('h4')) === '推断的来源' + && await textOf(sec.locator('[data-ev-prov-badge]')) === '推断,不属于已封存的证据') + const cell = await textOf(sec.locator('[data-ev-prov-cell]')) + check('……第一行醒目地写格子:工作表「客流汇总」G5', cell === '工作表「客流汇总」G5', cell) + const items = await fromOf(page) + check('……逐条:日期取自表头格 G4 → 年份取自统计期 → 行标签 B5(指标名)', items.join('|') + === '日期取自表头格 G4|年份取自统计期(客流汇总!B2)|行标签 B5「全日客流(人次)」', items.join('|')) + check('相关核对:R1「这一行成立」', (await textOf(pv(page, '[data-ev-prov-checks] [data-ev-prov-check="R1"]'))).includes('这一行成立')) + // 规格 4.1 的散文写「口径说明:…」;R2 的标题本身是「口径不同:…」,再套一层冒号读不顺,所以和别的核对同一种 + // 写法「R2 标题 — 口径说明」(口径说明是 4.2 partStatus.info 的原文)。改写法要连同 CheckRow 一起改 + check('……口径不同(R2)只写口径说明,不判行', (await textOf(pv(page, '[data-ev-prov-check="R2"]'))).endsWith('— 口径说明') + && await pv(page, '[data-ev-prov-check="R2"][data-ev-prov-row]').count() === 0) + const order = await page.evaluate(() => { + const p = document.querySelector('[data-evidence-panel]') + const q = p?.querySelector('[data-ev-query]') + const seq = ['[data-ev-prov-version]', '[data-ev-provenance]', '[data-ev-prov-checks]', '[data-ev-judge]', '[data-ev-seal]'] + .map((s) => p?.querySelector(s)).filter(Boolean) + if (!q || seq.length < 4) return `缺:${seq.length}` + let prev = q + for (const el of seq) { + if (!(prev.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) return `顺序不对:${el.outerHTML.slice(0, 60)}` + prev = el + } + return 'ok' + }) + check('三节在查询步骤之后、结论句裁判和封存之前:数据版本 → 推断的来源 → 相关核对', order === 'ok', order) + const heads = await Promise.all(['数据版本', '推断的来源', '相关核对'] + .map((name) => panel(page).getByRole('heading', { name, exact: true }).evaluateAll((els) => els.map((e) => e.tagName).join(',')))) + check('三节的标题都是真正的标题(getByRole heading,h4)', heads.every((t) => t === 'H4'), heads.join(' | ')) + check('推断的来源用虚线左边框,和其余几节区分', await sec.evaluate((el) => getComputedStyle(el).borderLeftStyle) === 'dashed') + check('相关核对也挂「推断」标签;数据版本来自封存链上的清单,不挂', await pv(page, '[data-ev-prov-checks] [data-ev-prov-badge]').count() === 1 + && await pv(page, '[data-ev-prov-version] [data-ev-prov-badge]').count() === 0) + check('封存那一行照旧只按片段接口的链算:「已封存 · 核对一致」', (await textOf(pv(page, '[data-ev-seal]'))).includes('已封存 · 核对一致')) + const integrityBase = await pv(page, '[data-ev-integrity]').count() + + const ver = pv(page, '[data-ev-prov-version]') + const vtext = await textOf(ver) + const p1 = fxp.provenance[ids.wide].version.parts[0] + check('数据版本:首行「数据源「flow_demo」· 每期替换」(每期替换不写期数)', await textOf(ver.locator('[data-ev-prov-head]')) === '数据源「flow_demo」· 每期替换', + vtext) + check('……每期一行:第几次导入 · 统计期;文件名、sha256 前 12 位、区域、原件状态、配方第几版', + vtext.includes('第 1 次导入 · 统计期 2026-08-01 至 2026-08-31') && vtext.includes(`文件「${p1.file_name}」· sha256 ${p1.raw_sha256.slice(0, 12)}`) + && !vtext.includes(p1.raw_sha256.slice(0, 13)) && vtext.includes('区域 客流汇总!B4:AG30') && vtext.includes('原件已保存') + && vtext.includes('配方第 1 版') && vtext.includes('涉及的表:日客流'), vtext) + check('……这一格所在的一期有标记;已封存的运行不写「尚未封存」', (await partsOf(page)).join(',') === '1*' + && vtext.includes('这一格所在的一期') && await ver.locator('[data-ev-prov-unsealed]').count() === 0) + + const det = sec.locator('[data-ev-prov-details]') + check('末尾一行小字「已按主键回查数据文件,值一致」,回查 SQL 收在「技术细节」里(默认收起)', + await textOf(sec.locator('[data-ev-prov-recheck]')) === '已按主键回查数据文件,值一致' + && await det.getAttribute('aria-expanded') === 'false' && await sec.locator('[data-ev-prov-tech]').isHidden() + && await det.getAttribute('aria-controls') === await sec.locator('[data-ev-prov-tech]').getAttribute('id')) + await det.focus() + await page.keyboard.press('Enter') + const tech = await textOf(sec.locator('[data-ev-prov-tech]')) + check('……「技术细节」是按钮,回车展开:回查 SQL 和参数,带复制', await det.getAttribute('aria-expanded') === 'true' + && await sec.locator('[data-ev-prov-tech]').isVisible() + && tech.includes('SELECT rowid, "全日客流" FROM "日客流" WHERE "日期" = ?') && tech.includes('2026-08-05') + && await sec.locator('[data-ev-prov-tech] button', { hasText: '复制 SQL' }).count() === 1, tech) + + // ---- 16、28:键盘打开导入清单,对话框是结构化的清单渲染,Esc 只关对话框 + await pv(page, '[data-ev-query] [data-ev-query-head]').focus() + let reached = false + for (let i = 0; i < 30 && !reached; i++) { + await page.keyboard.press('Tab') + reached = await activeHas(page, 'data-ev-prov-manifest') + } + check('键盘:从查询步骤 Tab 走得到「查看导入清单」', reached) + const arts = harness.hits.filter((h) => h.startsWith('artifact:P:')).length + await page.keyboard.press('Enter') + await page.waitForSelector('[role="dialog"] [data-manifest-block]', { timeout: 4000 }).catch(() => {}) + const dlg = page.locator('[role="dialog"]') + const dtext = await dlg.innerText().catch(() => '') + check('回车打开导入清单对话框,按清单的工件 id 经工件接口取', await dlg.count() === 1 + && harness.hits.filter((h) => h.startsWith('artifact:P:')).length === arts + 1 && harness.hits.includes(`artifact:P:${p1.manifest}`) + && dtext.includes('第 1 次导入的导入清单'), dtext.slice(0, 80)) + check('……正文是版本页的清单渲染(结构化区块),不是整份 JSON(展开的文字里没有键名 "acceptances")', + await dlg.locator('[data-manifest-block]').count() >= 5 && !dtext.includes('"acceptances"') && dtext.includes(p1.file_name) + && await dlg.locator('[data-manifest-raw]').evaluate((el) => !el.open).catch(() => false), String(await dlg.locator('[data-manifest-block]').count())) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.screenshot({ path: `${SHOTS}/evidence-p4-manifest-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + check('Esc 只关清单对话框:证据面板还开着,焦点回到「查看导入清单」', await dlg.count() === 0 && await panel(page).count() === 1 + && await activeHas(page, 'data-ev-prov-manifest')) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-inferred-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + + // ---- 18:缓存只在面板这次打开期间有效 + const c0 = provHits(harness.hits, 'wide') + await openP(page, 'wide') + await openP(page, 'long') + await openP(page, 'wide') + check('同一次打开里同一片段点两次,推断的来源只取一次', provHits(harness.hits, 'wide') === c0 + 1 && provHits(harness.hits, 'long') >= 1, + `${c0} → ${provHits(harness.hits, 'wide')}`) + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + check('……面板关掉了', await panel(page).count() === 0) + await openP(page, 'wide') + check('关闭面板再打开、再点同一片段:重新取(回答里有当前状态)', provHits(harness.hits, 'wide') === c0 + 2, String(provHits(harness.hits, 'wide'))) + + // ---- 2、3:长表的分段标题、全角标签的规范写法、合计表 + await openP(page, 'long') + const longItems = await fromOf(page) + check('长表一格:分段标题 B9(按配方中的标题「…」定位),不说它是格子原文;行标签 B16「13-14」', + longItems.includes('分段标题 B9(按配方中的标题「日间时段客流(人次)」定位)') && longItems.includes('行标签 B16「13-14」'), longItems.join('|')) + await openP(page, 'fullwidth') + const fw = await fromOf(page) + check('全角标签那一期的维度列:原文「8-9」,按规范写法存为「8-9」', fw.includes('原文「8-9」,按规范写法存为「8-9」'), fw.join('|')) + await openP(page, 'total') + const tItems = await fromOf(page) + check('合计表一格:合计标签 B28「18-22 时合计」(原文),口径「原表写明的合计:不要彼此相加…」', tItems.includes('合计标签 B28「18-22 时合计」') + && tItems.includes('原表写明的合计:不要彼此相加,也不要与明细相加') && tItems.includes('原文「18-22 时合计」,按规范写法存为「18-22时合计」'), + tItems.join('|')) + check('……相关核对里 K1「这一格一致」', (await textOf(pv(page, '[data-ev-prov-check="K1"]'))).includes('这一格一致')) + + // ---- 4:累积的两期 + await openP(page, 'union') + const un = fxp.provenance[ids.union] + check('按期累积:首行写「按期累积 · 共 2 期」,两期都列出,第 2 期标「这一格所在的一期」', + await textOf(pv(page, '[data-ev-prov-head]')) === `数据源「${un.version.source}」· 按期累积 · 共 2 期` + && (await partsOf(page)).join(',') === '1,2*', (await partsOf(page)).join(',')) + const ufrom = await textOf(pv(page, '[data-ev-prov-from-part]')) + check('……格子是第 2 期(9 月)文件的坐标', await textOf(pv(page, '[data-ev-prov-cell]')) === `工作表「${un.cell_source.sheet}」${un.cell_source.cell}` + && ufrom.includes('第 2 次导入') && ufrom.includes(un.version.parts[1].file_name), ufrom) + + // ---- 5–8:只给表级来历 + await tableOnly(page, 'alias', /改了名/, '改名:table_only,原因写「改了名」,没有格子,数据版本照样有') + await tableOnly(page, 'selfjoin', /同一张表多次/, '自连接:table_only,原因写「同一张表多次」') + await tableOnly(page, 'null', '这一格是空值,不推断来源', '空值:table_only,「这一格是空值,不推断来源」') + await tableOnly(page, 'aggregate', /计算、聚合/, '聚合:table_only,原因写「计算、聚合」') + check('……聚合的格子数据版本照样列出涉及的表', (await textOf(pv(page, '[data-ev-prov-tables]'))) === '涉及的表:日客流') + + // ---- 9、10、21:标红 + const failedColor = await tokenColor(page, 'color', 'var(--st-failed)') + const look = (code) => pv(page, `[data-ev-prov-alert="${code}"]`).evaluate((el) => { + const cs = getComputedStyle(el) + return { color: cs.color, border: cs.borderLeftColor, text: el.innerText } + }).catch(() => null) + await openP(page, 'tampered') + const tam = await look('db_tampered') + check('数据文件被改:标红(data-ev-prov-alert="db_tampered"),写「可能被修改过」,边框和字都是 --st-failed', + !!tam && tam.text.includes('可能被修改过') && tam.color === failedColor && tam.border === failedColor, JSON.stringify({ tam, failedColor })) + check('……不给格子、不写原因那一句(有 alert 时只画 alert),数据版本照样有', await pv(page, '[data-ev-prov-cell], [data-ev-prov-reason], [data-ev-prov-checks]').count() === 0 + && await pv(page, '[data-ev-prov-version]').count() === 1 && await pv(page, '[data-ev-provenance="table_only"]').count() === 1) + check('……标红用自己的属性:面板里 data-ev-integrity 的个数和没有 alert 时相同', await pv(page, '[data-ev-integrity]').count() === integrityBase) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-tampered-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + await openP(page, 'chain') + check('登记哈希与清单不符:table_only,标红 chain_mismatch,数据版本照样有,没有原因那一句', + await pv(page, '[data-ev-provenance="table_only"] [data-ev-prov-alert="chain_mismatch"]').count() === 1 + && await pv(page, '[data-ev-prov-version]').count() === 1 && await pv(page, '[data-ev-prov-reason]').count() === 0) + await openP(page, 'unreadable') + const unr = await look('manifest_unreadable') + check('清单无法读取:none 里只有红色的 manifest_unreadable,没有数据版本、没有原因那一句', + await pv(page, '[data-ev-provenance="none"] [data-ev-prov-alert="manifest_unreadable"]').count() === 1 && unr?.color === failedColor + && await pv(page, '[data-ev-prov-version], [data-ev-prov-reason], [data-ev-prov-cell], [data-ev-prov-checks]').count() === 0, JSON.stringify(unr)) + + // ---- 11、12:当前状态 + await openP(page, 'purged') + const pver = await textOf(pv(page, '[data-ev-prov-version]')) + const purgedLine = await textOf(pv(page, '[data-ev-prov-purged]')) + check('原件已清除:数据版本里写「原件已清除 · 清除时间 · 理由 · 署名(未认证)」,标「当前状态」', + /^原件已清除 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\) · 理由:合成理由:原件不再保留 · 署名(未认证):录入员甲/.test(purgedLine) + && await pv(page, '[data-ev-prov-purged] [data-ev-prov-current]').count() === 1, purgedLine) + check('……格子照样有,旁边注明「原件已清除:坐标来自导入时的记录,无法再对照原件」', await pv(page, '[data-ev-prov-cell]').count() === 1 + && await textOf(pv(page, '[data-ev-prov-raw]')) === '原件已清除:坐标来自导入时的记录,无法再对照原件') + await openP(page, 'revoked') + const rver = await textOf(pv(page, '[data-ev-prov-version]')) + check('作废接受:数据版本里写「这一期的接受已作废 · 作废时间 · 理由」(当前状态),接受理由照旧列出', + /^这一期的接受已作废 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\) · 理由:合成理由:接受依据有误 · 署名(未认证):未填写/ + .test(await textOf(pv(page, '[data-ev-prov-revoked]'))) + && await pv(page, '[data-ev-prov-revoked] [data-ev-prov-current]').count() === 1 + && rver.includes('已接受:「日客流」:全日客流 = 分区甲 + 分区乙 · 合成理由:分区合计口径调整 · 署名(未认证):未填写'), rver) + + // ---- 13:简单导入 + await openP(page, 'simple') + check('简单导入:none 里只有一句「这份表格按表头行直接导入…」,没有数据版本', + (await textOf(pv(page, '[data-ev-provenance="none"] [data-ev-prov-reason]'))).startsWith('这份表格按表头行直接导入') + && await pv(page, '[data-ev-prov-version], [data-ev-prov-alert], [data-ev-prov-checks]').count() === 0) + + // ---- 15、20:不该请求的两种 + check('夹具自检:手工源那一格的查询步骤没有提示,指标片段链上的查询步骤误带了提示', + fxp.segments[ids.manual].chain.every((st) => st.provenance !== true) + && fxp.segments[ids.metric].chain.some((st) => st.step === 'query' && st.provenance === true)) + await openP(page, 'manual', { wait: '[data-ev-query]' }) + await page.waitForTimeout(250) + check('手工源(查询步骤不带提示):不请求推断的来源,三节都不画', provHits(harness.hits, 'manual') === 0 && await noSections(page)) + await openP(page, 'metric', { wait: '[data-ev-metric]' }) + await page.waitForTimeout(250) + check('指标片段:链上的查询步骤带了提示也不请求(只认单元格引用)', provHits(harness.hits, 'metric') === 0 && await noSections(page) + && await pv(page, '[data-ev-query]').count() >= 1) + + // ---- 22、23、25、26:回收、遮罩、未封存、本期不成立并接受 + await tableOnly(page, 'gone', /已回收或不存在/, '数据文件已回收:table_only,原因写「已回收或不存在」,不标红') + await tableOnly(page, 'masked', '这一格涉及设置了遮罩的列,不推断来源', '遮罩:table_only,「这一格涉及设置了遮罩的列,不推断来源」') + check('……数据源设有遮罩:数据版本里没有「查看导入清单」,改写一句去向', await pv(page, '[data-ev-prov-manifest]').count() === 0 + && await textOf(pv(page, '[data-ev-prov-manifest-masked]')) === '数据源设置了遮罩,导入清单请到数据源卡片上的「版本」查看') + await openP(page, 'unsealed') + check('运行未封存:照常推断,数据版本一节写「本次运行尚未封存…」,不标红', await pv(page, '[data-ev-provenance="inferred"]').count() === 1 + && (await textOf(pv(page, '[data-ev-prov-version] [data-ev-prov-unsealed]'))).startsWith('本次运行尚未封存或封存核对未通过') + && await pv(page, '[data-ev-prov-alert]').count() === 0) + // 接受理由挂在本期结论上(接受针对的是这一期的核对结果,不是这一行),写法取 4.2 的 accepted 原文「已接受,理由:…」, + // 不是 4.1 散文里的「已接受:理由」;行级结果单独一行,本期结论另起一行——d26_other 要同时看得到「这一行成立」和 + // 「本期不成立 · 已接受」(4.4 第 26 条) + await openP(page, 'd26_bad') + const bad = await textOf(pv(page, '[data-ev-prov-check="R1"]')) + check('不成立的那一天:R1「这一行不成立」,接着写「本期不成立 · 已接受,理由:…」', bad.includes('这一行不成立') + && bad.includes('本期不成立 · 已接受,理由:合成理由:分区合计口径调整(署名(未认证):未填写)'), bad) + await openP(page, 'd26_other') + const other = await textOf(pv(page, '[data-ev-prov-check="R1"]')) + check('同一期的另一天:「这一行成立」,本期结论照样写「本期不成立 · 已接受」', other.includes('这一行成立') && other.includes('本期不成立 · 已接受'), other) + + // ---- 人工录入的统计期、列表 + await openP(page, 'human') + const hItems = await fromOf(page) + check('统计期人工录入:年份取自人工录入的统计期(署名(未认证):录入员甲)', hItems.includes('年份取自人工录入的统计期(署名(未认证):录入员甲)') + && (await textOf(pv(page, '[data-ev-prov-version]'))).includes('人工录入(署名(未认证):录入员甲)'), hItems.join('|')) + await openP(page, 'list') + check('列表:格子在列表头下面(列表头 F5),统计期未记录', await textOf(pv(page, '[data-ev-prov-cell]')) === '工作表「月报」F8' + && (await fromOf(page)).includes('列表头 F5') && (await textOf(pv(page, '[data-ev-prov-version]'))).includes('第 1 次导入 · 统计期未记录')) + + // ---- 27:多于 3 期的折叠 + await openP(page, 'many') + const more = pv(page, '[data-ev-prov-more]') + check('四期:首行「按期累积 · 共 4 期」,只展开这一格所在的一期和最近一期,其余收进「另有 2 期」', + (await textOf(pv(page, '[data-ev-prov-head]'))).endsWith('按期累积 · 共 4 期') && (await partsOf(page)).join(',') === '2*,4' + && await textOf(more) === '另有 2 期' && await more.getAttribute('aria-expanded') === 'false', (await partsOf(page)).join(',')) + await more.focus() + await page.keyboard.press('Enter') + check('……「另有 2 期」是带 aria-expanded 的按钮:回车展开(true),四期按统计期列全,焦点留在按钮上', + await more.getAttribute('aria-expanded') === 'true' && (await partsOf(page)).join(',') === '1,2*,3,4' && await activeHas(page, 'data-ev-prov-more'), + (await partsOf(page)).join(',')) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await panel(page).screenshot({ path: `${SHOTS}/evidence-p4-many-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + + // ---- 17:360 宽的画布右栏 + const wrap = async () => page.evaluate((sel) => { + const box = document.querySelector(sel) + const p = box?.querySelector('[data-evidence-panel]') + return { mode: p?.getAttribute('data-evidence-panel') ?? null, box: [box?.scrollWidth, box?.clientWidth], panel: p ? [p.scrollWidth, p.clientWidth] : null } + }, PVN) + const fits = (w) => w.mode === 'inline' && w.box[0] <= w.box[1] && !!w.panel && w.panel[0] <= w.panel[1] + await openP(page, 'wide', { box: PVN }) + await page.locator(`${PVN} [data-ev-prov-details]`).click().catch(() => {}) + const w1 = await wrap() + await openP(page, 'many', { box: PVN }) + await page.locator(`${PVN} [data-ev-prov-more]`).click().catch(() => {}) + const w2 = await wrap() + await openP(page, 'tampered', { box: PVN }) + const w3 = await wrap() + check('360 宽的画布右栏:栏内展开;宽表一格(技术细节展开)、四期(全部展开)、标红三种都不出横向滚动', + fits(w1) && fits(w2) && fits(w3), JSON.stringify({ w1, w2, w3 })) + if (SHOTS) { + await openP(page, 'wide', { box: PVN }) + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(250) + await page.locator(PVN).screenshot({ path: `${SHOTS}/evidence-p4-360-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + await page.locator(`${PVN} [data-evidence-panel] button`, { hasText: '回到正文' }).click().catch(() => {}) + await page.waitForTimeout(150) + + // ---- 24:加载态 + const wideId = ids.wide + const slow = await open('/ui-harness.html?evidence=1', { + prov: async (run, sid, reply) => { + if (sid === wideId) await new Promise((res) => setTimeout(res, 300)) + return reply + }, + }) + await slow.page.locator(`${PV} [data-seg="${wideId}"]`).click() + await slow.page.waitForSelector('[data-evidence-panel] [data-ev-prov-loading]', { timeout: 4000 }).catch(() => {}) + const loading = await textOf(pv(slow.page, '[data-ev-provenance="loading"] [data-ev-prov-loading]')) + check('取的过程中:「推断的来源」一节写「正在推断来源…」,三节不先画空壳', loading === '正在推断来源…' + && await pv(slow.page, '[data-ev-prov-version], [data-ev-prov-checks], [data-ev-prov-cell]').count() === 0, loading) + await slow.page.waitForSelector('[data-evidence-panel] [data-ev-provenance="inferred"]', { timeout: 4000 }).catch(() => {}) + check('……答复到了以后加载态消失,三节画出来', await pv(slow.page, '[data-ev-prov-loading]').count() === 0 + && await pv(slow.page, '[data-ev-prov-version]').count() === 1 && await pv(slow.page, '[data-ev-prov-checks]').count() === 1) + check('没有运行时报错(加载态)', slow.errors.length === 0, slow.errors.join(' | ')) + await slow.ctx.close() + + // ---- 19:出错、老服务端。另守渲染矩阵本身:服务端误把数据版本带在 none · manifest_unreadable、table_only 的答复里 + // 时照样按矩阵画(前者不画数据版本,后者不画格子)——不能只靠夹具里那些字段恰好为空。 + // 清除、作废、导入的时间没记(契约里 at 可以是 null,夹具里恰好都有):略去这一项,不留孤立的「—」 + const longId = ids.long + const untimed = (ans) => ({ + ...ans, + version: { + ...ans.version, + parts: ans.version.parts.map((pt) => ({ + ...pt, + committed_at: null, + purged: pt.purged && { ...pt.purged, at: null }, + revoked: pt.revoked && { ...pt.revoked, at: null }, + })), + }, + }) + const sloppy = { + [ids.unreadable]: { ...fxp.provenance[ids.unreadable], version: fxp.provenance[wideId].version }, + [ids.alias]: { ...fxp.provenance[ids.alias], cell_source: fxp.provenance[wideId].cell_source, checks: fxp.provenance[wideId].checks }, + [ids.purged]: untimed(fxp.provenance[ids.purged]), + [ids.revoked]: untimed(fxp.provenance[ids.revoked]), + } + const broken = await open('/ui-harness.html?evidence=1', { + prov: (run, sid, reply) => (sid === wideId ? fxp.errors.server : sid === longId ? fxp.errors.old_server + : sloppy[sid] ? { json: sloppy[sid] } : reply), + }) + await openP(broken.page, 'wide') + check('推断来源接口回 500:「推断的来源」一节只写「未能获取推断的来源」', await textOf(pv(broken.page, '[data-ev-provenance="error"] [data-ev-prov-failed]')) + === '未能获取推断的来源' && await pv(broken.page, '[data-ev-prov-version], [data-ev-prov-checks], [data-ev-prov-cell]').count() === 0) + check('……其余几节照常:查询步骤、封存都在', await pv(broken.page, '[data-ev-query]').count() === 1 + && (await textOf(pv(broken.page, '[data-ev-seal]'))).includes('已封存 · 核对一致')) + await openP(broken.page, 'long', { wait: '[data-ev-query]' }) + await broken.page.waitForTimeout(300) + check('回 404 且没有机读码(老服务端):请求发了,三节都不画', provHits(broken.hits, 'long') === 1 && await noSections(broken.page), + String(provHits(broken.hits, 'long'))) + await openP(broken.page, 'unreadable') + check('渲染矩阵:清单无法读取时,答复里哪怕带着数据版本也不画,只画红色提示', + await pv(broken.page, '[data-ev-prov-alert="manifest_unreadable"]').count() === 1 && await pv(broken.page, '[data-ev-prov-version]').count() === 0) + await openP(broken.page, 'alias') + check('渲染矩阵:只给表级来历时,答复里哪怕带着格子和核对也不画', await pv(broken.page, '[data-ev-prov-reason]').count() === 1 + && await pv(broken.page, '[data-ev-prov-cell], [data-ev-prov-checks]').count() === 0) + await openP(broken.page, 'purged') + const purgedUntimed = await textOf(pv(broken.page, '[data-ev-prov-purged]')) + const verUntimed = await textOf(pv(broken.page, '[data-ev-prov-version]')) + check('清除时间、导入时间没记:写「原件已清除 · 理由:… · 署名(未认证):…」,不留孤立的「—」,也不写「导入于」', + /^原件已清除 · 理由:合成理由:原件不再保留 · 署名(未认证):录入员甲/.test(purgedUntimed) + && !verUntimed.includes('—') && !verUntimed.includes('导入于') + && await pv(broken.page, '[data-ev-prov-purged] [data-ev-prov-current]').count() === 1, verUntimed) + await openP(broken.page, 'revoked') + const revokedUntimed = await textOf(pv(broken.page, '[data-ev-prov-revoked]')) + check('作废时间没记:写「这一期的接受已作废 · 理由:… · 署名(未认证):未填写」,不留孤立的「—」', + /^这一期的接受已作废 · 理由:合成理由:接受依据有误 · 署名(未认证):未填写/.test(revokedUntimed) + && !(await textOf(pv(broken.page, '[data-ev-prov-version]'))).includes('—'), revokedUntimed) + check('没有运行时报错(出错、老服务端)', broken.errors.length === 0, broken.errors.join(' | ')) + await broken.ctx.close() +}) + if (SHOTS) { for (const theme of ['dark', 'light']) { await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs index 0dc28bcc..23287374 100644 --- a/scripts/check-versions.mjs +++ b/scripts/check-versions.mjs @@ -894,8 +894,8 @@ await section('版本 · 导入清单', async () => { const excluded = await root.locator('[data-summary-excluded]').innerText().catch(() => '') check('排除的行:原因、锚点、行号', excluded.includes('排除的行') && excluded.includes('只核对、不另存的合计行') && excluded.includes('18-22 时合计') && excluded.includes('第 28–30 行'), excluded.replace(/\s+/g, ' ')) - check('回执摘要:区域外文字全文,注明不提供给模型', (await block('receipt')).includes('客流汇总表') - && (await block('receipt')).includes('区域外文字只在这里显示,不提供给模型')) + check('回执摘要:区域外文字全文,注明不导入数据表、裁判可见', (await block('receipt')).includes('客流汇总表') + && (await block('receipt')).includes('区域外文字不导入数据表;核对报告时,裁判可以看到其中可见单元格的文字')) check('AI 用量块:没有调用模型', (await block('ai')).includes('这次导入没有调用模型')) check('清单校验通过时不出哈希警告', await root.locator('[data-manifest-unverified]').count() === 0) const raw = root.locator('[data-manifest-raw]')