diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py
index 1a88fc7d..590d14a9 100644
--- a/backend/app/api/evidence.py
+++ b/backend/app/api/evidence.py
@@ -9,6 +9,7 @@
- 运行输入:入口节点 node.finished 事件里的 node_output 工件
- 表结构快照:tool.end 事件里的 schema_artifact,或者 node.finished.evidence 里的 schema 条目
- 检索快照:retrieve.end 事件里的 artifact,或者 node.finished.evidence 里的 retrieval 条目
+- 分析结论:node.finished.evidence 里的 analysis 条目(Agent 分析工具、分析卡记的),它的输入快照照查询快照认
artifacts 表可以事后插行,run.output 是可以改写的一列,封存之后追加的事件不在
核对范围里——这三样都不当来源。解析数字层的链:口径卡指标 → 它的输入 → 查询快照里
@@ -70,6 +71,7 @@ from fastapi.responses import JSONResponse, Response
from sqlalchemy import select, update
from sqlalchemy.exc import StatementError
+from app.analysis.contract import CATALOG_PREFIX, LABELS as ANALYSIS_LABELS, LEDGER_KIND, is_result, method_label
from app.api.coded import CodedHTTPException
from app.core.artifact_store import load
from app.core.events import EventType
@@ -109,6 +111,10 @@ from app.engine.evidence import (
UNKNOWN_ENTITY_REASON,
UNVERIFIED_ENTITY_REASON,
RenderError,
+ analysis_card,
+ analysis_formulas,
+ analysis_input_snapshot,
+ analysis_recheck,
build_catalog,
closest_entities,
find_segment,
@@ -119,6 +125,7 @@ from app.engine.evidence import (
make_eid,
normalize_quote,
query_entry_fields,
+ render_cell,
render_metric,
schema_column,
schema_table,
@@ -127,6 +134,7 @@ from app.engine.evidence import (
uncited_claims,
withhold_metrics,
)
+from app.engine.expressions import locate_cell
from app.engine.judge import FIELDS_MAX, SETTLED, UNSUPPORTED, VERDICTS, candidates, outdated
from app.engine.lineage import untraced
from app.engine.merge_query import MERGE_SOURCE, trace_cell
@@ -196,6 +204,8 @@ class _Sealed:
schemas: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 封存范围内交回过的检索快照:工件 id → {node_id, source}
retrievals: dict[str, dict[str, Any]] = field(default_factory=dict)
+ #: 封存范围内台账里记过的分析结论:工件 id → {node_id, exec, call_id / item, inputs}
+ analyses: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 封存时记下的清单哈希(没封存的是 None):旧答案的猜测按 (run_id, 它) 缓存
manifest_hash: str | None = None
#: 运行记录上的 manifest_seq 原值(老运行是 None):按需裁判追加判定前核对封存没换过
@@ -304,6 +314,10 @@ async def _sealed(run_id: str) -> _Sealed:
out.schemas[artifact] = {"node_id": node_id, "source": entry.get("source")}
elif entry.get("kind") == "retrieval":
out.retrievals[artifact] = {"node_id": node_id, "source": entry.get("source")}
+ elif entry.get("kind") == LEDGER_KIND:
+ out.analyses.setdefault(artifact, {
+ "node_id": node_id, **{k: entry[k] for k in ("exec", "call_id", "item", "inputs")
+ if entry.get(k) is not None}})
return out
@@ -517,6 +531,14 @@ def _graph_of(report: _Report, sealed: _Sealed, inputs: dict[str, Any]) -> tuple
"report": report.node_id}
for source in _merge_inputs(entry.get("artifact"))
if (target := _query_alias(catalog, source.get("artifact"))))
+ elif entry.get("kind") == LEDGER_KIND:
+ # 分析结论:哪种分析、算成没有、判定码、谁做的(分析卡的那一项 / Agent 的那次调用);连向它的输入(「派生自」:
+ # 输入快照的 Q…,下钻的上一层 A…),图上看得出 A3 是从 Q2 算出来的
+ item.update({k: entry[k] for k in ("op", "mode", "status", "verdict", "item", "call_id")
+ if entry.get(k) is not None})
+ edges.extend({"from": alias, "to": target, "rel": "derived_from", "report": report.node_id}
+ for target in dict.fromkeys(_catalog_alias_of(catalog, a) for a in entry.get("inputs") or [])
+ if target and target != alias)
elif entry.get("kind") in ENTITY_KINDS:
# 表和字段:叫什么、出现在哪几次查询里、来历(表结构快照 / SQL / 结果列)各自封存了没有
item.update({k: entry[k] for k in ("name", "table", "tables", "qualified", "source") if entry.get(k)})
@@ -934,6 +956,9 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed,
entry, doc, sealed, masks)
if seg.get("state") != "deterministic" or cite.get("status") != "resolved":
return []
+ if cite.get("kind") == LEDGER_KIND or (cite.get("kind") == "cell" and entry.get("kind") == LEDGER_KIND):
+ # 分析结论:判定语、具名标量、结果表的一格都接同一条链(判定 → 方法 → 计算 → 输入 → 复算 → 封存)
+ return await _analysis_chain(seg, cite, entry, doc, sealed, masks)
if cite.get("kind") == "cell":
locator = cite.get("locator") or {}
# 只有直接引用的单元格才可能推断来源(P4-SPEC 2.8.2)。指标链里的查询步骤不带:指标片段的推断来源只会
@@ -953,6 +978,259 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed,
return await _metric_chain(seg, cite, entry, doc, sealed, masks)
+# --------------------------------------------------------------------------
+# 分析结论的出处链
+# --------------------------------------------------------------------------
+
+#: Agent 写的假设(hypothesis)旁边那句:它不是结论,只在证据面板显示(细案 01 §7.3)
+HYPOTHESIS_NOTE = "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文"
+UNSEALED_ANALYSIS = "这份分析结论不属于封存范围内的任何事件(封存前的台账里没有它),不能作为证据展示"
+TAMPERED_ANALYSIS = "分析结论与哈希不一致,疑似被修改,不展示其中的判定和数"
+MISSING_ANALYSIS = "无法读取分析结论"
+#: 每个算子用到的阈值(细案 01 §10「用在」一栏):方法一步只列这几个,判定标准照它们写
+_OP_SETTINGS: dict[str, tuple[str, ...]] = {
+ "decompose": ("main_share", "concentration", "offset_ratio", "flat_pct", "min_denominator", "max_drill_depth",
+ "table_top_k"),
+ "baseline": ("k_warn", "k_alert", "min_history", "max_history"),
+ "trend": ("trend_min_points", "trend_alpha", "min_history", "max_history"),
+ "differ": ("differ_alpha", "min_n", "min_expected"),
+ "comparable": (),
+}
+#: 输入的角色(analysis.contract.AnalysisInput.role)给人看的叫法
+_INPUT_ROLES = {"data": "数据", "base": "基期", "target": "本期", "headline_base": "基期总数",
+ "headline_target": "本期总数", "parent": "上一层拆解"}
+#: 闭合核对:headline 的那一格对着结论里的哪个总数(细案 01 §2.1)
+_HEADLINE_TOTALS = {"headline_base": "base_total", "headline_target": "target_total"}
+#: 计算一步最多给结果表的几行(前 table_top_k 段,加上被引用的那一行前后各 WINDOW 行)
+MAX_ANALYSIS_ROWS = MAX_WINDOW_ROWS
+
+
+def _analysis_of(artifact: str) -> tuple[dict[str, Any] | None, bool | None]:
+ """取回一份分析结论:(结论, hash_ok)。哈希对不上的 (None, False),取不回来的 (None, None),内容不是分析结论的 (None, None)。"""
+ content, ok = _fetch(artifact) if artifact else (None, None)
+ return (content, ok) if ok is True and is_result(content) else (None, False if ok is False else None)
+
+
+def _catalog_alias_of(catalog: dict[str, Any], artifact: Any) -> str | None:
+ """这份工件在报告目录里的编号:查询快照是 Q…,分析结论是 A…。"""
+ if not artifact:
+ return None
+ return next((alias for alias, e in catalog.items()
+ if isinstance(e, dict) and e.get("kind") in ("query", LEDGER_KIND) and e.get("artifact") == artifact), None)
+
+
+def _analysis_cited(cite: dict[str, Any], result: dict[str, Any] | None) -> dict[str, Any]:
+ """这一处引用的是结论的哪一项:判定语(phrase)、具名标量(value:键、原值、换算)、结果表的一格(cell:行、列、原值)。"""
+ locator = cite.get("locator") or {}
+ if cite.get("kind") == "cell":
+ row, column = locator.get("row"), locator.get("column")
+ raw = None
+ table = (result or {}).get("table") if isinstance((result or {}).get("table"), dict) else None
+ if table is not None:
+ with contextlib.suppress(Exception):
+ raw, column = locate_cell(table, row, column)
+ return {"what": "cell", "row": row, "column": column, "raw": raw}
+ if locator.get("key"):
+ values = (result or {}).get("values") if isinstance((result or {}).get("values"), dict) else {}
+ return {"what": "value", "key": locator["key"], "raw": values.get(locator["key"]),
+ **({"conv": cite["conv"]} if cite.get("conv") else {})}
+ return {"what": "phrase"}
+
+
+def _analysis_rendered(cite: dict[str, Any], result: dict[str, Any]) -> str | None:
+ """按封存的结论重新渲染这一处引用(判定语按文本格、具名标量和结果表的格按单元格的规则)。渲染不出来是 None。"""
+ try:
+ cited = _analysis_cited(cite, result)
+ if cited["what"] == "phrase":
+ return render_cell(result.get("phrase"), kind="text") if isinstance(result.get("phrase"), str) else None
+ if cited["what"] == "value":
+ return render_cell(cited["raw"], cite.get("conv"))
+ return render_cell(cited["raw"], cite.get("conv"), plain=cite.get("plain") is True)
+ except RenderError:
+ return None
+
+
+async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], doc: dict[str, Any],
+ sealed: _Sealed, masks: _Masks) -> list[dict[str, Any]]:
+ """分析结论的出处链(00-总方案 §3.3):判定 → 方法 → 计算 → 输入 → 复算 → 封存。
+
+ - 判定(analysis):判定语、判定标准、判定码和点名的段、附带说明、算不了的原因,这一处引用的是哪一项(判定语、具名标量、
+ 结果表的一格);Agent 写的假设另列,标成「Agent 写的假设」(HYPOTHESIS_NOTE)
+ - 方法(analysis_method):方法与版本、规范化后的参数、实际用的阈值(只列这个算子用到的)、现查的表范围(probes)
+ - 计算(analysis_calc):结果表(前 table_top_k 段,加上被引用的那一行附近)、具名的数、关键数的代入式
+ (evidence.analysis_formulas)
+ - 输入(analysis_input,每份一步):落到报告目录的哪个 Q、用了哪些列和行、headline 那一格和结论里总数的闭合核对、
+ 下钻的上一层是哪个 A;后面接上每份输入快照的查询步骤(只认封存范围内交回过的,遮罩照查询步骤的办法)
+ - 复算(analysis_recompute):按 sha256 取回封存的输入、用同一个函数复算,和封存的逐字段比(evidence.analysis_recheck);
+ 方法升过版、复算不了都照实写
+ - 封存(analysis_seal):这件工件是不是封存范围内的台账记过的、哈希复验通过
+ 结论取不回、哈希不符的只给判定和封存两步,照实说为什么。"""
+ catalog = doc.get("catalog") or {}
+ alias = str(cite.get("alias") or entry.get("alias") or "")
+ artifact = str(entry.get("artifact") or "")
+ result, hash_ok = _analysis_of(artifact)
+ info = sealed.analyses.get(artifact)
+ in_ledger = sealed.trusted and (str(entry.get("node_id") or ""), artifact) in sealed.ledger
+ eid = make_eid(LEDGER_KIND, artifact or None, {})
+ rendered = _analysis_rendered(cite, result) if result is not None else None
+ judged: dict[str, Any] = {
+ "step": "analysis", "alias": alias, "op": entry.get("op"), "mode": entry.get("mode"),
+ "method_label": method_label(entry.get("op"), entry.get("mode")),
+ "origin": "card" if analysis_card(entry) else "agent",
+ **{k: entry[k] for k in ("item", "call_id") if entry.get(k)},
+ "node_id": entry.get("node_id"), "label": _node_label(sealed, entry.get("node_id")) or None,
+ "artifact": artifact or None, "eid": entry.get("eid"), "eid_ok": entry.get("eid") == eid,
+ "cited": _analysis_cited(cite, result), "rendered": seg.get("text"),
+ "render_ok": rendered is not None and rendered == seg.get("text"), "hash_ok": hash_ok,
+ }
+ seal = {"step": "analysis_seal", "alias": alias, "node_id": entry.get("node_id"), "artifact": artifact or None,
+ "ledger": info is not None, "hash_ok": hash_ok, "eid_ok": judged["eid_ok"],
+ "sealed": in_ledger and hash_ok is True and judged["eid_ok"]}
+ if info is None:
+ seal["note"] = UNSEALED_ANALYSIS
+ if result is None:
+ judged["note"] = TAMPERED_ANALYSIS if hash_ok is False else MISSING_ANALYSIS
+ return [judged, seal]
+ verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else None
+ judged.update(
+ status=result.get("status"), reason_code=result.get("reason_code"), reason=result.get("reason"),
+ phrase=result.get("phrase"), criterion=result.get("criterion"), verdict=verdict,
+ caveats=[{k: c.get(k) for k in ("code", "text")} for c in result.get("caveats") or [] if isinstance(c, dict)])
+ if isinstance(result.get("hypothesis"), str) and result["hypothesis"].strip():
+ judged.update(hypothesis=result["hypothesis"], hypothesis_note=HYPOTHESIS_NOTE)
+ settings = result.get("settings") if isinstance(result.get("settings"), dict) else {}
+ method = {
+ "step": "analysis_method", "alias": alias, "method": result.get("method"),
+ "method_label": judged["method_label"], "params": result.get("params") or {},
+ "settings": [{"key": k, "label": ANALYSIS_LABELS.get(k, k), "value": settings[k]}
+ for k in _OP_SETTINGS.get(str(result.get("op")), ()) if k in settings],
+ "probes": [p for p in result.get("probes") or [] if isinstance(p, dict)],
+ }
+ inputs, queries = await _analysis_inputs(alias, result, catalog, doc, sealed, masks)
+ hidden = {str(c).lower() for q in queries for c in q.get("masked") or []}
+ calc = _analysis_calc(alias, result, judged["cited"], hidden)
+ again = await _analysis_recheck(result, sealed)
+ recheck = {"step": "analysis_recompute", "alias": alias, **again}
+ return [judged, method, calc, *inputs, *queries, recheck, seal]
+
+
+def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hidden: set[str]) -> dict[str, Any]:
+ """计算一步:结果表(前 table_top_k 段加上被引用的那一行附近,最多 MAX_ANALYSIS_ROWS 行,高亮被引用的格)、具名的数(原值、
+ 渲染出来的字)、关键数的代入式。结果表里万一有取自遮罩列的列(输入查询步骤里遮着的列名),照查询步骤写「已遮罩」。"""
+ values = result.get("values") if isinstance(result.get("values"), dict) else {}
+ shown_values = []
+ for key, raw in values.items():
+ try:
+ text = render_cell(raw)
+ except RenderError:
+ text = MISSING
+ shown_values.append({"key": str(key), "raw": raw, "rendered": text,
+ **({"cited": True} if cited.get("what") == "value" and cited.get("key") == key else {})})
+ step: dict[str, Any] = {"step": "analysis_calc", "alias": alias, "values": shown_values,
+ "formulas": analysis_formulas(result)}
+ table = result.get("table") if isinstance(result.get("table"), dict) else None
+ if table is None or not isinstance(table.get("rows"), list):
+ return step
+ columns = [str(c) for c in table.get("columns") or []]
+ rows = table["rows"]
+ settings = result.get("settings") if isinstance(result.get("settings"), dict) else {}
+ top = settings.get("table_top_k") if isinstance(settings.get("table_top_k"), int) else 10
+ index = set(range(min(top, len(rows))))
+ row = cited.get("row") if cited.get("what") == "cell" else None
+ if isinstance(row, int):
+ index |= {i for i in range(row - WINDOW, row + WINDOW + 1) if 0 <= i < len(rows)}
+ picked = sorted(index)[:MAX_ANALYSIS_ROWS]
+ masked = {i for i, c in enumerate(columns) if c.lower() in hidden}
+ step["table"] = {
+ "columns": columns, "labels": dict(table.get("labels") or {}) if isinstance(table.get("labels"), dict) else {},
+ "total_rows": len(rows), "row_index": picked,
+ "rows": [[MASKED if i in masked else v for i, v in enumerate(rows[r])] if isinstance(rows[r], list) else rows[r]
+ for r in picked],
+ "masked": [columns[i] for i in sorted(masked)],
+ "highlight": {"cells": [[row, str(cited.get("column"))]] if isinstance(row, int) else []},
+ **({"window_truncated": True} if len(index) > MAX_ANALYSIS_ROWS else {}),
+ }
+ return step
+
+
+async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str, Any], doc: dict[str, Any],
+ sealed: _Sealed, masks: _Masks) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """输入:每份一步(analysis_input),再给每份输入快照一个查询步骤(_query_steps,高亮用到的列;整份用到的给前
+ MAX_WINDOW_ROWS 行)。headline 的那一格另核对和结论里的总数闭不闭合(照实写判定是不是 unclosed,不在这里重算容差);
+ 下钻的上一层落到报告目录的哪个 A。"""
+ values = result.get("values") if isinstance(result.get("values"), dict) else {}
+ verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else {}
+ unclosed = verdict.get("code") == "unclosed"
+ steps: list[dict[str, Any]] = []
+ cells: dict[str, list[tuple[Any, Any]]] = {}
+ for item in result.get("inputs") or []:
+ if not isinstance(item, dict):
+ continue
+ artifact = str(item.get("artifact") or "")
+ role = str(item.get("role") or "")
+ step: dict[str, Any] = {
+ "step": "analysis_input", "of": alias, "ref": item.get("ref"), "role": role,
+ "role_label": _INPUT_ROLES.get(role, role or "输入"), "artifact": artifact or None,
+ **{k: item[k] for k in ("columns", "rows", "row", "column", "value") if k in item},
+ }
+ if role == "parent":
+ step["alias"] = _catalog_alias_of(catalog, artifact)
+ parent, ok = _analysis_of(artifact)
+ step.update(sealed=sealed.trusted and artifact in sealed.analyses, hash_ok=ok,
+ **({"phrase": parent.get("phrase")} if parent else {}))
+ steps.append(step)
+ continue
+ query = sealed.queries.get(artifact)
+ step.update(query=_catalog_alias_of(catalog, artifact), node_id=(query or {}).get("node_id"),
+ sealed=sealed.trusted and query is not None)
+ if role in _HEADLINE_TOTALS and _HEADLINE_TOTALS[role] in values:
+ total = values[_HEADLINE_TOTALS[role]]
+ with contextlib.suppress(RenderError):
+ step["closure"] = {"total_key": _HEADLINE_TOTALS[role], "total": render_cell(total),
+ "headline": render_cell(item.get("value")), "closed": not unclosed}
+ steps.append(step)
+ if artifact and query is not None:
+ wanted = cells.setdefault(artifact, [])
+ if isinstance(item.get("row"), int) and item.get("column"):
+ wanted.append((item["row"], item["column"]))
+ for column in item.get("columns") or []:
+ picked = item.get("rows")
+ rows = list(range(MAX_WINDOW_ROWS)) if picked == "all" else \
+ [r for r in picked if isinstance(r, int)][:MAX_WINDOW_ROWS] if isinstance(picked, list) else []
+ wanted.extend((r, column) for r in rows)
+ queries: list[dict[str, Any]] = []
+ for artifact, picked in cells.items():
+ snap, _ = masks.fetch(artifact)
+ total = len(snap["rows"]) if isinstance(snap, dict) and isinstance(snap.get("rows"), list) else 0
+ picked = list(dict.fromkeys((r, c) for r, c in picked if not isinstance(r, int) or r < total))
+ for sub in await _query_steps(artifact, picked, doc, sealed, masks):
+ queries.append({**sub, "input_of": alias})
+ return steps, queries
+
+
+async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]:
+ """复算:按 sha256 取回封存范围内的输入快照(evidence.analysis_input_snapshot)和下钻的上一层结论,在线程里复算、比较
+ (evidence.analysis_recheck)。不在封存范围内的输入一律当取不回来。"""
+ snapshots: dict[str, Any] = {}
+ parents: dict[str, Any] = {}
+ for item in result.get("inputs") or []:
+ artifact = str((item or {}).get("artifact") or "") if isinstance(item, dict) else ""
+ if not artifact or artifact in snapshots or artifact in parents:
+ continue
+ if item.get("role") == "parent":
+ parents[artifact] = _analysis_of(artifact)[0] if artifact in sealed.analyses else None
+ continue
+ info = sealed.queries.get(artifact)
+ content, ok = _fetch(artifact) if info is not None else (None, None)
+ snapshots[artifact] = analysis_input_snapshot(content, artifact, ref=item.get("ref"),
+ node_id=(info or {}).get("node_id")) if ok is True else None
+
+ def run() -> dict[str, Any]:
+ return analysis_recheck(result, snapshots.get, load_result=parents.get)
+
+ return await asyncio.to_thread(run)
+
+
def _missing_input(cite: dict[str, Any], entry: dict[str, Any]) -> bool:
"""引用的指标存在,只是这次缺输入没有值。引用写错(目录里没有、同名指标有歧义)的不算。"""
return (cite.get("kind") == "metric" and cite.get("status") == "unresolved" and entry.get("kind") == "metric"
@@ -1007,6 +1285,10 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
# 期 4:这个指标做没做过双盲复算、结果如何。没有双盲复算复核过这份指标集时没有这个键,老运行一字不差
if checked := _dual_check_of(sealed, str(entry.get("node_id") or ""), artifact, metric_id, metric):
step["dual_check"] = checked
+ # 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查(analysis.contract.MetricComparability),点得开那份
+ # 可比性结论(artifact)。没做过检查的指标没有这个键
+ if isinstance((metric or {}).get("comparability"), dict):
+ step["comparability"] = _comparability_view(metric["comparability"], sealed, chained=step["sealed"])
card_node = str(entry.get("node_id") or "")
if metric is not None and metric.get("inputs"):
# 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说
@@ -1047,6 +1329,26 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
return [step, *inputs, *upstream, *queries]
+def _comparability_view(comparability: dict[str, Any], sealed: _Sealed, *, chained: bool) -> dict[str, Any]:
+ """指标步骤里的可比性:判定(comparable / conditional / incomparable)、判定语、原因(一句一个),连同那份可比性结论的
+ 工件:取回、复验哈希,带上它的判定标准、方法和现查的表范围(点得开)。sealed:哈希复验通过,而且它记在封存过的口径卡
+ 指标集里(chained:指标步骤本身封存了;内容寻址,卡上记着它的 sha256)或者封存范围内的台账里。"""
+ artifact = str(comparability.get("artifact") or "")
+ result, hash_ok = _analysis_of(artifact)
+ view: dict[str, Any] = {
+ "verdict": comparability.get("verdict"), "phrase": comparability.get("phrase"),
+ "reasons": [str(r) for r in comparability.get("reasons") or [] if r], "artifact": artifact or None,
+ "hash_ok": hash_ok,
+ "sealed": sealed.trusted and hash_ok is True and (chained or artifact in sealed.analyses),
+ }
+ if result is not None:
+ view.update(method=result.get("method"), criterion=result.get("criterion"),
+ caveats=[{k: c.get(k) for k in ("code", "text")} for c in result.get("caveats") or []
+ if isinstance(c, dict)],
+ probes=[p for p in result.get("probes") or [] if isinstance(p, dict)])
+ return view
+
+
async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Masks", catalog: dict[str, Any],
wanted: dict[str, list[tuple[Any, Any]]], seen: set[tuple[str, str]],
depth: int = 1, *, card_node: str = "") -> list[dict[str, Any]]:
@@ -2905,16 +3207,51 @@ async def evidence_provenance(run_id: str, segment_id: str, report: str | None =
def _export_cite_key(seg: dict[str, Any]) -> tuple | None:
- """数字 / 取值片段的引用键:同一处引用(同一个指标、同一格、同样的换算)共用一条出处链。
- 别的片段(表名、引文)的链跟着片段走,不归并。"""
- if seg.get("kind") not in ("number", "value"):
+ """数字 / 取值 / 判定语片段的引用键:同一处引用(同一个指标、同一格、同一条分析结论的同一项、同样的换算)共用一条
+ 出处链。别的片段(表名、引文)的链跟着片段走,不归并。具名标量的键另接在后面:没有它的引用键和以前一模一样。"""
+ if seg.get("kind") not in ("number", "value", LEDGER_KIND):
return None
cite = seg.get("cite")
if not isinstance(cite, dict):
return None
loc = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
- return (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"),
- loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue"))
+ key = (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"),
+ loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue"))
+ return (*key, loc["key"]) if loc.get("key") else key
+
+
+#: 报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):导出页、可验证报告按它渲染,编号点得开。各组的键
+SCOPE_GROUPS = ("phenomena", "decomposed", "excluded", "refused")
+#: 分析范围里一个编号在导出页数据块里的片段键:refs、notes 按它找出处链(不和片段 id s0、s1… 撞)
+SCOPE_REF = "scope:{alias}"
+
+
+def scope_aliases(doc: dict[str, Any]) -> list[str]:
+ """报告文档的「分析范围」里点名的分析结论编号,按出现的先后、去重。没有这一段的文档是空列表。"""
+ scope = doc.get("analysis_scope") if isinstance(doc.get("analysis_scope"), dict) else None
+ if scope is None:
+ return []
+ out = [str(item["alias"]) for group in SCOPE_GROUPS for item in scope.get(group) or []
+ if isinstance(item, dict) and isinstance(item.get("alias"), str) and item["alias"]]
+ return list(dict.fromkeys(out))
+
+
+async def _scope_chains(doc: dict[str, Any], sealed: _Sealed, report_node: str) -> dict[str, list[dict[str, Any]]]:
+ """分析范围里每个编号的出处链(和点开正文里的判定语同一条 _chain,按那条结论的判定语认):{编号: 链}。目录里没有的
+ 编号不给(文档里写着、目录里却没有,只能是文档有问题,范围那一项点不开)。"""
+ catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
+ out: dict[str, list[dict[str, Any]]] = {}
+ for alias in scope_aliases(doc):
+ entry = catalog.get(alias)
+ if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND:
+ continue
+ result, _ = _analysis_of(str(entry.get("artifact") or ""))
+ seg = {"id": SCOPE_REF.format(alias=alias), "kind": LEDGER_KIND, "state": "deterministic",
+ "text": render_cell(result.get("phrase"), kind="text") if result and result.get("phrase") else alias,
+ "cite": {"alias": alias, "kind": LEDGER_KIND, "status": "resolved", "locator": {},
+ "eid": entry.get("eid")}}
+ out[alias] = await _chain(seg, doc, sealed, report_node)
+ return out
async def _export_withheld(sealed: _Sealed, doc: dict[str, Any]) -> frozenset[str]:
@@ -3022,6 +3359,14 @@ async def export_report_html(run_id: str, report: str | None = None) -> Response
refs[sid] = str(ck)
if key is not None:
memo[key] = str(ck)
+ # 系统写的「分析范围」:每个编号点得开,链和点开正文里的判定语是同一条(引用键相同的共用)
+ for alias, chain in (await _scope_chains(doc, sealed, chosen.node_id)).items():
+ sid = SCOPE_REF.format(alias=alias)
+ ck = (LEDGER_KIND, alias, "resolved", None, None, None, None, None, None)
+ if ck not in memo:
+ memo[ck] = str(ck)
+ chains[str(ck)] = chain
+ refs[sid] = memo[ck]
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None: # 两次读之间被删掉了
@@ -3739,6 +4084,14 @@ def _note(seg: dict[str, Any], unit: dict[str, Any]) -> str:
"仅核对它存在且本次用到,不核对其业务含义")
if seg.get("state") == "deterministic" and seg.get("kind") == "quote":
return "这段原话在知识库检索命中的片段中逐字出现(空白归一化后比对),已由系统核对"
+ if seg.get("state") == "deterministic" and cite.get("kind") == LEDGER_KIND:
+ if (cite.get("locator") or {}).get("key"):
+ return "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写"
+ return ("判定语由系统按平台的方法算出判定、按模板写成,未经模型转写;判定标准、计算过程、输入和复算见出处链。"
+ "判定只说明变化来自哪里,不说明原因")
+ if seg.get("state") == "deterministic" and cite.get("kind") == "cell" \
+ and str(cite.get("alias") or "").startswith(CATALOG_PREFIX):
+ return "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写"
if seg.get("state") == "deterministic" and cite.get("kind") == "cell":
return "该值由系统从查询快照中取出、按固定规则显示,未经模型转写"
if seg.get("state") == "deterministic":
@@ -3994,21 +4347,30 @@ async def _judge_loader(doc: dict[str, Any], catalog: dict[str, Any], units: lis
read = loader or load
masks = _Masks()
shown: dict[str, Any] = {}
+ wanted: list[tuple[str, Any]] = []
for alias in dict.fromkeys(a for c in candidates(doc, units) for a in c.aliases):
entry = catalog.get(alias)
- if not isinstance(entry, dict) or entry.get("kind") != "query" or not entry.get("artifact"):
+ if not isinstance(entry, dict) or not entry.get("artifact"):
continue
- artifact = str(entry["artifact"])
+ if entry.get("kind") == "query":
+ wanted.append((str(entry["artifact"]), entry.get("source")))
+ elif entry.get("kind") == LEDGER_KIND:
+ # 分析结论的摘录给它输入查询的 SQL:输入快照同样逐份遮(结论本身不经这里,摘录照它自己的办法取)
+ with contextlib.suppress(Exception):
+ result = read(str(entry["artifact"]))
+ wanted.extend((str(i["artifact"]), None) for i in (result or {}).get("inputs") or []
+ if isinstance(i, dict) and i.get("artifact") and i.get("role") != "parent")
+ for artifact, source in wanted:
if artifact in shown:
continue
try:
snap = read(artifact)
except Exception: # noqa: BLE001 - 读不出来、不在封存范围里:摘录本来就不给它,交给 prepare 照它的办法处理
continue
- if not isinstance(snap, dict):
+ if not isinstance(snap, dict) or not isinstance(snap.get("rows"), list):
continue
recorded = _mask_names(snap.get("mask_columns"))
- sure = set((await masks.sets(artifact, entry.get("source") or snap.get("source"), snap)).sure)
+ sure = set((await masks.sets(artifact, source or snap.get("source"), snap)).sure)
shown[artifact] = {**snap, "mask_columns": sorted(recorded | sure)} if sure - recorded else snap
def reader(artifact: str) -> Any:
@@ -4262,8 +4624,11 @@ GROUPS = (("cited", "有出处"), ("none", "无证据"), ("suspicious", "可疑
_INTEGRITY = frozenset({"bad_schema", "segment_mismatch", "structural_text", "render_mismatch", "eid_mismatch",
"state_mismatch"})
#: 落在已经列出的片段上也单列一行的违规:文档对不上的,和引用了遮罩列的格子的(masked_cell:引用的那一格落在的正是一个
-#: 「有出处」的单元格片段,不单列的话审计表、导出的 CSV 里只有一行普通的「有出处」)
-_LISTED_ALWAYS = _INTEGRITY | {"masked_cell"}
+#: 「有出处」的单元格片段,不单列的话审计表、导出的 CSV 里只有一行普通的「有出处」);分析说法核对的三种(判断词没挂分析
+#: 结论、判断和结论对不上、因果说法没有依据,engine/analysis_claims.py):句子里的判定语、数照样是「有出处」的片段,说法本身
+#: 有问题要单独看得到
+ANALYSIS_CLAIM_CODES = frozenset({"analysis_claim_uncited", "analysis_claim_mismatch", "causal_unsupported"})
+_LISTED_ALWAYS = _INTEGRITY | {"masked_cell"} | ANALYSIS_CLAIM_CODES
UNCITED_CLAIM = "这句结论没有附依据(既没有引用,也没有注明依据)"
UNCITED_CLAIM_COUNTED = "这句结论没有附依据。本报告要求结论句附依据,出具时计入缺口"
UNCITED_CLAIM_WITHHELD = "这句结论没有附依据,按出具契约不予出具"
@@ -4297,15 +4662,17 @@ SEAL_NONE = "未封存:运行尚未结束,或正停在人工审批"
_CSV_LABELS: dict[str, dict[Any, str]] = {
"group": dict(GROUPS),
"kind": {"number": "数字", "value": "值", "entity": "表名、字段名", "quote": "引文", "claim": "结论句",
- "violation": "违规"},
+ "violation": "违规", LEDGER_KIND: "分析判定"},
"state": {"deterministic": "有出处", "none": "无证据", "candidate": "候选"},
"issue": {"uncited_number": "没有出处的数字", "unresolved_ref": "引用无法解析", "unknown_entity": "疑似不存在的名称",
"unverified_entity": "无法核实", "uncited_claim": "结论句未附依据", "no_candidate": "未找到候选",
"contradicted_claim": "证据相矛盾", "insufficient_claim": "证据不足", "unsupported_claim": "证据不支持",
- "masked_cell": "引用了遮罩列",
+ "masked_cell": "引用了遮罩列", "inline_see": "依据写在句子中间", "absence_claim": "缺失断言",
+ "analysis_claim_uncited": "判断词未挂分析结论", "analysis_claim_mismatch": "判断与分析结论不符",
+ "causal_unsupported": "因果说法没有依据",
**{code: "文档不一致" for code in _INTEGRITY}},
"evidence_kind": {"metric": "口径卡指标", "cell": "查询单元格", "input": "运行输入", "table": "表", "column": "字段",
- "quote": "知识库原话", "query": "查询结果"},
+ "quote": "知识库原话", "query": "查询结果", LEDGER_KIND: "分析结论"},
"sealed": {True: "是", False: "否", None: "—"},
}
#: 电子表格会把以这些字符开头的格子当公式算:报告、答案里的字来自模型,照原样写进去就能被人利用
@@ -4507,10 +4874,20 @@ def _segment_row(group: str, report: _Report, field_name: str | None, seg: dict[
if group != "cited":
return row
kind = cite.get("kind")
- if kind == "cell":
+ if kind == "cell" and entry.get("kind") == LEDGER_KIND:
+ # 分析结论结果表里的一格:证据种类记分析结论,封存按台账认(结论不是查询快照)
+ row.update(evidence=_cell_name(alias, cite.get("locator") or {}), evidence_kind=LEDGER_KIND,
+ artifact=entry.get("artifact"), node_id=entry.get("node_id"),
+ sealed=_entry_sealed(sealed, entry, inputs))
+ elif kind == "cell":
artifact = str(entry.get("artifact") or "")
row.update(evidence=_cell_name(alias, cite.get("locator") or {}), artifact=artifact or None,
node_id=entry.get("node_id"), sealed=sealed.trusted and artifact in sealed.queries)
+ elif kind == LEDGER_KIND:
+ # 判定语写结论的叫法(「变化拆解(可加) · 判定语」),具名标量写到键(A3.top_share)
+ key = (cite.get("locator") or {}).get("key")
+ row.update(evidence=f"{alias}.{key}" if key else entry.get("label") or alias, artifact=entry.get("artifact"),
+ node_id=entry.get("node_id"), sealed=_entry_sealed(sealed, entry, inputs) if entry else False)
elif kind == "quote":
source = cite.get("source") or {}
artifact = str(source.get("artifact") or entry.get("artifact") or "")
diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py
new file mode 100644
index 00000000..800e5cd7
--- /dev/null
+++ b/backend/tests/test_analysis_evidence_api.py
@@ -0,0 +1,322 @@
+"""分析结论进证据链(第 1 波 W1-b):证据接口、审计、报告导出。
+
+运行是直接落库的虚构封存运行(虚构零售库 shop):一次查询(Q1)、一份按地区拆的分析结论(A1,分析卡或 Agent 做的),
+报告引用判定语、具名标量、结果表的格。算子第 1 波还没落地,复算按 monkeypatch 换。
+
+- 证据图:A1 条目带算子、判定;「派生自」的边 A1 → Q1
+- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;Agent 写的假设标出来;复算不了照实写,不让接口出错
+- 审计:分析判定、分析结论的叫法;分析说法核对的违规单列一行
+- 报告导出:数据块里有分析结论那几步;文档带「分析范围」的,正文后面渲染、编号点得开
+- 指标步骤带可比性
+"""
+from __future__ import annotations
+
+import csv
+import io
+import json
+import re
+import uuid
+from typing import Any
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app import analysis
+from app.analysis.contract import ARTIFACT_KIND, ledger_entry
+from app.analysis.testing import fake_result
+from app.core import artifact_store
+from app.core.artifact_store import manifest_hash
+from app.db.base import SessionLocal
+from app.db.models import Run, RunEvent
+from app.engine.evidence import build_catalog, compose_doc, iter_segments
+from app.main import app
+from tests.test_analysis_evidence import SNAPSHOT
+
+GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
+ ("fetch", "tool", "按地区取数"), ("an", "agent", "分析"), ("card", "metrics", "周报口径"),
+ ("write", "report", "写周报")]]}
+
+
+@pytest.fixture
+async def client():
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ yield c
+
+
+async def sealed_run(text: str, *, item: str | None = "drop", call_id: str | None = None,
+ result: dict[str, Any] | None = None, doc_extra: dict[str, Any] | None = None,
+ card: dict[str, Any] | None = None) -> dict[str, Any]:
+ """落一次封存好的运行:查询快照、分析结论、(可选)口径卡、报告文档都按真实的工件写,事件按 runner 的算法封存。"""
+ run_id = uuid.uuid4().hex
+ q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot", run_id=run_id, node_id="fetch")
+ res = result or fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data",
+ "columns": ["region", "week", "gmv"], "rows": "all"}])
+ a = await artifact_store.put_json(res, kind=ARTIFACT_KIND, run_id=run_id, node_id="an")
+ qentry = {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop",
+ "source": "shop", "columns": SNAPSHOT["columns"], "rows": len(SNAPSHOT["rows"]), "truncated": False}
+ aentry = ledger_entry(res, node_id="an", exec_no=1, artifact=a, item=item, call_id=call_id)
+ nodes: dict[str, Any] = {}
+ events: list[tuple[int, str, str | None, dict[str, Any]]] = [
+ (1, "run.started", None, {"input": {}}), (2, "node.finished", "fetch", {"evidence": [qentry]}),
+ (3, "node.finished", "an", {"evidence": [aentry]})]
+ if card is not None:
+ card_id = await artifact_store.put_json(card, kind="metric_set", run_id=run_id, node_id="card")
+ nodes["card"] = {**card, "artifact": card_id}
+ events.append((4, "node.finished", "card", {"evidence": [{"kind": "metric_set", "node_id": "card",
+ "exec": 1, "artifact": card_id}]}))
+ catalog = build_catalog(nodes=nodes, ledger=[qentry, aentry])
+ doc = compose_doc(text, catalog, node_id="write", run_id=run_id)
+ doc.update(doc_extra or {})
+ doc_id = await artifact_store.put_json(doc, kind="report_doc", run_id=run_id, node_id="write")
+ seq = len(events)
+ events += [(seq + 1, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}),
+ (seq + 2, "run.finished", None, {"output": {}})]
+ async with SessionLocal() as session:
+ session.add(Run(id=run_id, workflow_name="分析周报", status="succeeded", run_class="exploratory",
+ graph=GRAPH, agent_limits={}, manifest_hash=manifest_hash(events), manifest_seq=events[-1][0]))
+ await session.flush()
+ for s, etype, node, data in events:
+ session.add(RunEvent(run_id=run_id, seq=s, type=etype, node_id=node, ts=float(s), data=data))
+ await session.commit()
+ return {"run": run_id, "doc": doc, "query": q, "analysis": a, "result": res}
+
+
+def seg_id(doc: dict[str, Any], ref: str) -> str:
+ return next(s["id"] for s in iter_segments(doc) if s.get("ref") == ref)
+
+
+TEXT = "[[a:A1]]。华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]]。[[see:A1]]"
+
+
+# --------------------------------------------------------------------------
+# 证据图
+# --------------------------------------------------------------------------
+
+
+async def test_the_graph_lists_the_analysis_and_what_it_was_derived_from(client):
+ made = await sealed_run(TEXT)
+ body = (await client.get(f"/api/runs/{made['run']}/evidence")).json()
+ assert body["mode"] == "cited"
+ item = next(e for e in body["evidence"] if e["alias"] == "A1")
+ assert item["kind"] == "analysis" and item["op"] == "decompose" and item["verdict"] == "driver"
+ assert item["item"] == "drop" and item["sealed"] is True and item["artifact"] == made["analysis"]
+ assert item["label"].startswith("变化拆解(可加) · ")
+ edges = {(e["from"], e["to"], e["rel"]) for e in body["edges"]}
+ assert ("A1", "Q1", "derived_from") in edges
+ assert ("A1.r0.delta", "A1", "cell_of") in edges
+
+
+# --------------------------------------------------------------------------
+# 片段接口:出处链
+# --------------------------------------------------------------------------
+
+
+async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and_seal(client):
+ made = await sealed_run(TEXT)
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
+ chain = body["chain"]
+ assert [s["step"] for s in chain] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query",
+ "analysis_recompute", "analysis_seal"]
+ judged, method, calc, given, query, again, seal = chain
+ assert judged["phrase"] == made["result"]["phrase"] and judged["criterion"] == "一段占总变化一半以上算主要来源"
+ assert judged["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
+ assert judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True and judged["eid_ok"] is True
+ assert judged["origin"] == "card" and judged["item"] == "drop" and judged["label"] == "分析"
+ assert judged["hypothesis"] == "地区拖累" and judged["hypothesis_note"].startswith("Agent 写的假设")
+ assert method["method"] == "decompose.additive@1" and method["method_label"] == "变化拆解(可加)"
+ assert {s["key"]: s["label"] for s in method["settings"]}["main_share"] == "「主要来源」的占比"
+ assert {s["key"] for s in method["settings"]} >= {"main_share", "concentration", "table_top_k"}
+ assert "k_warn" not in {s["key"] for s in method["settings"]}
+ assert calc["table"]["columns"][0] == "region" and calc["table"]["labels"]["share"] == "占变化的比重"
+ assert calc["table"]["total_rows"] == 4 and calc["table"]["rows"][0][:2] == ["华东", "820"]
+ assert {f["label"]: f["text"] for f in calc["formulas"]}["占比"] == \
+ "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ -194 = 61.9%"
+ assert given == {"step": "analysis_input", "of": "A1", "ref": "Q2", "role": "data", "role_label": "数据",
+ "artifact": made["query"], "columns": ["region", "week", "gmv"], "rows": "all", "query": "Q1",
+ "node_id": "fetch", "sealed": True}
+ assert query["alias"] == "Q1" and query["input_of"] == "A1" and query["total_rows"] == 8
+ assert query["highlight"]["cols"] == ["region", "week", "gmv"]
+ assert again["status"] == "failed" and again["text"] == "复算失败:系统里还没有这种分析的复算实现"
+ assert seal["sealed"] is True and seal["ledger"] is True and seal["hash_ok"] is True
+ assert body["seal"]["covered"] is True
+ assert body["note"].startswith("判定语由系统按平台的方法算出判定")
+ assert body["segment"]["kind"] == "analysis"
+
+
+async def test_named_values_and_result_cells_point_at_what_they_cite(client, monkeypatch):
+ monkeypatch.setattr(analysis, "recompute", lambda result, load, *, load_result=None: dict(result))
+ made = await sealed_run(TEXT)
+ value = (await client.get(
+ f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1.top_share|pct')}")).json()
+ assert value["chain"][0]["cited"] == {"what": "value", "key": "top_share", "raw": "0.6186", "conv": "pct"}
+ calc = value["chain"][2]
+ assert next(v for v in calc["values"] if v["key"] == "top_share") == {
+ "key": "top_share", "raw": "0.6186", "rendered": "0.6186", "cited": True}
+ assert value["chain"][-2]["text"] == "复算一致:按封存的输入重新计算,结论逐项相同"
+ assert value["note"] == "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写"
+
+ cell = (await client.get(
+ f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'v:A1.r0.delta')}")).json()
+ assert cell["chain"][0]["cited"] == {"what": "cell", "row": 0, "column": "delta", "raw": "-120"}
+ assert cell["chain"][2]["table"]["highlight"] == {"cells": [[0, "delta"]]}
+ assert cell["note"] == "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写"
+
+
+async def test_the_recompute_gets_the_sealed_inputs_by_hash(client, monkeypatch):
+ seen: dict[str, Any] = {}
+
+ def recompute(result, load, *, load_result=None):
+ seen["input"] = load(result["inputs"][0]["artifact"])
+ seen["missing"] = load("0" * 64)
+ return {**result, "method": "decompose.additive@2"}
+
+ monkeypatch.setattr(analysis, "recompute", recompute)
+ made = await sealed_run(TEXT)
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
+ again = next(s for s in body["chain"] if s["step"] == "analysis_recompute")
+ assert again["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同"
+ assert seen["input"]["rows"] == SNAPSHOT["rows"] and seen["input"]["node_id"] == "fetch"
+ assert seen["missing"] is None
+
+
+async def test_recompute_errors_do_not_break_the_panel(client, monkeypatch):
+ def broken(result, load, *, load_result=None):
+ raise ZeroDivisionError("division by zero")
+
+ monkeypatch.setattr(analysis, "recompute", broken)
+ made = await sealed_run(TEXT)
+ response = await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")
+ assert response.status_code == 200
+ again = next(s for s in response.json()["chain"] if s["step"] == "analysis_recompute")
+ assert again["status"] == "failed" and again["text"].startswith("复算失败:")
+
+
+async def test_a_tampered_result_is_said_and_not_shown(client):
+ # 工件按内容寻址:改的是这一次独有的那份结论(别的测试的结论内容相同、是同一个文件)
+ q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot")
+ own = fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data"}], criterion=f"只给这一次:{uuid.uuid4().hex}")
+ made = await sealed_run(TEXT, result=own)
+ path = artifact_store._path_of(made["analysis"])
+ path.write_text(path.read_text("utf-8").replace("华东", "华南"), "utf-8")
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
+ judged, seal = body["chain"]
+ assert judged["note"] == "分析结论与哈希不一致,疑似被修改,不展示其中的判定和数" and "phrase" not in judged
+ assert seal["hash_ok"] is False and seal["sealed"] is False and body["seal"]["covered"] is False
+
+
+async def test_agent_analyses_are_marked_as_such(client):
+ made = await sealed_run(TEXT, item=None, call_id="call-9")
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
+ assert body["chain"][0]["origin"] == "agent" and body["chain"][0]["call_id"] == "call-9"
+
+
+async def test_headline_inputs_show_the_closure_and_drill_downs_the_parent(client):
+ base = fake_result()
+ made = await sealed_run(TEXT)
+ q, a = made["query"], made["analysis"]
+ child = fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data", "columns": ["region", "gmv"],
+ "rows": [0, 1]},
+ {"ref": "Q2.r1.gmv", "artifact": q, "role": "headline_target", "row": 1,
+ "column": "gmv", "value": "2026"},
+ {"ref": "A3", "artifact": a, "role": "parent"}],
+ values={**base["values"], "target_total": "2026"})
+ drilled = await sealed_run("[[a:A1]]。", result=child)
+ body = (await client.get(f"/api/runs/{drilled['run']}/evidence/segments/{seg_id(drilled['doc'], 'a:A1')}")).json()
+ inputs = [s for s in body["chain"] if s["step"] == "analysis_input"]
+ assert [s["role"] for s in inputs] == ["data", "headline_target", "parent"]
+ assert inputs[1]["closure"] == {"total_key": "target_total", "total": "2,026", "headline": "2,026", "closed": True}
+ assert inputs[2]["phrase"] == base["phrase"] and inputs[2]["role_label"] == "上一层拆解"
+ query = next(s for s in body["chain"] if s["step"] == "query")
+ assert [0, "region"] in query["highlight"]["cells"] and [1, "gmv"] in query["highlight"]["cells"]
+
+
+async def test_the_metric_step_shows_the_comparability(client):
+ card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [
+ {"id": "wow", "name": "环比", "value": -0.087, "unit": "", "expression": "1", "inputs": [],
+ "comparability": {"artifact": "", "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据",
+ "reasons": ["本期只有 3 天数据"]}}]}
+ made = await sealed_run("环比 [[m:wow]]。", card=card)
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'm:wow')}")).json()
+ assert body["chain"][0]["comparability"] == {
+ "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", "reasons": ["本期只有 3 天数据"],
+ "artifact": None, "hash_ok": None, "sealed": False}
+
+
+# --------------------------------------------------------------------------
+# 审计
+# --------------------------------------------------------------------------
+
+
+async def test_the_audit_names_analysis_evidence_and_lists_claim_violations(client):
+ violation = {"code": "analysis_claim_mismatch", "message": "A1 判的是变化主要来自华东,不能写「主要来自华南」",
+ "segment": "s0", "unit": "u0", "span": [0, 4], "text": "主要来自"}
+ made = await sealed_run(TEXT, doc_extra={"violations": [violation]})
+ body = (await client.get(f"/api/runs/{made['run']}/evidence/audit")).json()
+ rows = [r for g in body["groups"] for r in g["rows"]]
+ phrase = next(r for r in rows if r.get("ref") == "a:A1")
+ assert phrase["kind"] == "analysis" and phrase["evidence_kind"] == "analysis" and phrase["sealed"] is True
+ assert phrase["evidence"].startswith("变化拆解(可加) · ")
+ value = next(r for r in rows if r.get("ref") == "a:A1.top_share|pct")
+ assert value["evidence"] == "A1.top_share" and value["kind"] == "number"
+ cell = next(r for r in rows if r.get("ref") == "v:A1.r0.delta")
+ assert cell["evidence"] == "A1.r0.delta" and cell["evidence_kind"] == "analysis" and cell["sealed"] is True
+ flagged = next(r for r in rows if r.get("issue") == "analysis_claim_mismatch")
+ assert flagged["group"] == "none" and flagged["kind"] == "violation"
+
+ text = (await client.get(f"/api/runs/{made['run']}/evidence/audit?format=csv")).text
+ table = list(csv.reader(io.StringIO(text.lstrip(""))))
+ head = table[0]
+ lines = [dict(zip(head, line)) for line in table[1:]]
+ assert any(r["种类"] == "分析判定" and r["证据种类"] == "分析结论" for r in lines)
+ assert any(r["问题"] == "判断与分析结论不符" for r in lines)
+
+
+def test_audit_labels_cover_the_new_violation_codes():
+ from app.api.evidence import _CSV_LABELS
+
+ issues = _CSV_LABELS["issue"]
+ assert issues["analysis_claim_uncited"] == "判断词未挂分析结论"
+ assert issues["analysis_claim_mismatch"] == "判断与分析结论不符"
+ assert issues["causal_unsupported"] == "因果说法没有依据"
+ assert issues["inline_see"] == "依据写在句子中间" and issues["absence_claim"] == "缺失断言"
+
+
+# --------------------------------------------------------------------------
+# 报告导出
+# --------------------------------------------------------------------------
+
+SCOPE = {"analysis_scope": {
+ "phenomena": [{"alias": "A1", "op": "decompose", "phrase": "销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)"}],
+ "decomposed": [{"alias": "A1", "op": "decompose", "mode": "additive", "dims": ["地区"], "verdict": "driver",
+ "phrase": "主要来自华东"}],
+ "excluded": [], "refused": [{"alias": "A1", "op": "baseline", "reason": "历史只有 5 期,至少要 8 期"}],
+ "not_decomposed": ["渠道"], "tests": 2}}
+
+
+def data_block(html: str) -> dict[str, Any]:
+ raw = re.search(r'', html, re.S).group(1)
+ return json.loads(raw)
+
+
+async def test_the_html_export_carries_the_analysis_chain_and_the_scope(client):
+ made = await sealed_run(TEXT, doc_extra=SCOPE)
+ response = await client.get(f"/api/runs/{made['run']}/report.html")
+ assert response.status_code == 200
+ html = response.text
+ data = data_block(html)
+ phrase = seg_id(made["doc"], "a:A1")
+ chain = data["chains"][data["refs"][phrase]]
+ assert [s["step"] for s in chain][:3] == ["analysis", "analysis_method", "analysis_calc"]
+ assert data["notes"][phrase].startswith("判定语由系统按平台的方法算出判定")
+ # 分析范围:正文后面渲染,编号点得开、和点开判定语是同一条链
+ assert '现象" in html and "拆过" in html
+ assert "算不了" in html and "渠道" in html and "这次做了 2 次差异检验" in html
+ assert 'data-seg="scope:A1"' in html and "(按地区)" in html
+ assert data["refs"]["scope:A1"] == data["refs"][phrase]
+ assert f'data-seg="{phrase}"' in html and 'data-kind="analysis"' in html
+
+
+async def test_docs_without_a_scope_render_no_scope(client):
+ made = await sealed_run(TEXT)
+ html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
+ assert '