合入 W1-b 证据接入:台账 A、写作目录、a / v:A / table:A / see:A 引用、出处链与复算、证据图、审计、导出与可验证报告、裁判摘录、账本跳过

This commit is contained in:
ThearchyHelios
2026-10-21 13:58:18 +08:00
18 changed files with 3522 additions and 112 deletions
+23 -3
View File
@@ -53,6 +53,26 @@ METHODS: dict[tuple[str, str | None], str] = {
}
#: agent 分析工具的名字(tools/builtin 里注册),一个算子一个工具:参数各自聚焦,模型少写错
TOOL_NAMES: dict[str, str] = {op: f"analysis_{op}" for op in OPS}
#: 算子、模式给人看的叫法:证据面板、写作目录、审计、导出、可验证报告、裁判摘录都用它(method_label 拼成一句)
OP_LABELS: dict[str, str] = {
"decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势", "differ": "差异检验", "comparable": "可比性检查",
}
MODE_LABELS: dict[str, str] = {
"additive": "可加", "ratio": "比率", "factor": "乘积", "rate": "比率", "mean": "均值", "rows": "逐行明细",
}
def method_label(op: Any, mode: Any = None) -> str:
"""「变化拆解(可加)」「波动基线」。认不出的算子、模式原样写出来,不编叫法。"""
name = OP_LABELS.get(str(op or ""), str(op or ""))
return f"{name}({MODE_LABELS.get(str(mode), str(mode))})" if mode else name
def is_result(content: Any) -> bool:
"""一件工件的内容是不是分析结论:按形状认(op 是五个算子之一、method 是文字、inputs 是列表)。证据接入取回 ARTIFACT_KIND
的工件、SQL 问题顺着输入往下找、可验证报告认嵌进来的工件,都按这一个函数认。"""
return isinstance(content, Mapping) and content.get("op") in OPS and isinstance(content.get("method"), str) \
and isinstance(content.get("inputs"), list)
#: 每个算子可能给出的判定(细案 01 §2–§6)
VERDICTS: dict[str, tuple[str, ...]] = {
@@ -391,8 +411,8 @@ def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
__all__ = [
"ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "DEFAULTS", "LABELS", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES",
"NODE_TYPE", "NOT_RECOMPUTED", "OPS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES", "VERDICTS",
"AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot",
"MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES",
"VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot",
"MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result",
"effective_settings", "ledger_entry", "same", "settings_problem",
"effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem",
]
+416 -15
View File
@@ -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,9 @@ from app.engine.evidence import (
UNKNOWN_ENTITY_REASON,
UNVERIFIED_ENTITY_REASON,
RenderError,
analysis_card,
analysis_formulas,
analysis_recheck,
build_catalog,
closest_entities,
find_segment,
@@ -119,6 +124,7 @@ from app.engine.evidence import (
make_eid,
normalize_quote,
query_entry_fields,
render_cell,
render_metric,
schema_column,
schema_table,
@@ -127,6 +133,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 +203,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 +313,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 +530,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 +955,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 +977,272 @@ 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 = "无法读取分析结论"
#: 口径卡指标上的可比性结论不进台账(00-总方案 §3.4):它记在封存过的口径卡指标集里,卡上记着它的 sha256,属于封存链的
#: 延伸(同推演认 value_artifact)。指标那一步没封存的,这份结论也不算封存
COMPARABILITY_CHAINED = ("这份可比性结论不进台账:口径卡执行时自动做的,记在封存过的口径卡指标集里(卡上记着它的 sha256),"
"随卡一起封存")
UNSEALED_COMPARABILITY = "这份可比性结论记在口径卡上,可这张卡的指标集不在封存范围内,不能作为证据展示"
#: 每个算子用到的阈值(细案 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, *, chained: bool | None = None,
queries: bool = True) -> 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):这件工件是不是封存范围内的台账记过的、哈希复验通过
结论取不回、哈希不符的只给判定和封存两步,照实说为什么。
口径卡指标上的可比性结论(_comparability_view)不进台账:chained 是那个指标的一步封存了没有(卡上记着这份结论的 sha256,
属于封存链的延伸),封存一步照它认;谁做的写口径卡(origin metric)。queries 为假时不接输入快照的查询步骤(可比性的
两份输入就是指标输入的那两份,指标链里已经有了)。"""
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 \
if chained is None else sealed.trusted and chained
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": "metric" if chained is not None else "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 chained is not None:
# 封存的路子是口径卡的指标集(卡上记着这份结论的 sha256),不是台账
seal.update(via="metric_set", note=COMPARABILITY_CHAINED if chained else UNSEALED_COMPARABILITY)
elif 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, steps = await _analysis_inputs(alias, result, catalog, doc, sealed, masks, queries=queries)
hidden = {str(c).lower() for q in steps 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, *steps, 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, *, queries: bool = True
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""输入:每份一步(analysis_input),再给每份输入快照一个查询步骤(_query_steps,高亮用到的列;整份用到的给前
MAX_WINDOW_ROWS 行;queries 为假时不给)。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)
found: list[dict[str, Any]] = []
for artifact, picked in cells.items() if queries else ():
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):
found.append({**sub, "input_of": alias})
return steps, found
async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]:
"""复算(evidence.analysis_recheck):输入快照交工件原内容,下钻的上一层交那份结论,都按 sha256 取回、复验哈希,在线程里
复算、比较。只认封存范围内的——输入快照要是这次运行交回过的查询(sealed.queries),上一层要是台账记过的分析结论;
不在范围内、哈希对不上的一律当取不回(复算照实说取不回哪一份)。"""
snapshots = {str(i["artifact"]) for i in result.get("inputs") or []
if isinstance(i, dict) and i.get("artifact") and i.get("role") != "parent"}
parents = {str(i["artifact"]) for i in result.get("inputs") or []
if isinstance(i, dict) and i.get("artifact") and i.get("role") == "parent"}
def load(artifact: str) -> Any:
if artifact not in snapshots or artifact not in sealed.queries:
return None
content, ok = _fetch(artifact)
return content if ok is True else None
def load_result(artifact: str) -> Any:
return _analysis_of(artifact)[0] if artifact in parents and artifact in sealed.analyses else None
return await asyncio.to_thread(analysis_recheck, result, load, load_result=load_result)
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 +1297,12 @@ 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"] = await _comparability_view(
metric["comparability"], sealed, masks, chained=step["sealed"], node_id=str(entry.get("node_id") or ""),
doc=doc)
card_node = str(entry.get("node_id") or "")
if metric is not None and metric.get("inputs"):
# 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说
@@ -1047,6 +1343,36 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
return [step, *inputs, *upstream, *queries]
async def _comparability_view(comparability: dict[str, Any], sealed: _Sealed, masks: _Masks, *, chained: bool,
node_id: str, doc: dict[str, Any]) -> dict[str, Any]:
"""指标步骤里的可比性:判定(comparable / conditional / incomparable)、判定语、原因(一句一个),连同那份可比性结论的
工件:取回、复验哈希,带上它的判定标准、方法和现查结果,再给一条点得开的出处链(steps:判定 → 方法 → 计算 → 输入 →
复算 → 封存,同分析结论的引用,_analysis_chain;两份输入快照的查询步骤指标链里已经有了,不再给)。
这份结论不进台账,只从口径卡的指标集里引用:sealed 认的是哈希复验通过,而且记着它的指标集封存了(chained:指标步骤
本身封存了;内容寻址,卡上记着它的 sha256——同推演认 value_artifact),或者它恰好也在封存范围内的台账里。"""
artifact = str(comparability.get("artifact") or "")
result, hash_ok = _analysis_of(artifact)
scoped = sealed.trusted and bool(artifact) and (chained or artifact in sealed.analyses)
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": scoped and hash_ok is True,
}
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)])
if artifact:
entry = {"kind": LEDGER_KIND, "artifact": artifact, "node_id": node_id, "op": "comparable", "mode": None,
"eid": make_eid(LEDGER_KIND, artifact, {})}
cite = {"kind": LEDGER_KIND, "alias": None, "locator": {}, "status": "resolved"}
view["steps"] = await _analysis_chain({"text": str(comparability.get("phrase") or "")}, cite, entry, doc,
sealed, masks, chained=scoped, queries=False)
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 +3231,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 +3383,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 +4108,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 +4371,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 +4648,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 +4686,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 +4898,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 "")
+138 -7
View File
@@ -23,6 +23,14 @@
规则同上):点开数字时看得到每个输入的 SQL 和被引用的行,引用链(④)按「已核对的口径卡的输入」认它们。只给 id 的
工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被
报告引用、也没被附带的口径卡的输入引用的工件不嵌。
- 分析结论([[a:A3]]、[[v:A3.r0.列]]、[[see:A3]] 引用的结论工件)照样按原字节嵌入,连同它的输入快照(遮罩规则同上)和
下钻的上一层结论;任一份输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id——结果表里的维度值取自输入快照,
这份文件宁可多挡。浏览器只核哈希、核判定语和数是不是结论里写的那些,不在页面里重算统计:结论随文件附带时,服务端在
导出时按封存的输入复算一遍(engine/evidence.analysis_recheck),结果记在这份工件的条目上(recheck),页面照它展示。
口径卡指标上的可比性结论(口径卡执行时自动做的,不进台账、只从卡上引用)随附带的卡一起附上,规则同上,引用链(④)
按「已核对的口径卡记着的可比性」认它。分析工具的 tool.end(预览里是结论的前一截)、分析卡一项的 analysis.end(带着
判定语)和结论本身同一个认法:结论随文件附带得了才带规范字符串。
- 报告文档带着系统写的「分析范围」(analysis_scope)的,页面在正文后面照它渲染,编号点得开那条结论。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次
@@ -55,9 +63,10 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote
from app.analysis.contract import ARTIFACT_KIND, LEDGER_KIND, NODE_TYPE as ANALYSIS_NODE, is_result
from app.core.artifact_store import canonical_json, content_hash
from app.core.events import EventType
from app.engine.evidence import iter_units, query_entry_fields
from app.engine.evidence import analysis_recheck, iter_units, query_entry_fields
from app.engine.lineage import in_scope, scoped_id
from app.engine.masking import OPAQUE_SQL, SYSTEM_SCHEMAS, indirect_reads
from app.engine.merge_query import MERGE_SOURCE
@@ -84,7 +93,11 @@ _SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, Even
#: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文,文档只给哈希的不带)、口径卡(指标集的 id
#: 记在台账里;卡上有值不给的指标、有输入取自遮罩列的不带,见 _card_shown)。成果节点在运行里没有带遮罩的快照时带
#: 目录条目的种类 → 嵌进来的工件种类。运行输入、检索、表和字段的条目不嵌:前者没有工件,后几样不在核对范围里
_CITED_KINDS = {"metric": "metric_set", "query": "query"}
_CITED_KINDS = {"metric": "metric_set", "query": "query", LEDGER_KIND: ARTIFACT_KIND}
#: 下钻的上一层结论最多跟几层(同分析设置里下钻层数的上限)
_PARENT_DEPTH = 6
#: 分析卡里一项做完的事件(core/events.EventType.ANALYSIS_END):带着那一项的判定、判定语和结论工件(analysis_artifact)
_ANALYSIS_END = "analysis.end"
#: 没挂在工作流上的运行(画布、问数据当场搭的):老数据叫「临时图」,新的叫「未保存的工作流」,也可能是空的。
#: 和前端 lib/terms.ts 的 runName 同一个规则
@@ -202,6 +215,22 @@ class _Store:
self._seen = seen
return self._seen
def analysis_masked(self, artifact: str, depth: int = 0) -> bool:
"""这份分析结论算不算带遮罩(只给 id):结果表、判定语里的维度值取自它的输入快照。遮罩列不能当维度、度量
(app/analysis 在调用时就挡下),可这份文件按自己宁可多挡的认法:任一份输入快照证明不了没碰遮罩列(masked),
结论也只给 id;下钻的上一层结论同样认(最多 _PARENT_DEPTH 层)。读得出字节、却认不出是一份分析结论的,按带遮罩算
(同口径卡)。"""
key = f"analysis:{artifact}"
if key not in self._masked:
result = self.content(artifact)
if not is_result(result):
self._masked[key] = self.raw(artifact) is not None
return self._masked[key]
self._masked[key] = any(
(self.analysis_masked(a, depth + 1) if depth < _PARENT_DEPTH else True) if role == "parent"
else self.masked(a) for a, role in _analysis_inputs(result))
return self._masked[key]
def card_masked(self, artifact: str, events: Sequence[tuple[int, str, str | None, Any]]) -> bool:
"""这张口径卡算不算带遮罩:卡上有一个输入取自遮罩列,或者遮没遮核对不了。卡上记着每个输入的值(cell() 取的格、
Agent 字段、按位置取的 vars.res.rows[0][2])和代入式,整张卡不随文件附带——没法只拿掉几个值,拿掉了哈希就对不上。
@@ -1003,10 +1032,11 @@ def _report_events(events: Iterable[tuple[int, str, str | None, Any]]) -> list[d
def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
"""报告正文引用到的口径卡和查询快照:(工件 id, 种类, 来源节点),按正文顺序、去重。
"""报告正文引用到的口径卡、查询快照和分析结论:(工件 id, 种类, 来源节点),按正文顺序、去重。
看每个解析成功的引用(行内的数字、单元格,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡,
查询条目指向快照。整表在组装时已经展开成每格一个单元格引用,不用另看。"""
看每个解析成功的引用(行内的数字、单元格、判定语,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡,
查询条目指向快照,分析结论条目指向结论工件。整表在组装时已经展开成每格一个单元格引用,不用另看。报告文档里系统写的
「分析范围」点名的结论也算(页面上点得开它们)。"""
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
out: list[tuple[str, str, str | None]] = []
for _, unit in iter_units(doc):
@@ -1021,6 +1051,34 @@ def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
if kind and isinstance(entry.get("artifact"), str) and entry["artifact"]:
owner = entry.get("node_id")
out.append((entry["artifact"], kind, str(owner) if owner else None))
scope = doc.get("analysis_scope") if isinstance(doc.get("analysis_scope"), dict) else {}
for group in ("phenomena", "decomposed", "excluded", "refused"):
for item in scope.get(group) or []:
entry = catalog.get(item.get("alias")) if isinstance(item, dict) else None
if isinstance(entry, dict) and entry.get("kind") == LEDGER_KIND and isinstance(entry.get("artifact"), str) \
and entry["artifact"]:
owner = entry.get("node_id")
out.append((entry["artifact"], ARTIFACT_KIND, str(owner) if owner else None))
return list(dict.fromkeys(out))
def _analysis_inputs(result: Any) -> list[tuple[str, str]]:
"""一份分析结论记着的输入:(工件 id, 角色),按先后、按工件去重。角色是 parent 的是下钻的上一层结论,别的是查询快照。"""
out: dict[str, str] = {}
for item in (result.get("inputs") or []) if isinstance(result, dict) else []:
if isinstance(item, dict) and isinstance(item.get("artifact"), str) and item["artifact"]:
out.setdefault(item["artifact"], "parent" if item.get("role") == "parent" else "data")
return list(out.items())
def _card_comparability(card: Any) -> list[str]:
"""口径卡指标上记着的可比性结论(contract.MetricComparability 的 artifact),按先后、去重。这份结论不进台账,只从卡上
引用:卡随文件附带时一并附上,引用链(④)按「已核对的口径卡记着的可比性」认它。"""
out: list[str] = []
for metric in (card.get("metrics") or []) if isinstance(card, dict) else []:
found = metric.get("comparability") if isinstance(metric, dict) else None
if isinstance(found, dict) and isinstance(found.get("artifact"), str) and found["artifact"]:
out.append(found["artifact"])
return list(dict.fromkeys(out))
@@ -1085,7 +1143,8 @@ class _Artifacts:
# 服务端的文件不在了:只给 id,浏览器照实说核对不了
item["missing"] = True
elif masked or (kind == "query" and self.store.masked(artifact)) \
or (kind == "metric_set" and self.store.card_masked(artifact, self.events)):
or (kind == "metric_set" and self.store.card_masked(artifact, self.events)) \
or (kind == ARTIFACT_KIND and self.store.analysis_masked(artifact)):
item["masked"] = True
# 不嵌内容,浏览器就算不出这份文件对不对得上:服务端自己看到的照实带上,核对页据此判不通过、说清原因。
# 以前被改过的卡原样嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢
@@ -1101,6 +1160,40 @@ class _Artifacts:
return item
def _attach_analysis(artifacts: _Artifacts, store: _Store, item: dict[str, Any] | None, artifact: str,
owner: str | None, cited_queries: list[str], depth: int = 0) -> None:
"""随文件附带的分析结论:它的输入快照(种类 query,遮罩照常认)、下钻的上一层结论(同样附带它的输入,最多
_PARENT_DEPTH 层)一并列进清单,再把服务端的复算结论记在这份结论的条目上(recheck)。只给 id 的结论、被改过的
(哈希对不上)不顺着它去附带别的:被改过的结论说输入是哪几份,不能替它圆。"""
if item is None or "b64" not in item or not store.intact(artifact) or "recheck" in item:
return
result = store.content(artifact)
if not is_result(result):
return
for input_id, role in _analysis_inputs(result):
if role == "parent":
if depth < _PARENT_DEPTH:
parent = artifacts.add(input_id, ARTIFACT_KIND, owner)
_attach_analysis(artifacts, store, parent, input_id, owner, cited_queries, depth + 1)
continue
artifacts.add(input_id, "query", owner)
cited_queries.append(input_id)
item["recheck"] = _recheck(result, store)
def _recheck(result: dict[str, Any], store: _Store) -> dict[str, Any]:
"""导出时服务端按封存的输入把这份分析结论复算一遍(engine/evidence.analysis_recheck,和证据面板同一个函数):输入快照、
上一层结论只认这份文件里哈希对得上的原字节。页面照这个结果写「导出时服务端的复算」,不在浏览器里重算统计。"""
def load(artifact: str) -> Any:
return store.content(artifact) if store.intact(artifact) else None
def load_result(artifact: str) -> Any:
content = store.content(artifact) if store.intact(artifact) else None
return content if is_result(content) else None
return analysis_recheck(result, load, load_result=load_result)
# --------------------------------------------------------------------------
# 事件
# --------------------------------------------------------------------------
@@ -1121,6 +1214,8 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi
kind = node_types.get(node_id or "")
if kind == NodeType.METRICS:
return _card_shown(data, store, masked_run, events, forced)
if kind == ANALYSIS_NODE:
return _analysis_shown(data, store)
if kind == NodeType.REPORT:
# 预览就是那份文档正文的前一截:文档只给哈希的,这条也只给哈希
preview = data.get("preview") if isinstance(data, dict) else None
@@ -1130,10 +1225,24 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi
if etype == EventType.TOOL_END:
# 查询类:把查询快照的 id 和运行记录连起来。预览里是查询结果的前一截,数据源带遮罩的只给哈希
artifact = data.get("query_artifact") if isinstance(data, dict) else None
return isinstance(artifact, str) and bool(artifact) and not store.masked(artifact)
if isinstance(artifact, str) and artifact:
return not store.masked(artifact)
# 分析工具:预览里是结论的前一截(判定语、具名的数、结果表),和结论本身同一个认法
found = data.get("analysis_artifact") if isinstance(data, dict) else None
return isinstance(found, str) and bool(found) and _result_shown(found, store)
if etype == _ANALYSIS_END:
# 分析卡的一项:带着判定、判定语,结论随文件附带得了(输入没碰遮罩列)才带
found = data.get("analysis_artifact") if isinstance(data, dict) else None
return isinstance(found, str) and bool(found) and _result_shown(found, store)
return False
def _result_shown(artifact: str, store: _Store) -> bool:
"""一份分析结论的内容能不能出现在页面上(事件里带着它的判定语、预览):文件在、认得出是分析结论、输入都没碰遮罩列
(_Store.analysis_masked)。和这份结论随文件附带时同一个认法。"""
return store.raw(artifact) is not None and not store.analysis_masked(artifact)
def _card_shown(data: Any, store: _Store, masked_run: bool, events: Sequence[tuple[int, str, str | None, Any]],
forced: frozenset[str] = frozenset()) -> bool:
"""口径卡的节点完成事件带不带规范字符串。预览里逐条写着「指标(名称)= 值」(metrics 节点的 text),赋了变量的另有
@@ -1160,6 +1269,18 @@ def _card_shown(data: Any, store: _Store, masked_run: bool, events: Sequence[tup
return not any(store.card_masked(card, events) for card in known)
def _analysis_shown(data: Any, store: _Store) -> bool:
"""分析卡的节点完成事件带不带规范字符串。产出是每一项的判定、具名的数、判定语(results),台账里记着每份结论的 id:
这几份结论都随文件附带(输入没碰遮罩列、文件在)才带,有一份只给 id、读不出来,或者一份都没记的,这条只给哈希——
和结论本身同一个认法(_Store.analysis_masked),预览里的维度值不比结论里的多。"""
if not isinstance(data, dict):
return False
results = [str(e["artifact"]) for e in data.get("evidence") or []
if isinstance(e, dict) and e.get("kind") == LEDGER_KIND and isinstance(e.get("artifact"), str)
and e["artifact"]]
return bool(results) and all(_result_shown(a, store) for a in results)
def _masked_cards(doc: dict[str, Any]) -> set[str]:
"""报告文档的证据目录里写成「已遮罩」的指标(withhold_metrics 记了 masked)所在的口径卡。"""
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
@@ -1318,12 +1439,22 @@ def build_bundle(
if kind == "query":
cited_queries.append(artifact)
continue
if kind == ARTIFACT_KIND:
# 分析结论随文件附带了(输入都没碰遮罩列、文件在、哈希对得上):它的输入快照、下钻的上一层结论一并附上,
# 引用链(④)按「已核对的分析结论的输入」认它们;服务端按封存的输入复算一遍,结果记在条目上(recheck)
_attach_analysis(artifacts, store, item, artifact, owner, cited_queries)
continue
# 口径卡随文件附带了(没遮、文件在):它记着的输入——哪个指标的值取自哪次查询的哪一格——
# 那几次查询的快照也一并附上(设了遮罩的照旧只给 id)。点开数字看得到输入的 SQL 和被引用的行;
# 引用链(④)按「已核对的口径卡的输入」认它们,被改过的卡(intact 不过)不带,免得替它圆
if kind == "metric_set" and item is not None and "b64" in item and store.intact(artifact):
for query, query_owner in _card_input_queries(store.content(artifact)):
artifacts.add(query, "query", query_owner)
# 指标上的可比性结论(口径卡执行时自动做的,不进台账、只从卡上引用)同样附上:它的输入快照、服务端的复算
# 跟着它(_attach_analysis),输入带遮罩的照旧只给 id
for found in _card_comparability(store.content(artifact)):
_attach_analysis(artifacts, store, artifacts.add(found, ARTIFACT_KIND, owner), found, owner,
cited_queries)
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
shown_events = []
+3
View File
@@ -77,6 +77,9 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.doc pre code { padding: 0; background: none; }
.doc hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
.doc .link { color: var(--accent); }
.scope { margin-top: 14px; padding: 12px 16px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); }
.scope h4 { margin: 0 0 4px; font-size: 15px; }
.scope ul { margin: 0; padding-left: 1.2em; }
.table-wrap { max-width: 100%; margin: 8px 0; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
.table-wrap table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table-wrap th, .table-wrap td { padding: 5px 9px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
+246 -16
View File
@@ -19,6 +19,11 @@
* 有格的列,伪造的缩进、列号挤不走核对过的数;
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
* 绑不进封存,页面上写明它们取自运行记录。
* 分析结论(判定语 [[a:A3]]、具名的数 [[a:A3.键]]、结果表的格 [[v:A3.r0.列]])在 ③ ④ 里和查询快照一样核哈希、核引用链
* (它的输入快照按「已核对的分析结论的输入」认);⑤ 里核正文显示的判定语、数是不是结论里写的那几个字(同服务端的渲染
* 规则)。本页不重算统计:结论随文件附带时,出处面板展示导出时服务端按封存的输入复算的结论(工件条目上的 recheck)。
* 口径卡指标上的可比性结论不进台账、只从卡上引用:④ 按「已核对的口径卡记着的可比性」认它,指标的数字详情里展示它。
* 报告文档带着系统写的「分析范围」(analysis_scope)的,正文后面照它渲染,编号点得开那条结论。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、检索快照这类工件标着
* 遮罩)一律算不通过:只可能是文件被改过。报告文档的证据目录里写着取自遮罩列的指标的值时,服务端只给它的哈希
* (升级前生成的文档,或者生成之后数据源才设的遮罩),页面照实说明,这份报告算未能核对。总览只有一项无法核对都没有、
@@ -46,7 +51,10 @@
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对', partial: '部分无法核对' }
const MARK = { pass: '✓', fail: '✗', na: '?', partial: '?' }
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' }
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照', analysis_result: '分析结论' }
/** 分析结论(app/analysis):工件种类、目录条目种类、分析范围里的几组 */
const ANALYSIS = 'analysis_result'
const SCOPE_GROUPS = [['phenomena', '现象'], ['decomposed', '拆过'], ['excluded', '排除'], ['refused', '算不了']]
const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' }
const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' }
// rejected:被审批人驳回结束(运行记录 cancelled + end_reason=rejected)。封存里它和手动停止是同一种结束事件(运行取消),
@@ -60,7 +68,8 @@
'node.failed': '节点失败', 'node.skipped': '节点跳过', 'edge.taken': '分支走向', 'llm.start': '模型调用开始',
'llm.end': '模型调用结束', 'llm.thinking': '模型思考', 'tool.start': '工具调用', 'tool.end': '工具返回',
'tool.error': '工具出错', 'tool.gated': '门控判定', 'retrieve.end': '知识检索', 'memory.end': '记忆读写',
'merge.end': '合并查询', 'sandbox.start': '沙箱开始', 'sandbox.end': '沙箱结束', 'agent.step.start': 'Agent 步骤开始',
'merge.end': '合并查询', 'analysis.end': '分析卡的一项',
'sandbox.start': '沙箱开始', 'sandbox.end': '沙箱结束', 'agent.step.start': 'Agent 步骤开始',
'agent.step.end': 'Agent 步骤结束', 'agent.route.start': '调度决策开始', 'agent.route.end': '调度决策结束',
'human.requested': '请求人工审批', 'human.resolved': '人工审批结果', 'approval.inquiry': '审批质询',
'issuance': '出具判定', 'caliber.upgrade': '口径卡升版处置', 'catalog.drift': '数据目录变化',
@@ -111,7 +120,7 @@
sumEvents: (n) => `${n} 条带内容的事件;其余事件只附哈希,参与 ② 的清单核对`,
sumManifest: (n) => `按 ${n} 条事件的哈希重算`,
sumCount: (n, unit) => `${n} ${unit}`,
sumNumbers: (numbers, values) => `数字 ${numbers} 个 · 取值 ${values} 个`,
sumNumbers: (numbers, values, phrases = 0) => `数字 ${numbers} 个 · 取值 ${values} 个${phrases ? ` · 判定语 ${phrases} 个` : ''}`,
sumMeta: '与封存范围内已核对的事件对照',
showAll: (n) => `展开全部 ${n} 项`,
eventMismatch: '内容与记录的哈希不一致',
@@ -136,13 +145,16 @@
maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带',
/** 报告文档只给哈希:证据目录里写着值取自遮罩列的指标的原值(升级前的报告撰写节点生成的,或者生成之后数据源才设的遮罩) */
maskedDoc: '证据目录里写着取自遮罩列的指标的值(生成时没有按遮罩换掉),未随文件附带,只能核对到哈希',
maskedOdd: '只有查询快照、口径卡和报告文档会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
maskedOdd: '只有查询快照、口径卡、分析结论和报告文档会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
/** 分析结论只给哈希:它的输入快照有一份证明不了没碰设了遮罩的列(结果表里的维度值取自输入快照) */
maskedAnalysis: '这份分析结论的输入查询用到或可能用到设了遮罩的列,结论未随文件附带,只能核对到哈希',
artifactMissing: '导出时服务端已没有这份工件的文件',
artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过',
chainDoc: (label) => `报告「${label}」的文档`,
chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`,
chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`,
chainByCard: (label, metric) => `口径卡「${label}」的输入(指标 ${metric})`,
chainByAnalysis: (label) => `分析结论(${label})的输入`,
chainFrom: '引用自',
chainNoCheckEvent: '找不到这个报告节点的报告自查事件',
chainCheckEventFailed: '报告自查事件的内容没有通过核对',
@@ -159,6 +171,9 @@
srcCell: (alias, row, column) => `查询 ${alias} · 第 ${row + 1} 行 · ${column}`,
srcQuery: (alias) => `查询 ${alias}`,
srcInput: (field) => `运行输入「${field}」`,
srcAnalysis: (alias) => `分析结论 ${alias} 的判定语`,
srcAnalysisValue: (alias, key) => `分析结论 ${alias} 的具名的数「${key}」`,
srcAnalysisCell: (alias, row, column) => `分析结论 ${alias} 的结果表 · 第 ${row + 1} 行 · ${column}`,
srcNone: '没有出处',
srcOther: '其他引用',
numUncited: '该数字未使用引用标记,无法核对其来源',
@@ -175,6 +190,11 @@
numMetricDiffers: '引用的值与口径卡里的值不一致',
numNoCell: (why) => `快照里找不到这一格:${why}`,
numCellDiffers: '引用的值与快照里那一格的值不一致',
numNoPhrase: '分析结论里没有判定语',
numNoValue: (key) => `分析结论里没有名为「${key}」的数`,
numValueDiffers: '引用的值与分析结论里记的值不一致',
numNoTable: '分析结论里没有结果表',
numAnalysisCell: '引用的值与分析结论结果表里那一格的值不一致',
/** cited 已经加好引号(空值写「空值(NULL)」);expected 和引用的值写法一样时不写(文字取值) */
numShownDiffers: (shown, cited, expected) => `正文显示「${shown}」,引用的值是${cited}`
+ (expected == null ? '' : `,按显示规则应显示为「${expected}」`),
@@ -206,7 +226,7 @@
shownLookalike: '紧挨着长得像数字的字母或符号(l、I、O、上标数字、圈码这类),读起来可能多出一位',
shownOdd: '紧挨着这个数的字不在常见写法里,无法确认读者看到的数',
numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过',
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : kind === 'analysis' ? '判定语' : '数字'} ${text},${result},查看出处`,
// 数字详情里口径卡一节:指标、计算方式(原式、代入式)、输入、复算。遮罩未附带的卡没有这一节(原因见核对明细 ③)
dMetric: '指标', dUnit: '单位', dExpression: '原式(计算方式)', dSubstituted: '代入式(代入输入后的算式)',
dInputs: '输入', dRecompute: '复算', dSourceName: '数据源', dInputValue: '取值', dFrom: '取自',
@@ -236,6 +256,19 @@
dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值',
dCell: '快照里的值', dShown: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为',
dArtifact: '所在工件', dReport: '所在报告',
dPhrase: '判定语', dCriterion: '判定标准', dMethod: '方法与版本', dAnalysis: '分析结论里的值', dRecheck: '复算',
dAnalysisInputs: '输入', dHypothesis: 'Agent 写的假设', dCaveats: '附带说明',
recheckBy: '导出时服务端按封存的输入复算的结论;本页只核哈希,不在浏览器里重算统计',
recheckNone: '导出时没有复算(结论未随文件附带)',
analysisInput: (role, alias) => `${role}${alias ? ` · ${alias}` : ''}`,
// 分析结论输入的角色(analysis.contract.AnalysisInput.role),同证据面板的叫法
roleLabel: { data: '数据', base: '基期', target: '本期', headline_base: '基期总数', headline_target: '本期总数',
parent: '上一层拆解' },
dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' },
dJudged: '判定', dChecks: '各项检查', comparabilityNotAttached: '可比性结论未随文件附带(见核对明细 ③ ④),这里按口径卡的记录展示',
scopeTitle: '分析范围', scopeNote: '这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定语、判定标准、输入和复算。',
scopeMissing: '没拆', scopeAll: '候选维度都拆过了', scopeUnknown: '候选维度核对不了', scopeTests: '检验',
scopeTestsText: (n) => `这次做了 ${n} 次差异检验`, scopeParent: (alias) => `下钻自 ${alias}`, scopeDims: (dims) => `按${dims}`,
dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`,
nullValue: '空值(NULL)',
appendedNote: '这些事件追加在封存之后,不在封存范围内,不参与清单核对,也不能当作证据。',
@@ -1260,7 +1293,7 @@
// 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来
// (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了。报告文档
// 只给哈希时页面上没有正文可核,总览不是绿色的通过(overallOf 按报告文档没核对上算)
if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc') {
if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc' && a.kind !== ANALYSIS) {
item.result = FAIL
item.reason = T.maskedOdd
} else if (a.mismatch) {
@@ -1268,7 +1301,7 @@
item.reason = T.maskedMismatch
} else {
item.reason = a.unparsable ? T.maskedUnparsable : a.kind === 'query' ? T.maskedQuery
: a.kind === 'report_doc' ? T.maskedDoc : T.masked
: a.kind === 'report_doc' ? T.maskedDoc : a.kind === ANALYSIS ? T.maskedAnalysis : T.masked
}
} else if (a.missing) {
item.reason = T.artifactMissing
@@ -1337,6 +1370,7 @@
if (refs.length && !out.queryAliases.has(id)) out.queryAliases.set(id, refs[0].alias)
}
out.arts = arts
out.chained = chained
// 已核对的口径卡记着的输入 → 输入指向的查询快照。卡本身核过哈希(③ 通过),它记的引用和文档的证据目录
// 同一个分量:报告只引用指标、不直接引用查询时,查询快照就是从这条路嵌进来、也是从这条路认的。
// 没核对过的卡(哈希对不上)不认:被改过的卡说输入来自哪,不能替它圆
@@ -1351,14 +1385,33 @@
}
}
}
// 指标上的可比性结论不进台账、只从卡上引用(卡记着它的 sha256):已核对的卡记着的,同卡的输入一样认
for (const metric of Array.isArray(item.json.metrics) ? item.json.metrics : []) {
const c = isObj(metric) && isObj(metric.comparability) ? metric.comparability : null
if (c && typeof c.artifact === 'string' && c.artifact) {
push(byCard, c.artifact, { card: item, metric: metric.id })
}
}
}
// 已核对的分析结论记着的输入 → 输入快照、下钻的上一层结论。同口径卡:结论本身核过哈希,它记的输入和文档的证据目录
// 同一个分量;没核对过的(哈希对不上)不认
const byAnalysis = new Map() // 工件 id → [{ analysis }]
for (const item of out.artifacts) {
if (item.a.kind !== ANALYSIS || item.result !== PASS) continue
for (const input of Array.isArray(item.json.inputs) ? item.json.inputs : []) {
if (isObj(input) && typeof input.artifact === 'string' && input.artifact && input.artifact !== item.a.id) {
push(byAnalysis, input.artifact, { analysis: item })
}
}
}
for (const item of out.artifacts) {
if (item.a.kind === 'report_doc') continue
const evs = byEvent.get(item.a.id) || []
const docs = byDoc.get(item.a.id) || []
const cards = byCard.get(item.a.id) || []
const ok = evs.length > 0 || docs.length > 0 || cards.length > 0
out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, cards, result: ok ? PASS : FAIL,
const analyses = byAnalysis.get(item.a.id) || []
const ok = evs.length > 0 || docs.length > 0 || cards.length > 0 || analyses.length > 0
out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, cards, analyses, result: ok ? PASS : FAIL,
reason: ok ? '' : T.chainUnreferenced })
if (ok) chained.add(item.a.id)
}
@@ -1371,7 +1424,7 @@
out.docs.push({ report, art, link, doc, trusted: trusted.has(report) })
if (!doc) continue
for (const [seg, path] of segmentsOf(doc)) {
if (seg.kind !== 'number' && seg.kind !== 'value') continue
if (seg.kind !== 'number' && seg.kind !== 'value' && seg.kind !== 'analysis') continue
// 引用的值在文档 JSON 里的原文:超过 2^53 的整数照原文写进原因和出处面板
const citedToken = () => tokenAt(tokensOf(art), [...path, 'cite', 'value'])
const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained, citedToken)
@@ -1394,10 +1447,16 @@
const entry = own(doc.catalog, cite.alias)
const loc = isObj(cite.locator) ? cite.locator : {}
if (cite.kind === 'metric') return T.srcMetric(own(entry, 'name') || loc.metric || cite.ref || '')
if (cite.kind === 'cell' && own(entry, 'kind') === 'analysis' && Number.isInteger(loc.row)) {
return T.srcAnalysisCell(cite.alias, loc.row, String(loc.column ?? ''))
}
if (cite.kind === 'cell') {
return Number.isInteger(loc.row) ? T.srcCell(cite.alias, loc.row, String(loc.column ?? '')) : T.srcQuery(cite.alias)
}
if (cite.kind === 'input') return T.srcInput(loc.field || cite.ref || '')
if (cite.kind === 'analysis') {
return loc.key != null ? T.srcAnalysisValue(cite.alias, String(loc.key)) : T.srcAnalysis(cite.alias)
}
return T.srcOther
}
@@ -1418,10 +1477,14 @@
{ fmt: 'plain', conv: convOf(cite) }))
return shown.result === FAIL ? shown : { ...shown, result: NA, reason: T.numInput }
}
if (cite.kind !== 'metric' && cite.kind !== 'cell') return { result: NA, reason: T.numOther, source }
if (cite.kind !== 'metric' && cite.kind !== 'cell' && cite.kind !== 'analysis') {
return { result: NA, reason: T.numOther, source }
}
const entry = own(doc.catalog, cite.alias)
const info = { source, kind: cite.kind, cite }
const wanted = cite.kind === 'metric' ? 'metric' : 'query'
// 分析结论:判定语、具名的数(cite.kind analysis),和它结果表里的一格(cite.kind cell,目录条目是 analysis)
const analysis = cite.kind === 'analysis' || (cite.kind === 'cell' && own(entry, 'kind') === 'analysis')
const wanted = cite.kind === 'metric' ? 'metric' : analysis ? 'analysis' : 'query'
if (!isObj(entry) || entry.kind !== wanted || typeof entry.artifact !== 'string') {
return { ...info, result: FAIL, reason: T.numNoEntry }
}
@@ -1436,6 +1499,7 @@
}
if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained }
info.citedToken = citedToken()
if (analysis) return checkAnalysis(info, seg, doc, art)
if (cite.kind === 'metric') {
const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric')
const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : []
@@ -1478,6 +1542,51 @@
/** 引用上记的换算(|万、|pct 这类),没写是 null */
const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv)
/**
* 分析结论的一处引用(checkNumber 已核过工件的哈希和引用链):引用的值等于结论里记的那个值,正文显示的字等于按服务端
* 的规则渲染出来的字。判定语按文本格渲染(engine/evidence.render_cell 的 kind=text:压成一行、竖线换成 ¦),具名的数按
* 单元格的规则(不做日期时间的改写,同服务端),结果表的一格同查询快照的一格(显示规则照文档记的版本)。不重算统计
*/
function checkAnalysis(info, seg, doc, art) {
const cite = info.cite
const result = art.json
const loc = isObj(cite.locator) ? cite.locator : {}
info.analysis = result
if (cite.kind === 'cell') {
const table = isObj(result.table) ? result.table : null
if (!table) return { ...info, result: FAIL, reason: T.numNoTable }
let cell
try {
const [raw, column, key] = locateCell(table, loc.row, loc.column)
info.row = loc.row
info.column = column
info.truth = cellValue(raw, columnKind(table, column))
info.truthToken = tokenAt(tokensOf(art), ['table', 'rows', loc.row, key])
if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw)
cell = { raw, column }
} catch (e) {
return { ...info, result: FAIL, reason: T.numNoCell(e.message) }
}
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numAnalysisCell }
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite),
columnKind(table, cell.column), display, own(cite, 'plain') === true))
}
if (loc.key != null) {
const values = isObj(result.values) ? result.values : {}
if (!Object.prototype.hasOwnProperty.call(values, loc.key)) return { ...info, result: FAIL, reason: T.numNoValue(loc.key) }
const raw = values[loc.key]
info.truth = cellValue(raw)
info.truthToken = tokenAt(tokensOf(art), ['values', loc.key])
if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw)
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numValueDiffers }
return checkShown(info, seg, () => renderCell(raw, info.truthToken, convOf(cite), null, 1))
}
if (typeof result.phrase !== 'string' || !result.phrase.trim()) return { ...info, result: FAIL, reason: T.numNoPhrase }
info.truth = result.phrase
return checkShown(info, seg, () => renderCell(result.phrase, null, null, 'text', 1))
}
/** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */
function unitOf(unit) {
if (!unit) return ''
@@ -1752,7 +1861,7 @@
const eventWhat = (ev) => [EVENT_LABEL[ev.type] || ev.type, ev.node_id ? nodeLabel(ev.node_id) : null].filter(Boolean).join(' · ')
function numberButton(number, body) {
const kind = number.seg.kind === 'value' ? 'value' : 'number'
const kind = number.seg.kind === 'value' ? 'value' : number.seg.kind === 'analysis' ? 'analysis' : 'number'
const btn = h('button', {
type: 'button', class: 'num', 'data-num': number.key, 'data-kind': kind, 'data-result': number.result,
'aria-label': T.numAria(kind, String(number.seg.text ?? ''), RESULT[number.result]),
@@ -1811,10 +1920,12 @@
metric ? [T.dMetric, [metric.name || metric.id || '—', metric.id && metric.name ? h('span', { class: 'muted' }, `(${metric.id})`) : null]] : null,
metric && metric.unit ? [T.dUnit, String(metric.unit)] : null,
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, exactText(cite.value, number.citedToken)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell,
'truth' in number ? [number.kind === 'metric' ? T.dCard : number.analysis ? T.dAnalysis : T.dCell,
number.truthText ?? exactText(number.truth, number.truthToken)] : null,
checked && !same ? [T.dExpected, number.expected] : null,
[T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]],
// 分析范围里的编号不是正文里的字:没有「正文显示」可核
number.scope ? null
: [T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]],
number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null,
[T.dReport, nodeLabel(number.report.node_id)],
]
@@ -1861,13 +1972,128 @@
}
parts.push(kv([[T.dRecompute, metric.recompute_ok == null ? T.recomputeNone
: metric.recompute_ok ? T.recomputeOk : T.recomputeBad]]))
if (isObj(metric.comparability)) parts.push(...comparabilityBlock(metric.comparability))
}
const snap = number.snapshot
if (snap) parts.push(...snapshotBlock(snap, number.row, number.column,
view.out.artifacts.find((a) => a.a.id === number.artifact)))
if (number.analysis || number.kind === 'analysis') parts.push(...analysisBlock(number))
return parts
}
/** 口径卡指标上的可比性(口径卡执行时自动做的可比性检查):卡上记的判定和原因;那份结论随文件附带、核对通过的,再给
* 判定标准、方法与版本、各项检查和导出时服务端的复算。没附带的照卡上的记录写,说明为什么没有更多 */
function comparabilityBlock(c) {
const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.map(String).join(';')}` : ''
const label = own(T.comparabilityLabel, c.verdict) || String(c.phrase || '—')
const artifact = typeof c.artifact === 'string' && c.artifact ? c.artifact : null
const art = artifact ? view.out.arts.get(artifact) : null
const ok = art && art.result === PASS && view.out.chained && view.out.chained.has(artifact)
const result = ok && isObj(art.json) ? art.json : null
const parts = [h('p', { class: 'sub' }, T.dComparability), kv([
[T.dJudged, `${label}${c.verdict === 'comparable' ? '' : why}`],
artifact ? [T.dArtifact, [h('code', {}, short(artifact)), artifactState(artifact)]] : null,
])]
if (!result) {
if (artifact) parts.push(h('p', { class: 'note' }, T.comparabilityNotAttached))
return parts
}
parts.push(kv([
result.criterion ? [T.dCriterion, String(result.criterion)] : null,
[T.dMethod, String(result.method || '—')],
]))
const table = isObj(result.table) ? result.table : null
const columns = table && Array.isArray(table.columns) ? table.columns.map(String) : []
const labels = table && isObj(table.labels) ? table.labels : {}
if (columns.length && Array.isArray(table.rows) && table.rows.length) {
parts.push(h('p', { class: 'sub' }, T.dChecks), h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, columns.map((col) => h('th', {}, String(own(labels, col) || col))))),
h('tbody', {}, table.rows.filter(Array.isArray).map((row) => h('tr', {},
columns.map((_, i) => h('td', {}, row[i] == null ? '—' : textOf(row[i])))))))))
}
const recheck = isObj(art.a.recheck) ? art.a.recheck : null
parts.push(kv([[T.dRecheck, recheck
? [String(recheck.text || '—'), h('span', { class: 'muted' }, `(${T.recheckBy})`)] : T.recheckNone]]))
return parts
}
/** 数字详情里分析结论一节:判定语、判定标准、方法与版本、附带说明、Agent 写的假设、输入(各自的核对状态)、导出时服务端
* 的复算结论。结论没随文件附带(遮罩、文件不在)时只写复算一句 */
function analysisBlock(number) {
const result = isObj(number.analysis) ? number.analysis : null
const art = view.out.artifacts.find((a) => a.a.id === number.artifact)
const recheck = art && isObj(art.a.recheck) ? art.a.recheck : null
const parts = []
if (result) {
parts.push(kv([
[T.dPhrase, String(result.phrase || '—')],
result.criterion ? [T.dCriterion, String(result.criterion)] : null,
[T.dMethod, String(result.method || '—')],
]))
const caveats = (Array.isArray(result.caveats) ? result.caveats : []).filter(isObj).map((c) => String(c.text || ''))
if (caveats.length) parts.push(h('p', { class: 'sub' }, T.dCaveats), h('ul', { class: 'items' }, caveats.map((c) => h('li', {}, c))))
if (typeof result.hypothesis === 'string' && result.hypothesis.trim()) {
parts.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', {}, result.hypothesis))
}
const inputs = (Array.isArray(result.inputs) ? result.inputs : []).filter((i) => isObj(i) && typeof i.artifact === 'string')
if (inputs.length) {
parts.push(h('p', { class: 'sub' }, T.dAnalysisInputs), h('ul', { class: 'items' }, inputs.map((i) => h('li', {},
h('span', { class: 'what' }, [T.analysisInput(own(T.roleLabel, i.role) || String(i.role || ''),
view.out.queryAliases.get(i.artifact)), ' ',
h('code', {}, short(i.artifact))]), artifactState(i.artifact)))))
}
}
parts.push(kv([[T.dRecheck, recheck ? [String(recheck.text || '—'), h('span', { class: 'muted' }, `(${T.recheckBy})`)]
: T.recheckNone]]))
return parts
}
/** 报告文档里系统写的「分析范围」:现象、拆过(下钻按链缩进)、排除、算不了、没拆、检验了几次。编号点得开那条结论 */
function renderScope(doc, report) {
const scope = isObj(doc.analysis_scope) ? doc.analysis_scope : null
if (!scope) return null
const rows = []
for (const [key, title] of SCOPE_GROUPS) {
const items = (Array.isArray(scope[key]) ? scope[key] : []).filter(isObj)
if (!items.length) continue
const depth = new Map()
rows.push(h('dt', {}, title), h('dd', {}, h('ul', {}, items.map((item) => {
const level = item.parent ? (depth.get(String(item.parent)) ?? -1) + 1 : 0
depth.set(String(item.alias || ''), level)
const dims = Array.isArray(item.dims) && item.dims.length ? T.scopeDims(item.dims.join('、')) : null
const lead = [dims, item.parent ? T.scopeParent(String(item.parent)) : null].filter(Boolean).join(',')
const said = item.phrase || item.reason || ''
const li = h('li', {}, scopeButton(String(item.alias || ''), doc, report), lead ? `(${lead})` : '', said ? `:${said}` : '')
if (level) li.style.marginLeft = `${level * 14}px`
return li
}))))
}
if (Array.isArray(scope.not_decomposed)) {
rows.push(h('dt', {}, T.scopeMissing), h('dd', {}, scope.not_decomposed.length ? scope.not_decomposed.join('、') : T.scopeAll))
} else if (Object.prototype.hasOwnProperty.call(scope, 'not_decomposed')) {
rows.push(h('dt', {}, T.scopeMissing), h('dd', {}, String(scope.note || T.scopeUnknown)))
}
if (Number.isInteger(scope.tests) && scope.tests > 0) rows.push(h('dt', {}, T.scopeTests), h('dd', {}, T.scopeTestsText(scope.tests)))
if (!rows.length) return null
return h('section', { class: 'scope', 'data-scope': '' }, h('h4', {}, T.scopeTitle), h('p', { class: 'note' }, T.scopeNote),
h('dl', { class: 'kv' }, rows))
}
/** 分析范围里的一个编号:点开那条结论(工件的核对状态同 ③ ④;没有这条结论、没核对上的照实写) */
function scopeButton(alias, doc, report) {
if (!alias) return ''
const entry = own(doc.catalog, alias)
const artifact = isObj(entry) && typeof entry.artifact === 'string' ? entry.artifact : null
const art = artifact ? view.out.arts.get(artifact) : null
const ok = art && art.result === PASS && view.out.chained && view.out.chained.has(artifact)
const number = { seg: { text: alias, kind: 'analysis', cite: { alias, kind: 'analysis', status: 'resolved', locator: {} } },
key: `${report.node_id}|scope:${alias}`, report, kind: 'analysis', scope: true, artifact,
node: isObj(entry) ? entry.node_id : null,
source: T.srcAnalysis(alias), result: ok ? PASS : art && art.result === FAIL ? FAIL : NA,
reason: ok ? '' : !art ? T.numArtifactNotListed : art.reason || T.numNotChained, analysis: ok ? art.json : null }
return numberButton(number, alias)
}
function openNumber(number) {
const panel = $('detail')
if (panel.hidden) view.back = document.activeElement
@@ -1978,6 +2204,8 @@
box.append(h('p', { class: 'warn', 'data-why': why.key }, why.text))
}
if (item.doc) box.append(renderDoc(item.doc, { report: item.report, numbers }))
const scope = item.doc && item.trusted ? renderScope(item.doc, item.report) : null
if (scope) box.append(scope)
root.append(box)
}
}
@@ -2026,12 +2254,14 @@
}
const refs = [...c.docs.map((d) => T.chainByDoc(nodeLabel(d.report.node_id), d.alias)),
...c.cards.map((k) => T.chainByCard(nodeLabel(k.card.a.node_id), k.metric)),
...(c.analyses || []).map((k) => T.chainByAnalysis(view.out.queryAliases.get(k.analysis.a.id) || short(k.analysis.a.id))),
...c.events.map((ev) => T.chainByEvent(ev.seq, eventWhat(ev)))]
return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)),
refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : '']
}))
const values = out.numbers.filter((n) => n.seg.kind === 'value').length
root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values, values), out.numbers,
const phrases = out.numbers.filter((n) => n.seg.kind === 'analysis').length
root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values - phrases, values, phrases), out.numbers,
(n) => h('button', { type: 'button', 'data-num-item': n.key, onclick: () => {
if (n.button) n.button.scrollIntoView({ block: 'center' })
openNumber(n)
File diff suppressed because it is too large Load Diff
+118 -3
View File
@@ -44,6 +44,7 @@ from typing import Any
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from sqlalchemy import select
from app.analysis.contract import CATALOG_PREFIX, LEDGER_KIND, catalog_result, is_result, method_label
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
@@ -160,6 +161,15 @@ JUDGE_RULES = f"""判定(verdict)五选一:
7. used 写你实际用到的证据编号(摘录里【】中的那个,比如 m:gmv、Q1),没用到就写空数组。
8. 每句都要给判定,unit 照抄句子前面方括号里的编号。"""
#: 摘录里有分析结论(【A1】)时接在裁判规则后面的一条。只在这时才加:没有分析结论的报告,裁判看到的规则一字不差,
#: 已判过的句子照旧复用(复用键里只有摘录,没有规则)
ANALYSIS_JUDGE_PROMPT = (
"9. 【A1】这类分析结论的判定、判定标准和关键数是系统按平台的方法算出来的,不要质疑也不要复算;只判断句子的说法和"
"判定是否一致:「主要来自」「集中在」「超出常规波动」「呈上升趋势」「差异站得住」这类判断要和判定对得上,句子点名的段"
"要在判定里,方向要一致;判定写着算不了的,不能支持任何判断。分析结论只说明变化来自哪里,说明不了原因:句子写了"
"原因、证据里又没有原因的,判 partial 或 insufficient。"
)
#: 报告节点 claims 为 judge 时拼在写作规则后面:写作者要知道还有一道按证据的核对
JUDGE_RULE = ("这份报告的结论句还会由另一个模型逐句核对:它只看你挂的依据,判断这句话站不站得住。"
"说法不要超出证据——证据里没有的原因、范围、趋势,不要写成结论。")
@@ -299,8 +309,8 @@ def _candidate(unit: dict[str, Any], priority: int, order: int, text: str, secti
rows.append((alias, int(locator["row"])))
if kind == "cell" and locator.get("column") not in (None, ""):
cols.append((alias, str(locator["column"])))
elif kind in ("query", "retrieval"):
whole.append(alias)
elif kind in ("query", "retrieval", LEDGER_KIND):
whole.append(alias) # 分析结论:判定语、具名标量、依据 [[see:A1]] 都整份摘
elif kind == "quote" and isinstance(locator.get("hit"), int):
hits.append((alias, int(locator["hit"])))
return Candidate(unit=str(unit.get("id")), priority=priority, order=order, text=text, section=section,
@@ -565,6 +575,10 @@ def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch, catalog: d
if metric.get("expression"):
lines.append(f"原式:{_clean(str(metric['expression']))}")
lines.extend(_metric_inputs(metric, fetch, catalog, masked, mask))
comparability = metric.get("comparability")
if isinstance(comparability, dict) and str(comparability.get("phrase") or "").strip():
# 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查(判定语是系统写的,原样给)
lines.append(f"两期可比性(系统检查):{_clean(str(comparability['phrase']))}")
return "\n".join(lines)
@@ -664,6 +678,102 @@ def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], who
return "\n".join(lines)
def _analysis_alias(alias: Any) -> bool:
"""写作目录里分析结论的编号(A1、A2……)。"""
return isinstance(alias, str) and alias.startswith(CATALOG_PREFIX) and alias[len(CATALOG_PREFIX):].isdigit()
#: 分析结论的摘录最多列几个具名的数
ANALYSIS_VALUES = 16
def _analysis_excerpt(alias: str, entry: dict[str, Any], rows: set[int], fetch: _Fetch,
masked: dict[str, Any] | None, catalog: dict[str, Any], mask: Any = None) -> str | None:
"""一条分析结论的摘录:方法、判定语、判定标准、关键数(具名标量)、附带说明、结果表(被引用的行先给,再从头补满
EXCERPT_ROWS 行,最多 EXCERPT_COLS 列,表头写给人看的列名)、输入取自哪几次查询、用了哪几列,以及那几次查询的 SQL
(截到 SQL_CHARS,最多 ENTITY_QUERIES 次)。判定是系统算的,裁判只判句子的说法和判定是否一致(ANALYSIS_JUDGE_PROMPT)。
结论按工件取(经 fetch:按需裁判时只认封存范围内的工件,取不到、哈希不符的不给这段摘录);目录条目带着整份结论的
直接用。agent 写的假设不给:它不是结论。mask 同 _query_excerpt(核查 agent 的摘录才给):SQL 先遮再截,结果表里的文字、
判定语按值换,数照原样。"""
result = catalog_result(entry)
if result is None:
content = fetch(entry.get("artifact"))
result = content if is_result(content) else None
if result is None:
return None
scrub = mask.text if mask is not None else (lambda t: t)
lines = [f"【{alias}】分析结论({method_label(result.get('op'), result.get('mode'))},方法 {result.get('method') or '—'})"]
if result.get("status") == "refused":
lines.append(f"判定:算不了({_clean(str(result.get('reason') or '没有写原因'))})")
else:
lines.append(f"判定:{scrub(_clean(str(result.get('phrase') or ''))) or '—'}")
if criterion := _clean(str(result.get("criterion") or "")):
lines.append(f"判定标准:{criterion}")
values = result.get("values") if isinstance(result.get("values"), dict) else {}
if values:
shown = []
for key, raw in list(values.items())[:ANALYSIS_VALUES]:
try:
text = render_cell(raw)
except RenderError:
text = MISSING
shown.append(f"{key}={text if isinstance(raw, (int, float)) else scrub(text)}")
more = f" | …另有 {len(values) - ANALYSIS_VALUES} 个" if len(values) > ANALYSIS_VALUES else ""
lines.append("关键数:" + " | ".join(shown) + more)
caveats = [_clean(str(c.get("text") or "")) for c in result.get("caveats") or [] if isinstance(c, dict)]
if caveats := [c for c in caveats if c]:
lines.append("附带说明:" + ";".join(scrub(c) for c in caveats))
table = result.get("table") if isinstance(result.get("table"), dict) else None
if table is not None and isinstance(table.get("rows"), list) and table["rows"]:
columns = [str(c) for c in table.get("columns") or []][:EXCERPT_COLS]
labels = table.get("labels") if isinstance(table.get("labels"), dict) else {}
total = len(table["rows"])
picked = sorted(r for r in rows if 0 <= r < total)[:EXCERPT_ROWS]
picked += [r for r in range(total) if r not in picked][:EXCERPT_ROWS - len(picked)]
lines.append(f"结果表(共 {total} 行)列:" + " | ".join(_clean(str(labels.get(c) or c)) for c in columns))
for r in sorted(picked):
cells = []
for c in columns:
try:
raw, _ = locate_cell(table, r, c)
text = render_cell(raw)
cells.append(text if isinstance(raw, (int, float)) else scrub(text))
except (CellError, RenderError):
cells.append(MISSING)
lines.append(f"r{r}:" + " | ".join(cells))
sqls: list[str] = []
seen: set[str] = set()
for item in result.get("inputs") or []:
if not isinstance(item, dict) or not item.get("artifact") or item.get("role") == "parent":
continue
artifact = str(item["artifact"])
if artifact in seen:
continue
seen.add(artifact)
query = _query_alias(catalog, artifact)
snap = fetch(artifact)
snap = snap if isinstance(snap, dict) else {}
source = (catalog.get(query) or {}).get("source") if query else None
hidden, names = _derived(snap, _source_hidden(source or snap.get("source"), catalog, fetch, masked)
| _mask_names(snap.get("mask_columns")), fetch, mask)
used = [str(c) for c in item.get("columns") or [] if str(c).lower() not in hidden]
label = f"查询 {query}" if query else "一次查询(不在这份报告的证据目录中)"
lines.append(f"输入:取自{label}" + (f",用到 {'、'.join(used)} 列" if used else ""))
sql = _clean(str(snap.get("sql") or ""))
if sql and mask is not None:
sql = mask.sql(sql, snap, names)
if sql and len(sqls) < ENTITY_QUERIES:
sqls.append(_sql_line(f"查询 {query} 的 SQL" if query else "这次查询的 SQL", sql))
parents = [str(i.get("artifact")) for i in result.get("inputs") or []
if isinstance(i, dict) and i.get("role") == "parent"]
for artifact in parents:
found = next((a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == LEDGER_KIND
and e.get("artifact") == artifact), None)
lines.append(f"下钻自:{found or '一条分析结论(不在这份报告的证据目录中)'}")
return "\n".join(lines + sqls)
def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _Fetch,
masked: dict[str, Any] | None, catalog: dict[str, Any], *, provenance_on: bool = False,
mask: Any = None) -> str | None:
@@ -683,6 +793,9 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _
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, mask)
if kind == LEDGER_KIND:
rows = {r for c in cands for a, r in c.rows if a == alias}
return _analysis_excerpt(alias, entry, rows, fetch, masked, catalog, mask)
if kind == "table":
head = f"【{alias}】表 {entry.get('name')}" + (f"(数据源 {entry['source']})" if entry.get("source") else "")
return "\n".join([head, *_table_lines(entry, catalog, fetch, masked),
@@ -754,8 +867,10 @@ class JudgeRequest:
aliases = list(dict.fromkeys(a for c in units for a in c.aliases if a in self.excerpts))
evidence = "\n\n".join(self.excerpts[a] for a in aliases) or "(这些句子都没有挂依据)"
ordered = sorted(units, key=lambda c: c.order)
# 摘录里有分析结论时另加一条(ANALYSIS_JUDGE_PROMPT);没有的和以前一字不差
rules = f"{JUDGE_RULES}\n{ANALYSIS_JUDGE_PROMPT}" if any(_analysis_alias(a) for a in aliases) else JUDGE_RULES
return [
SystemMessage(content=f"{JUDGE_ROLE}\n\n{JUDGE_RULES}"),
SystemMessage(content=f"{JUDGE_ROLE}\n\n{rules}"),
HumanMessage(content=(
f"证据摘录(【】里是编号,判定时 used 写它):\n{evidence}\n\n"
"要判断的句子(每句都要给判定):\n" + "\n".join(self.unit_line(c) for c in ordered) + "\n\n"
+5 -4
View File
@@ -146,11 +146,12 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
if isinstance(sources, str) and sources != "ancestors":
sources = [sources]
if isinstance(sources, list):
# evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。
# 写了子工作流节点的,它交回来的(子图里的查询,节点 id「子工作流节点 id/…」)一并算
# evidence_from 只管查询、检索、分析结论这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。
# 分析结论按做它的节点认(Agent,或者分析卡)。写了子工作流节点的,它交回来的(子图里的查询,节点 id
# 「子工作流节点 id/…」)一并算
keep = {str(s) for s in sources}
ledger = [e for e in ledger
if e.get("kind") not in ("query", "retrieval", "schema") or _outer(e.get("node_id")) in keep]
ledger = [e for e in ledger if e.get("kind") not in ("query", "retrieval", "schema", "analysis")
or _outer(e.get("node_id")) in keep]
nodes = state.get("nodes") or {}
catalog = build_catalog(
nodes=nodes,
+8 -4
View File
@@ -8,7 +8,9 @@
- 写作目录里提醒写作者(engine/evidence.catalog_prompt)。
合并查询在内存 SQLite 上执行,不对照数据目录,它的快照没有自己的 checks,但记着 inputs:顺着往下找,问题记在
出问题的那条源查询上。几个指标、几处引用出自同一条查询时,问题只算一次(problems_by_query 按快照 id 归并)。
出问题的那条源查询上。分析结论(app/analysis 的工件)同样记着 inputs(它用到的查询快照):报告引用了一份分析结论,
它的输入查询上的问题照样算——写作目录随 A 条目提醒,出具契约按引用的查询降档。几个指标、几处引用出自同一条查询时,
问题只算一次(problems_by_query 按快照 id 归并)。
"""
from __future__ import annotations
@@ -17,6 +19,7 @@ from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any
from app.analysis.contract import is_result
from app.core import artifact_store
#: 合并查询快照的 source(engine/merge_query.MERGE_SOURCE)。这里写死同一个字面量,免得为一个常量引入执行器
@@ -57,9 +60,10 @@ def _load(artifact: str, loader: Callable[[str], Any] | None) -> Any:
def query_problems(artifact: Any, *, loader: Callable[[str], Any] | None = None,
_seen: set[str] | None = None, _node: str | None = None, _depth: int = 0) -> list[QueryProblem]:
"""这份查询快照(或合并快照的各个输入)里有 error 级 SQL 检查的那几条查询,一条一个 QueryProblem。
"""这份查询快照(或合并快照、分析结论的各个输入)里有 error 级 SQL 检查的那几条查询,一条一个 QueryProblem。
读不出来的快照、没有问题的快照都不出现。自引用、成环的坏快照不会卡死。
读不出来的快照、没有问题的快照都不出现。自引用、成环的坏快照不会卡死。分析结论的输入没记节点(node_id 为 None),
调用方按证据台账补(node_of);下钻的上一层结论(role parent)也是输入,顺着它再往下找。
"""
if not isinstance(artifact, str) or not artifact or _depth > _MAX_DEPTH:
return []
@@ -75,7 +79,7 @@ def query_problems(artifact: Any, *, loader: Callable[[str], Any] | None = None,
errors = [c for c in checks if isinstance(c, dict) and c.get("level") == "error"] if isinstance(checks, list) else []
if errors:
out.append(QueryProblem(artifact, _node, errors))
inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE else None
inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE or is_result(snapshot) else None
for entry in inputs if isinstance(inputs, list) else []:
if isinstance(entry, dict) and isinstance(entry.get("artifact"), str):
node = entry.get("node_id") if isinstance(entry.get("node_id"), str) else None
+10 -1
View File
@@ -20,6 +20,9 @@
整题(whole_of):主报告里所有带标签的数都来自同一口径的同一版、绑定一致,就记 equivalent,并写出还缺的入口字段(口径入口
节点声明了、这次的绑定里没有的;required 且没有缺省值的另列)。
分析结论里的数([[a:A3]]、[[a:A3.键]]、[[v:A3.r0.列]]、整表 [[table:A3]] 展开的格)不标:它们是从查询快照派生出来的,
不是口径(docs/design/analysis-round/00-总方案.md D11),不和口径比对、不进整题判断的计数,也不算成「没比对」。
这里只是纯函数:标签在比对时算好、存进比对工件(ledger/match.py);接口读的时候按这次运行查到过的遮罩值遮一遍(view_label):
绑定的值是遮罩值的不给值,差异条目里的 SQL 片段走证据面板同一套遮罩(api/evidence._Shown)。
"""
@@ -28,6 +31,8 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from app.analysis.contract import LEDGER_KIND
#: 标签的四种、认法的两种
KINDS = ("caliber", "related", "reasoning", "unknown")
HOWS = ("independent", "composed")
@@ -259,6 +264,8 @@ def referenced_columns(docs: list[Mapping[str, Any]],
if cite.get("status") != "resolved":
continue
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
if entry.get("kind") == LEDGER_KIND:
continue # 分析结论的结果表不是查询,不比对
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
artifact = str(entry.get("artifact") or "")
if cite.get("kind") == "cell" and artifact and isinstance(locator.get("column"), str):
@@ -375,7 +382,7 @@ def label_doc(doc: Mapping[str, Any], queries: Mapping[str, Mapping[str, Any]],
calibers: Mapping[str, Mapping[str, Any]],
load_card: Callable[[str], Mapping[str, Any] | None]) -> dict[str, dict[str, Any]]:
"""一份报告文档里每个数的标签:{片段 id: 标签}。只标引用解析了的数字、取值片段(单元格、指标);运行输入、表名、引文、
没有引用的数不标。"""
没有引用的数不标,分析结论里的数(具名标量、结果表的格)也不标(D11:派生的数,不是口径)。"""
from app.engine.evidence import iter_units
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
@@ -389,6 +396,8 @@ def label_doc(doc: Mapping[str, Any], queries: Mapping[str, Mapping[str, Any]],
if cite.get("status") != "resolved":
continue
entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {}
if entry.get("kind") == LEDGER_KIND:
continue
locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {}
artifact = str(entry.get("artifact") or "")
if cite.get("kind") == "cell" and artifact:
+60 -3
View File
@@ -4,7 +4,9 @@
执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的
出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查),
以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。
以及运行输入、表名字段名、引文各自的步骤;分析结论(判定语、具名的数、结果表的格)点开是判定 → 方法 → 计算 → 输入 →
复算 → 封存。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。报告文档带着系统写的「分析范围」
(analysis_scope)时,正文后面另渲染这一段,编号点得开同一条出处链;没有这个字段的文档一个字都不多。
这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希
(可验证报告,app/bundle)。页面页脚写明这一点。
@@ -77,7 +79,7 @@ _STATE_CLASS = {
"deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad",
"unjudged": "na", "candidate": "na",
}
_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"}
_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文", "analysis": "判定"}
def _esc(text: Any) -> str:
@@ -293,6 +295,61 @@ def render_body(doc: Mapping[str, Any]) -> str:
return f'<div class="doc">{"".join(_render_block(b) for b in blocks)}</div>'
#: 「分析范围」各组的叫法(00-总方案 §3.6),按这个顺序写
SCOPE_TITLES = (("phenomena", "现象"), ("decomposed", "拆过"), ("excluded", "排除"), ("refused", "算不了"))
SCOPE_NOTE = "这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定标准、计算、输入和复算。"
#: 分析范围里一个编号的片段键(api/evidence.SCOPE_REF 同一个写法):点开时按它找出处链
_SCOPE_REF = "scope:{alias}"
def _scope_item(item: Mapping[str, Any], *, depth: int = 0) -> str:
"""分析范围里的一条:编号(按钮,点开出处链)、维度、下钻自哪一条、判定语或算不了的原因。"""
alias = str(item.get("alias") or "")
title = f"分析结论 {_esc(alias)}:点开查看出处"
button = (f'<button type="button" class="num num-ok" data-seg="{_esc(_SCOPE_REF.format(alias=alias))}"'
f' data-state="deterministic" data-kind="analysis" title="{title}">'
f"{_esc(alias)}</button>") if alias else ""
dims = [str(d) for d in item.get("dims") or [] if d]
bits = ([f"按{'、'.join(dims)}"] if dims else []) + ([f"下钻自 {item['parent']}"] if item.get("parent") else [])
lead = f"({','.join(_esc(b) for b in bits)})" if bits else ""
said = item.get("reason") if item.get("reason") and not item.get("phrase") else item.get("phrase")
margin = f' style="margin-left:{depth * 14}px"' if depth else ""
return f"<li{margin}>{button}{lead}{':' if said else ''}{_esc(said or '')}</li>"
def render_scope(doc: Mapping[str, Any]) -> str:
"""报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):现象、拆过(下钻按链缩进)、排除、算不了、没拆、
检验了几次。文档没有这个字段(老文档、这次没有分析结论、报告节点关了这一段)时是空串,页面一个字都不多。"""
scope = doc.get("analysis_scope")
if not isinstance(scope, Mapping):
return ""
rows: list[str] = []
for key, title in SCOPE_TITLES:
items = [i for i in scope.get(key) or [] if isinstance(i, Mapping)]
if not items:
continue
depth: dict[str, int] = {}
lines = []
for item in items:
level = depth.get(str(item.get("parent") or ""), -1) + 1 if item.get("parent") else 0
depth[str(item.get("alias") or "")] = level
lines.append(_scope_item(item, depth=level))
rows.append(f"<dt>{title}</dt><dd><ul>{''.join(lines)}</ul></dd>")
missing = scope.get("not_decomposed")
if isinstance(missing, list):
names = "、".join(_esc(n) for n in missing if n)
rows.append(f"<dt>没拆</dt><dd>{names or '候选维度都拆过了'}</dd>")
elif "not_decomposed" in scope:
rows.append(f"<dt>没拆</dt><dd>{_esc(scope.get('note') or '候选维度核对不了')}</dd>")
tests = scope.get("tests")
if isinstance(tests, int) and not isinstance(tests, bool) and tests > 0:
rows.append(f"<dt>检验</dt><dd>这次做了 {tests} 次差异检验</dd>")
if not rows:
return ""
return (f'<section class="scope" aria-labelledby="scope-title"><h3 id="scope-title">分析范围</h3>'
f'<p class="muted">{SCOPE_NOTE}</p><dl class="scope-list">{"".join(rows)}</dl></section>')
# --------------------------------------------------------------------------
# 数据块和页面
# --------------------------------------------------------------------------
@@ -390,7 +447,7 @@ def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str:
"NAME": _esc(name),
"SEAL": seal_text,
"RUN": _esc(str(meta.get("run_id") or "")),
"BODY": render_body(doc),
"BODY": render_body(doc) + render_scope(doc),
"CSP": f"'sha256-{digest}'",
"DATA": _json_text(data),
"SCRIPT": script,
+190 -5
View File
@@ -3,9 +3,10 @@
* 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事:
* ① 填页面头部(报告节点、文档工件、封存状态、运行信息);
* ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板;
* ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
* ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算、可比性)、
* 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、
* 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。
* 运行输入、表名字段名、引文,分析结论的六步(判定、方法、计算、输入、复算、封存),和网页上的证据面板
* (api/evidence 的片段接口)同一个拼法。正文后面系统写的「分析范围」里的编号同样点得开(data-seg 是 scope:A1)。
* 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
@@ -20,7 +21,7 @@
deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实',
unjudged: '未裁判', candidate: '猜测的来源',
},
kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' },
kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文', analysis: '判定' },
segTitle: (kind, text) => `${kind}「${text}」`,
metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点',
metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间',
@@ -53,6 +54,7 @@
truncated: '查询结果已截断,该字段只含取回的部分行',
// 查询步骤
query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`,
dTotalRows: '行数',
dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分',
truncatedRows: '查询结果被截断,取回的行不完整',
merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告',
@@ -76,6 +78,28 @@
ledgerRelated: '相关口径', ledgerReasoning: '这次的推理:没有相关的已发布口径', ledgerUnknown: '没有比对',
dBinding: '参数', dDiffers: '差在哪', dHow: '怎么认的', dColumnOf: '对应口径的列',
unnamed: '已删除的工作流',
// 口径卡指标上的可比性(跨两份同形查询算的环比、同比)
dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' },
comparabilityOpen: '展开这份可比性结论(判定、方法、计算、输入、复算、封存)',
// 分析结论:判定 → 方法 → 计算 → 输入 → 复算 → 封存
analysis: '分析结论', dPhrase: '判定语', dCriterion: '判定标准', dSubjects: '点名的段',
dDirection: '方向', directionLabel: { up: '上升', down: '下降' }, dCaveats: '附带说明', dReason: '算不了的原因',
dCitedPart: '这一处引用的是', citedPhrase: '判定语', citedValue: (k) => `具名的数「${k}」`,
citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的',
originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' },
dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致',
method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查结果',
probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' },
probeBound: (expr, value) => `按运行当天现算的界 ${expr} = ${value}`, probeEmpty: '这一列一个值都没有',
probeRange: (min, max) => `${min} 至 ${max}`,
calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表',
resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`,
analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行',
allRows: '全部行', dHeadline: '总数那一格', dClosure: '闭合核对',
closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`,
dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`,
inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论',
viaCard: '记在口径卡的指标集里,随卡封存(不进台账)',
}
const MASKED = '已遮罩'
@@ -211,6 +235,23 @@
: T.originUnknown(String(u.path || NONE)))).join(';')
: T.originTraced] : null,
])))
// 可比性:口径卡执行时自动做的可比性检查(跨两份同形查询算的环比、同比),做过才有
if (isObj(step.comparability)) {
const c = step.comparability
const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.join(';')}` : ''
body.push(h('div', { style: 'margin-top:8px' }, kv([
[T.dComparability,
`${own(T.comparabilityLabel, c.verdict) || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`],
c.criterion ? [T.dCriterion, String(c.criterion)] : null,
c.artifact ? [T.dArtifact, h('code', {}, short(c.artifact))] : null,
])))
// 点得开那份可比性结论:服务端给的出处链(同分析结论的引用),收在一个可展开的块里
const steps = Array.isArray(c.steps) ? c.steps.filter((x) => isObj(x) && own(PARTS, x.step)) : []
if (steps.length) {
body.push(h('details', { class: 'comparability', 'data-comparability': '' },
h('summary', {}, T.comparabilityOpen), ...steps.map((x) => PARTS[x.step](x))))
}
}
// 双盲复算(期 4):复核过这个指标才有
if (isObj(step.dual_check)) {
const d = step.dual_check
@@ -254,7 +295,7 @@
step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.alias ? [T.dQuery, String(step.alias)] : null,
step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null,
step.total_rows != null ? [T.dTotalRows, T.dAllRows(step.total_rows)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql))
@@ -314,7 +355,7 @@
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'query' },
h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`),
h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}${step.input_of ? ` · ${T.inputOfAnalysis(step.input_of)}` : ''}`),
h('div', { class: 'part-body' }, ...body))
}
@@ -397,9 +438,153 @@
h('div', { class: 'part-body' }, ...body))
}
/** 完整性徽标:已封存、哈希核对失败(每一步都可带) */
function integrity(step) {
const bits = []
if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
return bits.length ? h('div', { class: 'chips' }, ...bits) : null
}
const muted = (text) => h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(text))
const waiting = (text) => h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(text))
/** 一张小表:表头 heads,行 rows(每行一组格),高亮的格(行号:列 id)和列 id 对上 */
function smallTable(heads, ids, rows, index, hits) {
const hit = new Set((hits || []).map((c) => `${c[0]}:${c[1]}`))
return h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, heads.map((c) => h('th', {}, String(c))))),
h('tbody', {}, rows.map((row, i) => h('tr', {}, (Array.isArray(row) ? row : ids.map((c) => own(row, c))).map((v, j) => h('td', {
class: [hit.has(`${index[i]}:${ids[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : ''].filter(Boolean).join(' ') || null,
}, v == null ? NONE : textOf(v))))))))
}
/** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设 */
function analysisPart(step) {
const body = []
const verdict = isObj(step.verdict) ? step.verdict : null
const cited = isObj(step.cited) ? step.cited : {}
const citedText = cited.what === 'value' ? T.citedValue(cited.key) : cited.what === 'cell'
? T.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : T.citedPhrase
body.push(kv([
[T.dPhrase, String(step.phrase || NONE)],
step.criterion ? [T.dCriterion, String(step.criterion)] : null,
verdict && Array.isArray(verdict.subjects) && verdict.subjects.length
? [T.dSubjects, verdict.subjects.map((s) => (Array.isArray(s) ? s.join(' · ') : String(s))).join('、')] : null,
verdict && verdict.direction ? [T.dDirection, T.directionLabel[verdict.direction] || String(verdict.direction)] : null,
[T.dCitedPart, citedText],
[T.dWho, `${own(T.originLabel, step.origin) || NONE}`
+ `${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`],
]))
if (step.status === 'refused') body.push(waiting(`${T.refused}${step.reason ? `:${step.reason}` : ''}`))
if (Array.isArray(step.caveats) && step.caveats.length) {
body.push(h('p', { class: 'sub' }, T.dCaveats),
h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, step.caveats.map((c) => h('li', {}, String(c.text || '')))))
}
if (step.hypothesis) {
body.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(step.hypothesis)))
if (step.hypothesis_note) body.push(muted(step.hypothesis_note))
}
if (step.note) body.push(waiting(step.note))
if (typeof step.render_ok === 'boolean') body.push(muted(step.render_ok ? T.renderOk : T.renderBad))
return h('section', { class: 'part', 'data-step': 'analysis' },
h('h3', {}, `${[T.analysis, step.alias].filter(Boolean).join(' ')} · ${step.method_label || NONE}`),
h('div', { class: 'part-body' }, ...body))
}
/** 现查结果的一条:有 expr 的是按运行当天现算的界(CURRENT_DATE 这类在库里算出的日期),其余是一列的实际范围;现查到的
* 范围最早、最晚都空,是这一列一个值都没有 */
function probeText(p) {
const how = `(${own(T.probeBasis, p.basis) || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})`
if (typeof p.expr === 'string' && p.expr) return `${T.probeBound(p.expr, p.value || NONE)}${how}`
const where = `${p.table || NONE}.${p.column || NONE}`
const empty = p.basis === 'live' && !p.min && !p.max
return `${where}:${empty ? T.probeEmpty : T.probeRange(p.min || NONE, p.max || NONE)}${how}`
}
/** 分析结论 · 方法:方法与版本、参数、实际用的阈值、现查结果 */
function analysisMethodPart(step) {
const body = [kv([[T.dMethod, `${step.method_label || NONE} · ${step.method || NONE}`]])]
if (isObj(step.params) && Object.keys(step.params).length) {
body.push(h('p', { class: 'sub' }, T.dParams), h('pre', { class: 'sql' }, JSON.stringify(step.params, null, 2)))
}
if (Array.isArray(step.settings) && step.settings.length) {
body.push(h('p', { class: 'sub' }, T.dSettings), kv(step.settings.map((x) => [String(x.label || x.key), textOf(x.value)])))
}
if (Array.isArray(step.probes) && step.probes.length) {
body.push(h('p', { class: 'sub' }, T.dProbes), h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
step.probes.filter(isObj).map((p) => h('li', {}, probeText(p)))))
}
return h('section', { class: 'part', 'data-step': 'analysis_method' }, h('h3', {}, T.method), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 计算:关键数的代入式、具名的数、结果表(引用的格高亮) */
function analysisCalcPart(step) {
const body = []
if (Array.isArray(step.formulas) && step.formulas.length) {
body.push(h('p', { class: 'sub' }, T.dFormulas), h('pre', { class: 'sql' }, step.formulas.map((f) => String(f.text || '')).join('\n')))
}
if (Array.isArray(step.values) && step.values.length) {
body.push(h('p', { class: 'sub' }, T.dValues), kv(step.values.map((v) => [String(v.key),
h('span', { class: v.cited ? 'hit' : null }, String(v.rendered ?? NONE))])))
}
const table = isObj(step.table) ? step.table : null
if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) {
const labels = isObj(table.labels) ? table.labels : {}
const index = Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i)
body.push(h('p', { class: 'sub' }, `${T.dResultTable}(${T.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})`),
smallTable(table.columns.map((c) => labels[c] || c), table.columns, table.rows.map((r) => (Array.isArray(r)
? r.map((v, j) => ((table.masked || []).includes(table.columns[j]) ? MASKED : v)) : r)), index,
isObj(table.highlight) ? table.highlight.cells : []))
if (table.window_truncated) body.push(muted(T.windowCut))
}
return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数那一格和闭合核对、下钻的上一层 */
function analysisInputPart(step) {
const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null
const closure = isObj(step.closure) ? step.closure : null
const body = [kv([
[T.dRole, String(step.role_label || step.role || NONE)],
step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null,
step.query ? [T.dQuery, String(step.query)] : null,
step.alias ? [T.dParent, String(step.alias)] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null,
rows ? [T.dRowsUsed, rows] : null,
'value' in step ? [T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null,
closure ? [T.dClosure, closure.closed ? T.closureOk(closure.headline, closure.total) : T.closureBad(closure.headline, closure.total)] : null,
step.phrase ? [T.dPhrase, String(step.phrase)] : null,
])]
const bits = integrity(step)
if (bits) body.push(bits)
return h('section', { class: 'part', 'data-step': 'analysis_input' },
h('h3', {}, `${T.analysisInput}${step.query || step.alias ? ` ${step.query || step.alias}` : ''}`), h('div', { class: 'part-body' }, ...body))
}
/** 分析结论 · 复算:按封存的输入重算、和封存的比(服务端算好的那一句) */
function analysisRecomputePart(step) {
const kind = step.status === 'same' ? 'ok' : step.status === 'different' ? 'bad' : 'na'
return h('section', { class: 'part', 'data-step': 'analysis_recompute' }, h('h3', {}, T.recompute),
h('div', { class: 'part-body' }, h('div', { class: 'chips' }, badge(kind, String(step.text || NONE)))))
}
/** 分析结论 · 封存:台账记没记过、哈希核对 */
function analysisSealPart(step) {
const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE],
[T.seal, step.via === 'metric_set' ? T.viaCard : step.ledger ? T.inLedger : T.notInLedger]])]
if (step.note) body.push(waiting(step.note))
const bits = integrity(step)
if (bits) body.push(bits)
return h('section', { class: 'part', 'data-step': 'analysis_seal' }, h('h3', {}, T.seal), h('div', { class: 'part-body' }, ...body))
}
const PARTS = {
metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart,
entity: entityPart, quote: quotePart,
analysis: analysisPart, analysis_method: analysisMethodPart, analysis_calc: analysisCalcPart,
analysis_input: analysisInputPart, analysis_recompute: analysisRecomputePart, analysis_seal: analysisSealPart,
}
/** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪;
+8 -1
View File
@@ -97,6 +97,13 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.chipbtn { font: inherit; font-size: 12px; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer;
text-align: left; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.hit { background: var(--mark); }
.scope { margin-top: 16px; padding: 14px 18px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); }
.scope h3 { margin: 0 0 4px; font-size: 16px; }
.scope > .muted { margin: 0 0 8px; font-size: 12px; }
.scope-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; font-size: 14px; }
.scope-list dt { color: var(--faint); }
.scope-list dd { margin: 0; min-width: 0; }
.scope-list ul { margin: 0; padding-left: 1.2em; }
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
@media print {
@@ -115,7 +122,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
body { font-size: 14px; }
.page { padding: 20px 16px 56px; }
h1 { font-size: 20px; }
.meta { grid-template-columns: minmax(0, 1fr); gap: 0; }
.meta, .scope-list { grid-template-columns: minmax(0, 1fr); gap: 0; }
.meta dt { margin-top: 6px; }
.doc { padding: 12px; }
}
+268
View File
@@ -0,0 +1,268 @@
"""分析结论进可验证报告(第 1 波 W1-b):bundle/build.py 带上结论工件和它的输入快照,verifier.js 在 node 里核对。
- 报告引用的分析结论按原字节嵌入,连同输入快照;服务端导出时按封存的输入复算一遍(真的 app.analysis.recompute),结果记在
工件条目上(recheck)
- 口径卡指标上的可比性结论(不进台账、只从卡上引用)随卡附带,④ 按「已核对的口径卡记着的可比性」认它
- 分析工具的 tool.end、分析卡一项的 analysis.end:结论随文件附带得了才带规范字符串
- 页面只核哈希、核判定语和数是不是结论里写的那几个字(不在浏览器里重算统计):原样导出全部通过,只改显示文字的伪造
点名不通过
- 输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id,引用它的判定语、数无法核对
- 报告文档里系统写的「分析范围」点名的结论也随文件附带
"""
from __future__ import annotations
import base64
import json
from typing import Any
import pytest
from app.analysis.contract import ARTIFACT_KIND, ledger_entry
from app.bundle.build import build_bundle
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
from app.engine.evidence import build_catalog, compose_doc, iter_segments
from tests.test_analysis_evidence import SNAPSHOT, analyzed, real_result, run_sync
from tests.test_analysis_evidence_api import LAST_WEEK, THIS_WEEK
from tests.test_bundle_shown import NODE, node, reseal
pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的核对脚本")
RUN_ID = "7b1e0c3a5d9f4a2e8c6b4d2f0a1e3c5b"
RUN = {"id": RUN_ID, "workflow_id": "wf-analysis-weekly", "workflow_name": "异动周报", "version": 2,
"version_hash": "cd" * 32, "run_class": "formal", "status": "succeeded",
"started_at": "2026-10-12T01:00:00+00:00", "finished_at": "2026-10-12T01:01:00+00:00"}
GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
("fetch", "tool", "按地区取数"), ("an", "agent", "分析"), ("write", "report", "写周报"), ("out", "output", "成果")]]}
TEXT = "## 异动\n\n[[a:A1]]。华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]]。[[see:A1]]"
class Files(dict):
def put(self, obj: Any) -> str:
raw = canonical_json(obj).encode()
sha = content_hash(raw)
self[sha] = raw
return sha
def load(self, artifact: str) -> Any:
raw = self.get(artifact)
return json.loads(raw) if raw is not None else None
def bundle_of(text: str = TEXT, *, snapshot: dict[str, Any] | None = None, doc_extra: dict[str, Any] | None = None,
source_masks: dict[str, list[str]] | None = None, graph: dict[str, Any] | None = None,
extra_events: list[tuple[str, str | None, dict[str, Any]]] = (),
drop: tuple[str, ...] = ()) -> tuple[dict[str, Any], Files, str]:
"""一次封存好的运行的页面数据:(bundle, 工件, 分析结论的 id)。结论按快照真算(app.analysis.run);extra_events 接在
分析节点完成之后({analysis} 换成结论的 id),drop 是导出时服务端已经没有的工件("query":输入快照)。"""
files = Files()
snap = snapshot or SNAPSHOT
q = files.put(snap)
result = real_result(q, snap)
a = files.put(result)
qentry = {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop",
"source": snap.get("source"), "columns": snap["columns"], "rows": len(snap["rows"]), "truncated": False}
aentry = ledger_entry(result, node_id="an", exec_no=1, artifact=a, item="drop")
catalog = build_catalog(nodes={}, ledger=[qentry, aentry], loader=files.load)
doc = compose_doc(text, catalog, node_id="write", run_id=RUN_ID, loader=files.load)
doc.update(doc_extra or {})
doc_id = files.put(doc)
events: list[tuple[int, str, str | None, Any]] = []
def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None:
events.append((len(events) + 1, etype, node_id, data))
add("run.started", None, {"nodes": 4, "resumed": False, "memory_scope": "default", "collection": "default",
"approval_default": None, "tool_trust": "formal", "agent_limits": {},
"data_versions": None, "replay_protocol": 2})
add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 5, "query_artifact": q, "preview": "…"})
add("node.finished", "fetch", {"duration_ms": 6, "attempt": 1, "evidence": [qentry]})
add("node.finished", "an", {"duration_ms": 8, "attempt": 1, "evidence": [aentry]})
for etype, node_id, data in extra_events:
add(etype, node_id, json.loads(json.dumps(data).replace("{analysis}", a)))
add("report.checked", "write", {"doc_artifact": doc_id, "ok": True})
add("node.finished", "write", {"duration_ms": 9, "attempt": 1, "preview": {"text": "(报告正文)", "doc_artifact": doc_id}})
add("issuance", "out", {"tier": "formal", "mode": "citations"})
add("run.finished", None, {"output": {"周报": "(报告正文)"}})
if "query" in drop:
files.pop(q)
bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash=manifest_hash(events), read=files.get,
exported_at="2026-10-21T08:00:00+00:00", graph=graph or GRAPH,
source_masks=source_masks or {})
return bundle, files, a
def item(bundle: dict[str, Any], artifact: str) -> dict[str, Any]:
return next(a for a in bundle["artifacts"] if a["id"] == artifact)
def verdicts(bundle: dict[str, Any]) -> dict[str, Any]:
return node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"])
def test_the_analysis_and_its_input_travel_with_the_file_and_verify():
bundle, files, a = bundle_of()
analysis_item = item(bundle, a)
assert analysis_item["kind"] == ARTIFACT_KIND and "b64" in analysis_item and analysis_item["node_id"] == "an"
# 导出时服务端真的复算了一遍:按封存的输入快照(这份文件里哈希对得上的原字节)重算,逐字段相同
assert analysis_item["recheck"] == {"status": "same", "method": "decompose.additive@1",
"text": "复算一致:按封存的输入重新计算,结论逐项相同"}
[query] = [x for x in bundle["artifacts"] if x["kind"] == "query"]
assert "b64" in query
got = verdicts(bundle)
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key]), got["why"]
numbers = {n["ref"]: n for n in got["numbers"]}
assert set(numbers) == {"a:A1", "a:A1.top_share|pct", "v:A1.r0.delta"}
assert all(n["result"] == "pass" and n["expected"] == n["text"] for n in numbers.values()), numbers
assert numbers["a:A1"]["text"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
assert got["overall"]["result"] == "pass"
def test_an_analysis_whose_input_is_gone_travels_only_as_a_hash():
"""导出时服务端已经没有输入快照:证明不了没碰遮罩列,结论只给 id,也就不顺着它复算、不附带别的。"""
bundle, _, a = bundle_of(drop=("query",))
assert item(bundle, a) == {"id": a, "kind": ARTIFACT_KIND, "node_id": "an", "masked": True}
@pytest.mark.parametrize("ref, text", [("a:A1", "销售额下降 194(-8.7%),主要来自华南(-120,占 61.9%)"),
("a:A1.top_share|pct", "71.9%"), ("v:A1.r0.delta", "-220")])
def test_forging_the_shown_text_fails_by_name(ref, text):
bundle, _, _ = bundle_of()
def change(doc: dict[str, Any]) -> None:
seg = next(s for s in iter_segments(doc) if s.get("ref") == ref)
doc["markdown"] = doc["markdown"].replace(seg["text"], text, 1)
seg["cite"]["rendered"] = seg["text"] = text
art = next(x for x in bundle["artifacts"] if x["kind"] == "report_doc")
doc = json.loads(base64.b64decode(art["b64"]))
change(doc)
got = verdicts(reseal(bundle, art, doc))
forged = next(n for n in got["numbers"] if n["ref"] == ref)
assert forged["result"] == "fail" and forged["reason"].startswith(f"正文显示「{text}」")
def test_an_analysis_on_a_masked_query_travels_only_as_a_hash():
masked = {**SNAPSHOT, "columns": ["region", "phone", "week", "gmv"], "mask_columns": ["phone"],
"sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3",
"rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]}
bundle, _, a = bundle_of(snapshot=masked)
assert item(bundle, a) == {"id": a, "kind": ARTIFACT_KIND, "node_id": "an", "masked": True}
got = verdicts(bundle)
why = next(w for w in got["why"]["artifacts"] if w["kind"] == ARTIFACT_KIND)
assert why["result"] == "na" and why["reason"].startswith("这份分析结论的输入查询用到或可能用到设了遮罩的列")
assert all(n["result"] == "na" for n in got["numbers"])
assert got["overall"]["result"] != "pass"
def test_analyses_named_only_in_the_scope_travel_too():
scope = {"analysis_scope": {"phenomena": [{"alias": "A1", "op": "decompose", "phrase": "主要来自华东"}],
"decomposed": [], "excluded": [], "refused": [], "not_decomposed": None,
"note": "拆解用的列对不上目录,候选维度核对不了", "tests": 0}}
bundle, _, a = bundle_of("本周销售额有变化。", doc_extra=scope)
assert "b64" in item(bundle, a)
got = verdicts(bundle)
assert all(r == "pass" for r in got["chain"]), got["why"]["chain"]
def test_an_analysis_card_event_shows_its_content_only_when_its_conclusions_travel():
"""分析卡(节点类型 analysis)的节点完成事件:结论都随文件附带才带规范字符串;结论只给 id 的,这条也只给哈希。
Agent 节点的照旧只给哈希。"""
card = {"nodes": [{**n, "type": "analysis"} if n["id"] == "an" else n for n in GRAPH["nodes"]]}
def shown(bundle: dict[str, Any]) -> bool:
return "canonical" in next(e for e in bundle["events"] if e["type"] == "node.finished" and e["node_id"] == "an")
assert shown(bundle_of(graph=card)[0])
assert not shown(bundle_of()[0])
masked = {**SNAPSHOT, "mask_columns": ["phone"], "columns": ["region", "phone", "week", "gmv"],
"sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3",
"rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]}
assert not shown(bundle_of(graph=card, snapshot=masked)[0])
got = verdicts(bundle_of(graph=card)[0])
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key])
def test_analysis_tool_and_card_events_show_content_only_when_the_conclusion_travels():
"""分析工具的 tool.end(预览里是结论的前一截)、分析卡一项的 analysis.end(带判定语):和结论随文件附带同一个认法。"""
events = [("tool.end", "an", {"tool": "analysis_decompose", "duration_ms": 3, "analysis_artifact": "{analysis}",
"preview": "{\"analysis_artifact\": \"{analysis}\"}"}),
("analysis.end", "an", {"item": "drop", "op": "decompose", "mode": "additive", "status": "ok",
"verdict": "driver", "direction": "down", "phrase": "主要来自华东",
"reason_code": None, "analysis_artifact": "{analysis}"})]
def shown(bundle: dict[str, Any]) -> dict[str, bool]:
return {e["type"]: "canonical" in e for e in bundle["events"] if e["type"] in ("tool.end", "analysis.end")
and e["node_id"] == "an"}
open_ = bundle_of(extra_events=events)[0]
assert shown(open_) == {"tool.end": True, "analysis.end": True}
got = verdicts(open_)
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got["why"]
masked = {**SNAPSHOT, "mask_columns": ["phone"], "columns": ["region", "phone", "week", "gmv"],
"sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3",
"rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]}
assert shown(bundle_of(snapshot=masked, extra_events=events)[0]) == {"tool.end": False, "analysis.end": False}
def card_bundle() -> tuple[dict[str, Any], Files, str, str]:
"""跨两周算环比的口径卡,指标上记着口径卡执行时自动做的可比性检查(结论落成工件,不进台账):
(bundle, 工件, 可比性结论的 id, 口径卡的 id)。"""
files = Files()
last, this = files.put(LAST_WEEK), files.put(THIS_WEEK)
result = run_sync(analyzed({"fetch·base": (LAST_WEEK, last), "fetch·target": (THIS_WEEK, this)},
{"op": "comparable", "mode": None, "params": {}},
base_query="fetch·base", target_query="fetch·target"))
found = files.put(result)
card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [
{"id": "wow", "name": "环比", "value": -0.0874, "unit": "", "format": "percent_of_ratio", "decimals": 3,
"expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'gmv') - 1",
"substituted": "2026 / 2220 - 1", "recompute_ok": True, "status": "ok",
"inputs": [{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2026, "node_id": "fetch", "via": "tool_cell",
"artifact": this, "locator": {"row": 0, "column": "gmv"}},
{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2220, "node_id": "fetch", "via": "tool_cell",
"artifact": last, "locator": {"row": 0, "column": "gmv"}}],
"comparability": {"artifact": found, "verdict": result["verdict"]["code"], "phrase": result["phrase"],
"reasons": []}}]}
card_id = files.put(card)
qentries = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": x, "tool": "db_query__shop",
"source": "shop", "columns": ["gmv"], "rows": 1, "truncated": False} for x in (last, this)]
mentry = {"kind": "metric_set", "node_id": "card", "exec": 1, "artifact": card_id}
catalog = build_catalog(nodes={"card": {**card, "artifact": card_id}}, ledger=[*qentries, mentry],
loader=files.load)
doc = compose_doc("## 环比\n\n销售额环比 [[m:wow]]。", catalog, node_id="write", run_id=RUN_ID, loader=files.load)
doc_id = files.put(doc)
events: list[tuple[int, str, str | None, Any]] = []
def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None:
events.append((len(events) + 1, etype, node_id, data))
add("run.started", None, {"nodes": 4, "resumed": False, "memory_scope": "default", "collection": "default",
"approval_default": None, "tool_trust": "formal", "agent_limits": {},
"data_versions": None, "replay_protocol": 2})
for x in (last, this):
add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 5, "query_artifact": x, "preview": "…"})
add("node.finished", "fetch", {"duration_ms": 6, "attempt": 1, "evidence": qentries})
add("node.finished", "card", {"duration_ms": 7, "attempt": 1, "evidence": [mentry]})
add("report.checked", "write", {"doc_artifact": doc_id, "ok": True})
add("node.finished", "write", {"duration_ms": 9, "attempt": 1, "preview": {"text": "(报告正文)", "doc_artifact": doc_id}})
add("run.finished", None, {"output": {"周报": "(报告正文)"}})
graph = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
("fetch", "tool", "按周取数"), ("card", "metrics", "周报口径"), ("write", "report", "写周报")]]}
bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash=manifest_hash(events), read=files.get,
exported_at="2026-10-21T08:00:00+00:00", graph=graph, source_masks={})
return bundle, files, found, card_id
def test_the_comparability_on_a_card_travels_with_the_card_and_is_chained_through_it():
bundle, _, found, card_id = card_bundle()
assert "b64" in item(bundle, card_id)
comparability = item(bundle, found)
assert comparability["kind"] == ARTIFACT_KIND and "b64" in comparability and comparability["node_id"] == "card"
assert comparability["recheck"]["status"] == "same"
got = verdicts(bundle)
assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got["why"]
assert {w["kind"] for w in got["why"]["chain"]} >= {"metric_set", ARTIFACT_KIND, "query"}
[wow] = got["numbers"]
assert wow["result"] == "pass", wow
+658
View File
@@ -0,0 +1,658 @@
"""分析结论进证据链(docs/design/analysis-round/00-总方案.md §3.3,第 1 波 W1-b):引擎这一层的纯函数。
- 写作目录按台账编 A1、A2……(只收报告祖先里的),条目带着从工件取回、复验过哈希的整份结论(contract.catalog_result);
存进报告文档的目录只留摘要
- [[a:A1]] 渲染判定语(片段种类 analysis,整段涂白、不算裸数字),[[a:A1.键|换算]] 渲染具名标量,[[v:A1.r0.列]]、
[[table:A1 …]] 取结果表(表头写给人看的列名),[[see:A1]] 当依据
- 受管级别的正式出具、契约没开「单元格引用」时,Agent 现场做的分析和它查的格同一个规矩,分析卡做的照常引用(D10)
- 写作目录的「分析结论」一段:判定语、判定标准、具名的数、结果表前 table_top_k 段、附带说明、算不了的原因、来自哪几个 Q,
放不下时结果表的各段排在查询的行前面补
- 分析结论的输入查询上的 SQL 检查问题随 A 条目出现;口径卡指标上的可比性照实写进写作目录
- 复算(analysis_recheck)、代入式(analysis_formulas):用 app.analysis 真算的结论(run 交的输入照分析入口的写法,复算交
工件原内容),也用 fake_result 测目录和引用的写法
"""
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from typing import Any
import pytest
from app import analysis
from app.analysis import contract
from app.analysis.contract import CallError, effective_settings, ledger_entry
from app.analysis.testing import fake_result
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import (
ANALYSIS_CELLS_REASON,
StreamRenderer,
analysis_formulas,
analysis_recheck,
build_catalog,
compose_doc,
iter_segments,
render_markers,
resolve_ref,
verify_doc,
writer_catalog,
)
from app.engine.sql_problems import query_problems
#: 虚构零售库 shop:四个地区两周的销售额
SNAPSHOT: dict[str, Any] = {
"columns": ["region", "week", "gmv"], "column_types": {"region": "text", "week": "text", "gmv": "number"},
"rows": [["华东", "W41", 820], ["华东", "W42", 700], ["华北", "W41", 600], ["华北", "W42", 560],
["华南", "W41", 700], ["华南", "W42", 666], ["西南", "W41", 100], ["西南", "W42", 100]],
"sql": "SELECT region, week, SUM(gmv) AS gmv FROM orders GROUP BY region, week ORDER BY region, week",
"source": "shop", "truncated": False, "row_count": 8,
}
class Store(dict):
"""内容寻址的假工件库:put 落一份、交回 sha256;loader 是 artifact_store.load 的约定(取不到 None)。"""
def put(self, obj: Any) -> str:
sha = content_hash(canonical_json(obj))
self[sha] = obj
return sha
def load(self, artifact: str) -> Any:
return self.get(artifact)
#: 运行当天(服务端时区):真算的结论记进 params.today,复算按它
TODAY = date(2026, 10, 21)
#: 按 SNAPSHOT 真算一份按地区拆的可加拆解
REQUEST: dict[str, Any] = {
"op": "decompose", "mode": "additive", "hypothesis": "华东拖累",
"params": {"query": "Q2", "by": ["region"], "period": "week", "base": "W41", "target": "W42", "measure": "gmv",
"label": "销售额"}}
#: 华东按渠道再拆一层(下钻):两个渠道之和 820 → 700,和 A1 里华东那一段闭合
CHANNELS: dict[str, Any] = {
"columns": ["channel", "week", "gmv"], "column_types": {"channel": "text", "week": "text", "gmv": "number"},
"rows": [["线上", "W41", 500], ["线上", "W42", 410], ["线下", "W41", 320], ["线下", "W42", 290]],
"sql": "SELECT channel, week, SUM(gmv) AS gmv FROM orders WHERE region = '华东' GROUP BY channel, week",
"source": "shop", "truncated": False, "row_count": 4,
}
def input_snapshot(content: dict[str, Any], *, ref: str, artifact: str, node_id: str = "fetch") -> dict[str, Any]:
"""照分析入口(tools/analysis.input_snapshot)的写法交给 run:列的类型按列序写成列表,加上出处,遮罩列留空。复算交的是
工件原内容(类型是 {列: 类型}):两边算出来要逐字段相同。"""
types = content.get("column_types") or {}
return {**content, "ref": ref, "artifact": artifact, "node_id": node_id,
"column_types": [types.get(c) for c in content["columns"]], "masked": []}
async def analyzed(snapshots: dict[str, tuple[dict[str, Any], str]], request: dict[str, Any] | None = None, *,
parents: dict[str, Any] | None = None, hypothesis: str | None = None,
**params: Any) -> dict[str, Any]:
"""app.analysis.run 真算一份结论。snapshots:{调用里写的名字: (快照内容, 工件 id)};params 盖在请求的参数上,hypothesis
换掉请求里 agent 写的假设(不进复算的比较,用来让一份结论的内容独一份)。"""
request = request or REQUEST
by_ref = {ref: input_snapshot(content, ref=ref, artifact=sha) for ref, (content, sha) in snapshots.items()}
asked = {**request, "params": {**request["params"], **params}}
if hypothesis is not None:
asked["hypothesis"] = hypothesis
return dict(await analysis.run(asked, by_ref.get, settings=effective_settings(None), context={"today": TODAY},
parents=(parents or {}).get))
def run_sync(coro: Any) -> Any:
"""在同步的地方等一个协程:没有正在跑的事件循环就直接 asyncio.run,协程测试里调用(已经在循环里)换一个线程跑。"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
with ThreadPoolExecutor(1) as pool:
return pool.submit(asyncio.run, coro).result()
def real_result(q: str, snapshot: dict[str, Any] | None = None, **params: Any) -> dict[str, Any]:
"""同步的 analyzed:按 SNAPSHOT(工件 q)真算 REQUEST。"""
return run_sync(analyzed({"Q2": (snapshot or SNAPSHOT, q)}, **params))
def setup(*, item: str | None = "drop", call_id: str | None = None, result: dict[str, Any] | None = None,
snapshot: dict[str, Any] | None = None) -> tuple[Store, list[dict[str, Any]], str, str]:
"""一次查询(Q1)加一份按地区拆的分析结论(A1):(工件库, 台账, 查询快照, 分析结论)。"""
store = Store()
q = store.put(snapshot or SNAPSHOT)
res = result or fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data",
"columns": ["region", "week", "gmv"], "rows": "all"}])
a = store.put(res)
ledger = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop",
"source": "shop", "columns": ["region", "week", "gmv"], "rows": 8, "truncated": False},
ledger_entry(res, node_id="an", exec_no=1, artifact=a, item=item, call_id=call_id)]
return store, ledger, q, a
def catalog_of(**kw: Any) -> tuple[Store, dict[str, Any]]:
store, ledger, _, _ = setup(**kw)
return store, build_catalog(nodes={}, ledger=ledger, loader=store.load)
def cites(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
return [(s["kind"], s["text"], s.get("issue")) for s in iter_segments(doc) if s.get("ref")]
# --------------------------------------------------------------------------
# 契约里补的两样:叫法、按形状认分析结论
# --------------------------------------------------------------------------
def test_method_labels_and_result_shapes():
from app.analysis.contract import OP_LABELS, OPS, is_result, method_label
assert set(OP_LABELS) == set(OPS)
assert method_label("decompose", "additive") == "变化拆解(可加)" and method_label("baseline", None) == "波动基线"
assert method_label("differ", "rate") == "差异检验(比率)" and method_label("odd", "x") == "odd(x)"
assert is_result(fake_result()) and is_result(fake_result("trend", verdict="rising"))
assert not is_result(SNAPSHOT) and not is_result({"op": "decompose", "method": 1, "inputs": []})
assert not is_result(None) and not is_result([fake_result()])
# --------------------------------------------------------------------------
# 写作目录的 A 条目
# --------------------------------------------------------------------------
def test_analyses_are_numbered_by_ledger_order_and_carry_the_whole_result():
store, ledger, q, a = setup()
second = fake_result("baseline", verdict="within_band", inputs=[{"ref": "Q1", "artifact": q, "role": "data"}])
b = store.put(second)
ledger.append(ledger_entry(second, node_id="trend_agent", exec_no=1, artifact=b, call_id="call-1"))
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
assert [k for k, e in catalog.items() if e["kind"] == "analysis"] == ["A1", "A2"]
first = catalog["A1"]
assert first["result"] is store[a] and first["item"] == "drop" and "call_id" not in first
assert first["op"] == "decompose" and first["verdict"] == "driver" and first["inputs"] == [q] and first["rows"] == 4
assert first["label"].startswith("变化拆解(可加) · 销售额下降")
assert catalog["A2"]["call_id"] == "call-1" and catalog["A2"]["label"].startswith("波动基线 · ")
def test_only_analyses_from_allowed_nodes_get_a_number():
store, ledger, q, _ = setup()
late = fake_result(inputs=[{"ref": "Q1", "artifact": q, "role": "data"}], phrase="下游的结论")
ledger.insert(0, ledger_entry(late, node_id="late", exec_no=1, artifact=store.put(late), item="x"))
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load, allowed={"fetch", "an"})
assert [k for k, e in catalog.items() if e["kind"] == "analysis"] == ["A1"]
assert catalog["A1"]["node_id"] == "an"
def test_unreadable_results_are_listed_with_the_reason_and_cannot_be_cited():
store, ledger, _, a = setup()
store[a] = {**store[a], "phrase": "被改过的判定"}
def tampered(artifact: str) -> Any:
if artifact == a:
raise ValueError("哈希不一致")
return store.get(artifact)
catalog = build_catalog(nodes={}, ledger=ledger, loader=tampered)
assert "result" not in catalog["A1"] and catalog["A1"]["unreadable"] == "A1 的分析结论与哈希不一致,疑似被修改"
cite = resolve_ref("a:A1", catalog, loader=tampered)
assert cite["status"] == "unresolved" and cite["reason"] == "A1 的分析结论与哈希不一致,疑似被修改"
gone = build_catalog(nodes={}, ledger=ledger, loader=lambda _: None)
assert gone["A1"]["unreadable"] == "A1 的分析结论已不存在,无法读取"
shaped = build_catalog(nodes={}, ledger=ledger, loader=lambda art: {"rows": []} if art == a else store.get(art))
assert shaped["A1"]["unreadable"] == "A1 的工件不是分析结论"
def test_evidence_from_filters_analyses_by_the_node_that_made_them():
from app.engine.nodes.report import report_catalog
from app.engine.schema import GraphSpec
store, ledger, _, _ = setup()
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}}
spec = GraphSpec.model_validate({
"nodes": [node("fetch", "tool", tool="x"), node("an", "tool", tool="x"),
node("write", "report", evidence_from=["fetch"])],
"edges": [{"source": "fetch", "target": "an"}, {"source": "an", "target": "write"}]})
write = spec.node_map()["write"]
state = {"nodes": {}, "evidence": ledger, "input": {}}
from unittest.mock import patch
with patch("app.engine.evidence.load_artifact", store.load):
assert set(report_catalog(state, spec, write)) == {"Q1"}
write.config["evidence_from"] = ["fetch", "an"]
assert set(report_catalog(state, spec, write)) == {"Q1", "A1"}
write.config["evidence_from"] = "ancestors"
assert set(report_catalog(state, spec, write)) == {"Q1", "A1"}
# --------------------------------------------------------------------------
# 引用的解析和渲染
# --------------------------------------------------------------------------
def test_the_judgment_phrase_is_one_system_rendered_segment_and_not_bare_numbers():
store, catalog = catalog_of()
doc = compose_doc("[[a:A1]]。[[see:A1]]", catalog, loader=store.load)
[seg] = [s for s in iter_segments(doc) if s.get("ref")]
assert seg["kind"] == "analysis" and seg["state"] == "deterministic"
assert seg["text"] == "销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)"
assert seg["cite"]["kind"] == "analysis" and seg["cite"]["role"] == "value" and seg["cite"]["locator"] == {}
# 判定语里的 194、8.7、120、62 都是系统写的:不算裸数字
assert doc["violations"] == [] and doc["stats"]["uncited_numbers"] == 0
assert doc["blocks"][0]["units"][0]["cites"] == ["A1"]
assert verify_doc(doc, catalog, loader=store.load)["ok"]
def test_named_values_render_like_cells_with_the_metric_conversions():
store, catalog = catalog_of()
doc = compose_doc("华东占 [[a:A1.top_share|pct]],总变化 [[a:A1.delta_total]],最大的是 [[a:A1.top_key]]。",
catalog, loader=store.load)
assert cites(doc) == [("number", "61.9%", None), ("number", "-194", None), ("value", "华东", None)]
share = next(s for s in iter_segments(doc) if s.get("ref") == "a:A1.top_share|pct")
assert share["cite"]["locator"] == {"key": "top_share"} and share["cite"]["conv"] == "pct"
assert share["cite"]["value"] == pytest.approx(0.6186)
assert verify_doc(doc, catalog, loader=store.load)["ok"]
@pytest.mark.parametrize("ref, reason", [
("a:A9", "引用的分析结论 A9 不存在"),
("a:A1.nope", "A1 中没有名为「nope」的数"),
("a:A1|pct", "判定语是一整句话,不能换算"),
("a:A1.top_key|pct", "「华东」不是数值,不能换算为「pct」"),
("a:Q1", "分析结论的引用格式无法识别"),
("a:A1.top_share.x", "分析结论的引用格式无法识别"),
("v:A1.r9.share", "A1 请求第 9 行,但结果表只有 4 行(行号从 0 开始)"),
("v:A1.r0.nope", "A1 没有列「nope」,可用的列:region、base、target、delta、pct、share、flag"),
("v:A9.r0.share", "引用的分析结论 A9 不存在"),
])
def test_wrong_analysis_references_say_why(ref, reason):
store, catalog = catalog_of()
cite = resolve_ref(ref, catalog, loader=store.load)
assert cite["status"] == "unresolved" and cite["reason"] == reason
def test_a_q_alias_is_not_an_analysis():
store, catalog = catalog_of()
catalog["A2"] = dict(catalog["Q1"])
assert resolve_ref("a:A2", catalog, loader=store.load)["reason"] == "A2 不是分析结论"
def test_result_table_cells_and_whole_tables_use_the_labels_as_headers():
store, catalog = catalog_of()
doc = compose_doc("华东变化 [[v:A1.r0.delta]]。\n\n[[table:A1 cols=region,delta,share rows=0-1]]",
catalog, loader=store.load)
assert "| 地区 | 变化 | 占变化的比重 |" in doc["markdown"]
assert ("number", "-120", None) in cites(doc) and ("value", "华北", None) in cites(doc)
cell = next(s for s in iter_segments(doc) if s.get("ref") == "v:A1.r0.delta")
assert cell["cite"]["kind"] == "cell" and cell["cite"]["alias"] == "A1"
assert cell["cite"]["locator"] == {"row": 0, "column": "delta"}
assert doc["violations"] == []
assert verify_doc(doc, catalog, loader=store.load)["ok"]
def test_header_labels_with_digits_are_not_bare_numbers():
result = fake_result()
result["table"]["labels"] = {**result["table"]["labels"], "base": "基期(2026-W41)", "target": "本期(2026-W42)"}
store, ledger, q, _ = setup(result={**result, "inputs": [{"ref": "Q2", "artifact": "x" * 64, "role": "data"}]})
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
doc = compose_doc("[[table:A1 cols=region,base,target rows=0-1]]", catalog, loader=store.load)
assert "| 地区 | 基期(2026-W41) | 本期(2026-W42) |" in doc["markdown"]
assert doc["violations"] == [] and verify_doc(doc, catalog, loader=store.load)["ok"]
def test_dimension_values_with_digits_written_as_text_are_names():
result = fake_result()
result["table"]["rows"][0][0] = "华东3区"
store, ledger, _, _ = setup(result=result, snapshot={**SNAPSHOT, "rows": [["华北", "W41", 1]]})
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
doc = compose_doc("下降最多的是华东3区。[[see:A1]]", catalog, loader=store.load)
assert doc["violations"] == []
def test_see_accepts_analyses_rows_and_the_a_prefix():
store, catalog = catalog_of()
doc = compose_doc("下降主要来自华东。[[see:A1]] 第一段同理。[[see:A1.r0,a:A1]] 越界。[[see:A1.r9]]",
catalog, loader=store.load)
sees = [c for _, u in __import__("app.engine.evidence", fromlist=["iter_units"]).iter_units(doc) for c in u["see"]]
assert [(c["alias"], c["status"]) for c in sees] == [("A1", "resolved"), ("A1", "resolved"), ("A1", "resolved"),
("A1.r9", "unresolved")]
assert sees[-1]["reason"] == "A1 只有 4 行,没有第 9 行(行号从 0 开始)"
assert sees[0]["kind"] == "analysis"
def test_the_doc_keeps_a_summary_and_reloads_the_result_from_the_artifact():
store, catalog = catalog_of()
doc = compose_doc("[[a:A1]],华东占 [[a:A1.top_share|pct]]。", catalog, loader=store.load)
stored = doc["catalog"]["A1"]
assert "result" not in stored and stored["artifact"] and stored["rows"] == 4 and stored["verdict"] == "driver"
# 文档里的摘要照样解析得了:结论按工件取回
assert resolve_ref("a:A1", doc["catalog"], loader=store.load)["status"] == "resolved"
assert verify_doc(doc, doc["catalog"], loader=store.load)["ok"]
def test_a_tampered_phrase_in_the_doc_is_a_render_mismatch():
store, catalog = catalog_of()
doc = compose_doc("[[a:A1]]。", catalog, loader=store.load)
seg = next(s for s in iter_segments(doc) if s.get("ref"))
seg["text"] = seg["cite"]["rendered"] = seg["text"].replace("华东", "华南")
doc["markdown"] = doc["markdown"].replace("华东", "华南")
codes = [v["code"] for v in verify_doc(doc, catalog, loader=store.load)["violations"]]
assert "render_mismatch" in codes
def test_streaming_renders_the_phrase_like_the_whole_text():
store, catalog = catalog_of()
text = "结论:[[a:A1]]。华东占 [[a:A1.top_share|pct]]%。[[see:A1]]"
whole = render_markers(text, catalog, loader=store.load)
for size in (1, 3, 7):
renderer = StreamRenderer(catalog, loader=store.load)
out = "".join(renderer.feed(text[i:i + size]) for i in range(0, len(text), size)) + renderer.flush()
assert out == whole
# 具名标量后面重复写的单位去掉(同指标)
assert "61.9%。" in whole and "61.9%%" not in whole
# --------------------------------------------------------------------------
# 受管级别的出具:Agent 现场做的分析和它查的格同一个规矩,分析卡做的总能引用(D10)
# --------------------------------------------------------------------------
@pytest.mark.parametrize("ref", ["a:A1", "a:A1.top_share|pct", "v:A1.r0.share", "table:A1 cols=region,share"])
def test_agent_analyses_follow_the_cells_rule_when_cells_are_off(ref):
store, catalog = catalog_of(item=None, call_id="call-7")
cite = resolve_ref(ref, catalog, cells_allowed=False, loader=store.load)
assert cite["status"] == "unresolved" and cite["reason"] == ANALYSIS_CELLS_REASON
assert resolve_ref(ref, catalog, cells_allowed=True, loader=store.load)["status"] == "resolved"
@pytest.mark.parametrize("ref", ["a:A1", "a:A1.top_share|pct", "v:A1.r0.share", "table:A1 cols=region,share"])
def test_card_analyses_are_always_citable(ref):
store, catalog = catalog_of(item="drop")
assert resolve_ref(ref, catalog, cells_allowed=False, loader=store.load)["status"] == "resolved"
def test_query_cells_keep_their_reason_and_see_stays_allowed():
store, catalog = catalog_of(item=None, call_id="call-7")
from app.engine.evidence import CELLS_REASON, resolve_support
assert resolve_ref("v:Q1.r0.gmv", catalog, cells_allowed=False, loader=store.load)["reason"] == CELLS_REASON
assert resolve_support("A1", catalog, loader=store.load)["status"] == "resolved"
doc = compose_doc("下降主要来自华东。[[see:A1]]", catalog, cells_allowed=False, loader=store.load)
assert doc["violations"] == []
# --------------------------------------------------------------------------
# 写作目录
# --------------------------------------------------------------------------
def test_writer_catalog_lists_the_analysis_with_how_to_cite_it():
store, catalog = catalog_of()
text = writer_catalog(catalog, loader=store.load).text
section = text[text.index("分析结论("):text.index("查询结果(")]
assert "判定语单独写 [[a:A1]]" in section and "[[see:A1]]" in section
assert "- A1:变化拆解(可加),来自 Q1" in section
assert "判定语 [[a:A1]] = 销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" in section
assert "判定标准:一段占总变化一半以上算主要来源" in section
assert "具名的数(写 [[a:A1.键]]):base_total=2,220,target_total=2,026,delta_total=-194" in section
assert "结果表(共 4 段):列 region(地区)、base(基期)" in section
assert "r0:region=华东,base=820,target=700,delta=-120" in section
assert "hypothesis" not in section and "地区拖累" not in section # agent 写的假设不进写作目录
# 分析结论排在查询前面
assert text.index("分析结论(") < text.index("- Q1:")
def test_writer_catalog_shows_only_the_top_segments_and_says_how_many_are_left():
result = fake_result(settings={**fake_result()["settings"], "table_top_k": 3})
store, catalog = catalog_of(result=result)
text = writer_catalog(catalog, loader=store.load).text
assert "r2:region=华南" in text and "r3:" not in text.split("查询结果(")[0]
assert "…其余 1 段(这里只列出前 3 段;后面的行也能按行号引用)" in text
def test_refused_results_say_why_and_warn_off_judgments():
result = fake_result("baseline", status="refused", reason_code="history_short",
caveats=[{"code": "weekday_effect", "text": "日数据有星期效应"}])
store, catalog = catalog_of(result=result)
text = writer_catalog(catalog, loader=store.load).text
assert "判定语 [[a:A1]] = 变化拆解没有做成:历史只有 5 期,至少要 8 期" in text
assert "算不了:历史只有 5 期,至少要 8 期。这条结论不能当「异常」「主要来自」「呈上升趋势」这类判断的依据" in text
assert "附带说明:日数据有星期效应" in text
assert "结果表(共" not in text and "具名的数(写" not in text
def test_agent_analyses_are_see_only_when_cells_are_off():
store, catalog = catalog_of(item=None, call_id="call-7")
text = writer_catalog(catalog, cells_allowed=False, loader=store.load).text
assert "只能在句末写 [[see:A1]] 作依据" in text
assert "具名的数(写" not in text and "r0:region=华东" not in text
assert "这次出具不允许直接引用 Agent 现场做的分析:标着只能当依据的几条" in text
card_store, card = catalog_of(item="drop")
shown = writer_catalog(card, cells_allowed=False, loader=card_store.load).text
assert "具名的数(写" in shown and "标着只能当依据的几条" not in shown
def test_drill_downs_and_inputs_outside_the_catalog_are_named():
store, ledger, q, a = setup()
child = fake_result(inputs=[{"ref": "Q9", "artifact": q, "role": "data"},
{"ref": "A3", "artifact": a, "role": "parent"},
{"ref": "Q8", "artifact": "f" * 64, "role": "data"}])
ledger.append(ledger_entry(child, node_id="an", exec_no=2, artifact=store.put(child), item="drill"))
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
text = writer_catalog(catalog, loader=store.load).text
assert "- A2:变化拆解(可加),来自 Q1,下钻自 A1,另有 1 份输入不在这份目录里" in text
def test_unreadable_results_are_listed_as_not_citable():
store, ledger, _, a = setup()
catalog = build_catalog(nodes={}, ledger=ledger, loader=lambda art: None if art == a else store.get(art))
assert "- A1:变化拆解(可加),来自 Q1(A1 的分析结论已不存在,无法读取,不要引用)" in \
writer_catalog(catalog, loader=store.load).text
def test_trimmed_catalogs_keep_the_judgments_and_fill_result_tables_before_query_rows():
store, ledger, q, _ = setup()
many = [{"kind": "query", "node_id": "fetch", "exec": i + 2, "artifact": store.put({**SNAPSHOT, "sql": f"-- {i}"}),
"tool": "db_query__shop", "source": "shop", "columns": SNAPSHOT["columns"], "rows": 8} for i in range(40)]
catalog = build_catalog(nodes={}, ledger=[*ledger, *many], loader=store.load)
full = writer_catalog(catalog, loader=store.load, budget=10 ** 6)
assert not full.trimmed
trimmed = writer_catalog(catalog, loader=store.load, budget=len(full.text) // 3)
assert trimmed.trimmed and len(trimmed.text) <= trimmed.budget
assert "判定语 [[a:A1]] = " in trimmed.text and "r0:region=华东,base=820" in trimmed.text
assert trimmed.omitted # 查询有省略,分析结论的结果表没被省掉
# 更紧:判定、判定标准、具名的数照样列出,结果表退成一句,开头照实说
tight = writer_catalog(catalog, loader=store.load, budget=2000)
assert len(tight.text) <= 2000 and "判定语 [[a:A1]] = " in tight.text and "具名的数(写" in tight.text
assert "r0:region=华东,base=820" not in tight.text
assert "结果表共 4 段,列 region(地区)" in tight.text and "要列出就单独一行写 [[table:A1]]" in tight.text
assert "另有 1 条分析结论,判定和具名的数都列出了,其中 1 条没列出结果表的各段" in tight.text
def test_metric_comparability_is_spoken_in_the_writer_catalog():
card = {"kind": "metric_set", "caliber": "周报", "caliber_version": "v1", "artifact": "c" * 64, "metrics": [
{"id": "wow", "name": "环比", "value": -0.087, "unit": "", "comparability": {
"artifact": "d" * 64, "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据",
"reasons": ["本期只有 3 天数据"]}},
{"id": "yoy", "name": "同比", "value": 0.02, "unit": "", "comparability": {
"artifact": "e" * 64, "verdict": "conditional", "phrase": "两期有条件可比:两段的周末天数不同",
"reasons": ["两段的周末天数不同:4 天对 6 天"]}},
{"id": "gmv", "name": "销售额", "value": 2026, "unit": "", "comparability": {
"artifact": "f" * 64, "verdict": "comparable", "phrase": "两期可比", "reasons": []}}]}
catalog = build_catalog(nodes={"card": card}, ledger=[])
assert catalog["m:wow"]["comparability"]["verdict"] == "incomparable"
text = writer_catalog(catalog).text
assert "环比 = -0.087(两期不可比:本期只有 3 天数据。这个比较不能当结论,引用时须如实说明两期不可比)" in text
assert "同比 = 0.02(两期有条件可比:两段的周末天数不同:4 天对 6 天。引用时须如实说明这几点)" in text
assert "- [[m:gmv]] 销售额 = 2,026\n" in text + "\n"
def test_sql_check_problems_of_the_input_queries_follow_the_analysis():
bad = {**SNAPSHOT, "checks": [{"code": "fanout_sum", "level": "error",
"message": "一对多关联后求和,金额被重复计算。改成先汇总再关联"}]}
store, ledger, q, a = setup(snapshot=bad)
found = query_problems(a, loader=store.load)
assert [p.artifact for p in found] == [q] and found[0].problems == ["一对多关联后求和,金额被重复计算"]
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
text = writer_catalog(catalog, loader=store.load).text
section = text[text.index("- A1:"):text.index("查询结果(")]
assert "(这次分析的输入查询未通过 SQL 检查:一对多关联后求和,金额被重复计算。其中的数存疑" in section
# --------------------------------------------------------------------------
# 复算与代入式
# --------------------------------------------------------------------------
def test_recheck_with_the_real_operators_says_a_sealed_result_holds():
"""真算的结论按封存的输入复算(load 交工件原内容),逐字段相同;agent 写的假设不比。"""
store = Store()
q = store.put(SNAPSHOT)
sealed = real_result(q)
assert sealed["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
out = analysis_recheck(sealed, store.get)
assert out == {"status": "same", "method": "decompose.additive@1", "text": "复算一致:按封存的输入重新计算,结论逐项相同"}
assert analysis_recheck({**sealed, "hypothesis": "别的假设"}, store.get)["status"] == "same"
def test_recheck_follows_the_parent_of_a_drill_down_by_hash():
store = Store()
q, c = store.put(SNAPSHOT), store.put(CHANNELS)
top = real_result(q)
a = store.put(top)
child = asyncio.run(analyzed({"Q3": (CHANNELS, c)}, parents={"A1": top}, query="Q3", by=["channel"],
parent={"of": "A1", "key": ["华东"]}))
assert [i["role"] for i in child["inputs"]] == ["parent", "data"] and child["inputs"][0]["artifact"] == a
assert analysis_recheck(child, store.get, load_result=store.get)["status"] == "same"
# 上一层取不回:照实说取不回哪一份,不说两遍「复算」
out = analysis_recheck(child, store.get, load_result=lambda sha: None)
assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回上一层的分析结论 A1"}
def test_recheck_says_what_differs_and_what_is_missing():
store = Store()
q = store.put(SNAPSHOT)
sealed = real_result(q)
# 封存的那份和按输入算出来的不一样(判定语被改过、哈希也就是另一份):点名哪几项不同
out = analysis_recheck({**sealed, "phrase": "销售额下降,主要来自华南"}, store.get)
assert out["status"] == "different" and out["fields"] == ["phrase"]
assert out["text"] == "复算不一致:判定语和封存的不同"
# 输入快照取不回(不在封存范围、哈希对不上的调用方都交 None)
out = analysis_recheck(sealed, lambda sha: None)
assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回 Q2 的查询快照"}
# 输入快照换了一份(同一个 sha256 交回了别的内容):复算照样算,结果和封存的不同
other = {**SNAPSHOT, "rows": [["华东", "W41", 820], ["华东", "W42", 650], *SNAPSHOT["rows"][2:]]}
assert analysis_recheck(sealed, lambda sha: other)["status"] == "different"
def test_recheck_names_both_versions_when_the_method_was_upgraded(monkeypatch):
store = Store()
q = store.put(SNAPSHOT)
sealed = real_result(q)
monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2")
out = analysis_recheck(sealed, store.get)
assert out == {"status": "same", "method": "decompose.additive@1", "method_now": "decompose.additive@2",
"text": "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同"}
out = analysis_recheck({**sealed, "phrase": "别的判定"}, store.get)
assert out["status"] == "different" and out["fields"] == ["phrase"]
assert out["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同"
def refuse(result, load, *, load_result=None):
raise CallError("unknown_mode", "变化拆解要写模式:additive / ratio / factor")
monkeypatch.setattr(analysis, "recompute", refuse)
out = analysis_recheck(sealed, store.get)
assert out["status"] == "failed" and out["method_now"] == "decompose.additive@2"
assert out["text"] == ("运行时用的是 decompose.additive@1,现在是 decompose.additive@2:现在的方法按封存的输入复算不了"
"(变化拆解要写模式:additive / ratio / factor)")
def test_recheck_errors_are_said_plainly(monkeypatch):
store = Store()
q = store.put(SNAPSHOT)
sealed = real_result(q)
seen: list[Any] = []
def broken(result, load, *, load_result=None):
seen.append(load(q))
raise ZeroDivisionError("division by zero")
monkeypatch.setattr(analysis, "recompute", broken)
out = analysis_recheck(sealed, store.get)
assert out["status"] == "failed" and out["text"].startswith("复算失败:") and seen == [SNAPSHOT]
monkeypatch.setattr(analysis, "recompute", lambda result, load, *, load_result=None: None)
assert analysis_recheck(sealed, store.get)["text"] == "复算失败:复算没有交回结论"
def test_formulas_substitute_the_key_numbers():
store = Store()
q = store.put(SNAPSHOT)
formulas = {f["label"]: f["text"] for f in analysis_formulas(real_result(q))}
assert formulas == {
"总变化": "总变化 = 本期合计 − 基期合计 = 2,026 − 2,220 = -194",
"变化率": "变化率 = 总变化 ÷ 基期合计 = -194 ÷ 2,220 = -8.7%",
"占比": "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%",
}
assert analysis_formulas(fake_result(status="refused", reason_code="history_short")) == []
assert analysis_formulas(fake_result(values={})) == []
def test_formulas_for_factor_ratio_and_baseline():
store = Store()
factor_snap = {"columns": ["week", "gmv", "orders", "aov"],
"rows": [["W41", 1000, 10, 100], ["W42", 1320, 12, 110]],
"sql": "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov "
"FROM orders GROUP BY week", "source": "shop", "truncated": False}
factor = asyncio.run(analyzed({"Q1": (factor_snap, store.put(factor_snap))},
{"op": "decompose", "mode": "factor", "params": {}}, query="Q1", period="week",
base="W41", target="W42", product="gmv", factors=["orders", "aov"], label="销售额"))
texts = {f["label"]: f["text"] for f in analysis_formulas(factor)}
assert texts["总变化"] == "总变化 = 本期合计 − 基期合计 = 1,320 − 1,000 = 320"
assert texts["占比"].startswith("orders的占比 = orders的贡献 ÷ 总变化 = 210.1449167") and texts["占比"].endswith(" = 65.7%")
ratio_snap = {"columns": ["channel", "week", "refunds", "orders"],
"rows": [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000],
["线下", "W42", 26, 1000]],
"sql": "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders "
"GROUP BY 1, 2", "source": "shop", "truncated": False}
ratio = asyncio.run(analyzed({"Q1": (ratio_snap, store.put(ratio_snap))},
{"op": "decompose", "mode": "ratio", "params": {}}, query="Q1", by="channel",
period="week", base="W41", target="W42", numerator="refunds", denominator="orders",
label="退款率"))
assert [f["text"] for f in analysis_formulas(ratio)] == [
"结构效应占比 = 结构效应合计 ÷ 比率的变化 = 0 ÷ 0.018 = 0.0%",
"自身效应占比 = 自身效应合计 ÷ 比率的变化 = 0.018 ÷ 0.018 = 100.0%"]
weeks = [f"2026-{m:02d}-{d:02d}" for m, d in ((6, 8), (6, 15), (6, 22), (6, 29), (7, 6), (7, 13), (7, 20), (7, 27),
(8, 3), (8, 10))]
series = {"columns": ["week", "orders"], "column_types": {"week": "text", "orders": "number"},
"rows": [[w, v] for w, v in zip(weeks, [100, 102, 98, 101, 99, 103, 100, 97, 102, 130])],
"sql": "SELECT week, COUNT(*) AS orders FROM orders GROUP BY 1", "source": "shop", "truncated": False}
baseline = asyncio.run(analyzed({"Q1": (series, store.put(series))}, {"op": "baseline", "mode": None, "params": {}},
query="Q1", period="week", measure="orders", label="订单数"))
assert baseline["params"]["basis"] == "change" and baseline["status"] == "ok"
band, z = [f["text"] for f in analysis_formulas(baseline)]
assert band == "常规波动范围 = 中位数 ± 2 倍稳健标准差 = -9.0% 至 9.0%(中位数 0.01%,过去 8 期)"
assert z == "稳健 z = (本期 − 中位数)÷ 稳健标准差 = (27.5% − 0.01%)÷ 稳健标准差 = 6.1072657902"
assert analysis_recheck(baseline, store.get)["status"] == "same"
# --------------------------------------------------------------------------
# 血缘(D16):分析卡的结果不是口径卡的原料
# --------------------------------------------------------------------------
def test_metrics_reading_analysis_results_are_not_traced_to_raw_data():
"""口径卡读 nodes.<分析卡>.results 的输入:运行时(traced)、静态(static_stops)都认作追不到原始数据,停在分析卡。
分析结论的输入只认台账里的查询快照,复算即血缘;写死 tool_cell、agent_field 的几处不用改。"""
from app.engine.lineage import static_stops, traced, untraced
item = {"path": "nodes.an.results.drop.values.top_share", "value": "0.6186", "node_id": "an", "via": "analysis"}
assert not traced(item) and untraced([item]) == [item]
stops = static_stops({"an": {"type": "analysis", "config": {}}, "fetch": {"type": "tool", "config": {}}},
"nodes.an.results.drop.values.top_share * 100")
assert [(s.path, s.node_id) for s in stops] == [("nodes.an.results.drop.values.top_share", "an")]
+439
View File
@@ -0,0 +1,439 @@
"""分析结论进证据链(第 1 波 W1-b):证据接口、审计、报告导出。
运行是直接落库的虚构封存运行(虚构零售库 shop):一次查询(Q1)、一份按地区拆的分析结论(A1,分析卡或 Agent 做的),
报告引用判定语、具名标量、结果表的格。结论用 app.analysis 真算(tests.test_analysis_evidence.analyzed),出处链里的复算是
真的 recompute:按 sha256 取回封存的输入快照、下钻的上一层结论,逐字段比。只有要它出错、升版的几处用 monkeypatch。
- 证据图:A1 条目带算子、判定;「派生自」的边 A1 → Q1
- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;Agent 写的假设标出来;复算不了照实写,不让接口出错
- 审计:分析判定、分析结论的叫法;分析说法核对的违规单列一行
- 报告导出:数据块里有分析结论那几步;文档带「分析范围」的,正文后面渲染、编号点得开
- 指标步骤带可比性:口径卡指标上的可比性结论(不进台账)照卡上记的 sha256 取回,点得开(判定 → … → 复算 → 封存)
"""
from __future__ import annotations
import csv
import io
import json
import re
import uuid
from collections.abc import Sequence
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from app import analysis
from app.analysis import contract
from app.analysis.contract import ARTIFACT_KIND, ledger_entry
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 CHANNELS, SNAPSHOT, analyzed
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
def _query_entry(content: dict[str, Any], artifact: str) -> dict[str, Any]:
return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "tool": "db_query__shop",
"source": "shop", "columns": content["columns"], "rows": len(content["rows"]), "truncated": False}
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, queries: Sequence[dict[str, Any]] = (),
parents: Sequence[dict[str, Any]] = ()) -> dict[str, Any]:
"""落一次封存好的运行:查询快照(SNAPSHOT,再加 queries)、分析结论(parents 在前,result 在后;result 缺省按 SNAPSHOT
真算一份按地区拆的可加拆解)、(可选)口径卡、报告文档都按真实的工件写,事件按 runner 的算法封存。"""
run_id = uuid.uuid4().hex
q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot", run_id=run_id, node_id="fetch")
more = [await artifact_store.put_json(c, kind="query_snapshot", run_id=run_id, node_id="fetch") for c in queries]
res = result or await analyzed({"Q2": (SNAPSHOT, q)})
qentries = [_query_entry(SNAPSHOT, q), *(_query_entry(c, a) for c, a in zip(queries, more))]
aentries = []
for i, parent in enumerate(parents):
found = await artifact_store.put_json(parent, kind=ARTIFACT_KIND, run_id=run_id, node_id="an")
aentries.append(ledger_entry(parent, node_id="an", exec_no=1, artifact=found, item=f"up{i}"))
a = await artifact_store.put_json(res, kind=ARTIFACT_KIND, run_id=run_id, node_id="an")
aentries.append(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": qentries}),
(3, "node.finished", "an", {"evidence": aentries})]
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=[*qentries, *aentries])
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, "queries": more}
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"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
assert judged["criterion"].startswith("一段占总变化的一半以上算主要来源")
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 {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis"}
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"]
# 真的复算:按 sha256 取回封存的输入快照,逐字段和封存的比
assert again == {"step": "analysis_recompute", "alias": "A1", "status": "same", "method": "decompose.additive@1",
"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):
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.618556701031", "conv": "pct"}
assert value["segment"]["text"] == "61.9%" and value["chain"][0]["render_ok"] is True
calc = value["chain"][2]
assert next(v for v in calc["values"] if v["key"] == "top_share") == {
"key": "top_share", "raw": "0.618556701031", "rendered": "0.618556701", "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):
"""复算的 load 交工件原内容(不加出处、不改列的类型),只认封存范围内交回过的查询;别的一律当取不回。"""
seen: dict[str, Any] = {}
real = analysis.recompute
def recompute(result, load, *, load_result=None):
seen["input"] = load(result["inputs"][0]["artifact"])
seen["missing"] = load("0" * 64)
return real(result, load, load_result=load_result)
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["status"] == "same" and seen["input"] == SNAPSHOT and seen["missing"] is None
async def test_an_input_outside_the_sealed_scope_is_not_recomputed_from(client):
"""结论记着的输入快照不是这次运行交回过的(台账里没有):复算照实说取不回,不去工件库里捞。"""
stray = {**SNAPSHOT, "sql": SNAPSHOT["sql"] + " -- 另一次"}
s_id = await artifact_store.put_json(stray, kind="query_snapshot")
made = await sealed_run(TEXT, result=await analyzed({"Q2": (stray, s_id)}))
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["status"] == "failed" and again["text"] == "复算失败:取不回 Q2 的查询快照"
given = next(s for s in body["chain"] if s["step"] == "analysis_input")
assert given["sealed"] is False and given["query"] is None
async def test_an_upgraded_method_names_both_versions(client, monkeypatch):
made = await sealed_run(TEXT)
monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2")
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["method"] == "decompose.additive@1" and again["method_now"] == "decompose.additive@2"
assert again["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同"
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 = await analyzed({"Q2": (SNAPSHOT, q)}, hypothesis=f"只给这一次:{uuid.uuid4().hex}")
assert own["hypothesis"].startswith("只给这一次:")
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(client):
"""给了总数(headline)的拆解:两格各是一份输入,和结论里的总数闭合;复算照样按 sha256 取回两份快照。"""
totals = {"columns": ["gmv_base", "gmv"], "column_types": {"gmv_base": "number", "gmv": "number"},
"rows": [[2220, 2026]], "sql": "SELECT 2220 AS gmv_base, 2026 AS gmv", "source": "shop",
"truncated": False}
t = artifact_store.content_hash(artifact_store.canonical_json(totals))
q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT))
result = await analyzed({"Q2": (SNAPSHOT, q), "Q5": (totals, t)}, headline=["Q5.r0.gmv_base", "Q5.r0.gmv"])
made = await sealed_run("[[a:A1]]。", result=result, queries=[totals])
body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
inputs = [s for s in body["chain"] if s["step"] == "analysis_input"]
assert [s["role"] for s in inputs] == ["headline_base", "headline_target", "data"]
assert inputs[0]["closure"] == {"total_key": "base_total", "total": "2,220", "headline": "2,220", "closed": True}
assert inputs[1]["closure"] == {"total_key": "target_total", "total": "2,026", "headline": "2,026", "closed": True}
assert inputs[1]["query"] == "Q2" and inputs[1]["sealed"] is True and inputs[2]["query"] == "Q1"
assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same"
async def test_a_drill_down_points_at_its_parent_and_recomputes_from_it(client):
"""下钻(华东按渠道再拆):输入里有上一层结论(parent),落到报告目录的 A1;复算按 sha256 取回台账记过的上一层。"""
q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT))
c = artifact_store.content_hash(artifact_store.canonical_json(CHANNELS))
top = await analyzed({"Q2": (SNAPSHOT, q)})
child = await analyzed({"Q3": (CHANNELS, c)}, parents={"A1": top}, query="Q3", by=["channel"],
parent={"of": "A1", "key": ["华东"]})
made = await sealed_run("[[a:A2]]。", result=child, parents=[top], queries=[CHANNELS])
body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A2')}")).json()
inputs = [s for s in body["chain"] if s["step"] == "analysis_input"]
assert [s["role"] for s in inputs] == ["parent", "data"]
assert inputs[0]["alias"] == "A1" and inputs[0]["phrase"] == top["phrase"] and inputs[0]["sealed"] is True
assert inputs[0]["role_label"] == "上一层拆解" and inputs[1]["query"] == "Q2"
again = next(s for s in body["chain"] if s["step"] == "analysis_recompute")
assert again["status"] == "same", again
graph = (await client.get(f"/api/runs/{made['run']}/evidence")).json()
edges = {(e["from"], e["to"], e["rel"]) for e in graph["edges"]}
assert {("A2", "A1", "derived_from"), ("A2", "Q2", "derived_from"), ("A1", "Q1", "derived_from")} <= edges
#: 本周、上周两份同形查询(只差日期):口径卡跨这两份算环比时自动做可比性检查
WINDOW = "SELECT SUM(gmv) AS gmv FROM orders WHERE order_date >= '{a}' AND order_date < '{b}'"
LAST_WEEK = {"columns": ["gmv"], "column_types": {"gmv": "number"}, "rows": [[2220]],
"sql": WINDOW.format(a="2026-10-05", b="2026-10-12"), "source": "shop", "truncated": False}
THIS_WEEK = {**LAST_WEEK, "rows": [[2026]], "sql": WINDOW.format(a="2026-10-12", b="2026-10-19")}
async def comparability_card() -> tuple[dict[str, Any], dict[str, Any]]:
"""一张跨两周算环比的口径卡,指标上记着口径卡执行时自动做的可比性检查(结论落成工件,不进台账):(卡, 可比性结论)。"""
last = artifact_store.content_hash(artifact_store.canonical_json(LAST_WEEK))
this = artifact_store.content_hash(artifact_store.canonical_json(THIS_WEEK))
result = await analyzed({"fetch·base": (LAST_WEEK, last), "fetch·target": (THIS_WEEK, this)},
{"op": "comparable", "mode": None, "params": {}},
base_query="fetch·base", target_query="fetch·target")
found = await artifact_store.put_json(result, kind=ARTIFACT_KIND, node_id="card")
verdict = result["verdict"]["code"]
card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [
{"id": "wow", "name": "环比", "value": -0.0874, "unit": "",
"expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'gmv') - 1",
"substituted": "2026 / 2220 - 1", "recompute_ok": True,
"inputs": [{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2026, "node_id": "fetch", "via": "tool_cell",
"artifact": this, "locator": {"row": 0, "column": "gmv"}},
{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2220, "node_id": "fetch", "via": "tool_cell",
"artifact": last, "locator": {"row": 0, "column": "gmv"}}],
"comparability": {"artifact": found, "verdict": verdict, "phrase": result["phrase"], "reasons": []}}]}
return card, result
async def test_the_metric_step_opens_the_comparability_result(client):
"""可比性结论不进台账、只从口径卡的指标集里引用:照卡上记的 sha256 取回,随卡封存;点得开它的整条出处链,复算是真的。"""
card, result = await comparability_card()
made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK])
body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'm:wow')}")).json()
view = body["chain"][0]["comparability"]
assert view["verdict"] == result["verdict"]["code"] and view["phrase"] == result["phrase"]
assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@1"
steps = view["steps"]
assert [s["step"] for s in steps] == ["analysis", "analysis_method", "analysis_calc", "analysis_input",
"analysis_input", "analysis_recompute", "analysis_seal"]
judged, method, calc, base, target, again, seal = steps
assert judged["origin"] == "metric" and judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True
assert judged["method_label"] == "可比性检查" and judged["node_id"] == "card"
assert [r[0] for r in calc["table"]["rows"]] == ["同口径", "窗口长度", "整期", "星期构成", "数据问题"]
assert (base["role"], target["role"]) == ("base", "target")
assert base["query"] == "Q2" and target["query"] == "Q3" and base["sealed"] is True
assert again["status"] == "same" and again["text"] == "复算一致:按封存的输入重新计算,结论逐项相同"
assert seal["via"] == "metric_set" and seal["ledger"] is False and seal["sealed"] is True
assert seal["note"].startswith("这份可比性结论不进台账")
# 两份输入快照的查询步骤在指标链里(指标的输入就是这两份),可比性的链里不再给
assert [s["step"] for s in body["chain"]].count("query") == 2 and "query" not in [s["step"] for s in steps]
async def test_a_card_without_the_comparability_artifact_says_only_what_it_recorded(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_app_metric_endpoint_opens_the_comparability_too(client):
card, _ = await comparability_card()
made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK])
body = (await client.get(f"/api/runs/{made['run']}/evidence/metrics/card/wow")).json()
view = body["chain"][0]["comparability"]
assert view["sealed"] is True and view["steps"][-2]["status"] == "same"
assert [s["query"] for s in view["steps"] if s["step"] == "analysis_input"] == [None, None]
# --------------------------------------------------------------------------
# 审计
# --------------------------------------------------------------------------
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,占 61.9%)"}],
"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'<script type="application/json" id="report-data">(.*?)</script>', 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 '<section class="scope"' in html and "<dt>现象</dt>" in html and "<dt>拆过</dt>" in html
assert "<dt>算不了</dt>" in html and "<dd>渠道</dd>" 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 '<section class="scope"' not in html and 'data-seg="scope:A1"' not in html
+117
View File
@@ -0,0 +1,117 @@
"""分析结论进证据链(第 1 波 W1-b):结论句裁判的摘录、口径账本。
- 裁判:引用了分析结论的句子,摘录里有方法、判定、判定标准、关键数、结果表和输入查询的 SQL;判定是系统算的,规则里另加
一条(只判句子说法和判定一致不一致)——没有分析结论的报告,裁判看到的规则一字不差
- 口径账本:分析结论里的数(判定语、具名标量、结果表的格)不标口径、不比对、不进整题判断的计数(D11)
"""
from __future__ import annotations
from typing import Any
from app.analysis.testing import fake_result
from app.engine import judge as judging
from app.engine.evidence import build_catalog, compose_doc, iter_segments
from app.ledger import labels as ledger_labels
from tests.test_analysis_evidence import setup
def composed(text: str, **kw: Any) -> tuple[Any, dict[str, Any], dict[str, Any]]:
store, ledger, _, _ = setup(**kw)
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
return store, catalog, compose_doc(text, catalog, loader=store.load)
# --------------------------------------------------------------------------
# 裁判
# --------------------------------------------------------------------------
def test_the_judge_gets_the_method_the_judgment_and_the_input_sql():
store, catalog, doc = composed("[[a:A1]],华东变化 [[v:A1.r0.delta]]。[[see:A1]]")
request = judging.prepare(doc, catalog, loader=store.load)
[cand] = request.cands
assert cand.aliases == ("A1",) and "A1" in cand.whole and ("A1", 0) in cand.rows and ("A1", "delta") in cand.cols
excerpt = request.excerpts["A1"]
assert excerpt.startswith("【A1】分析结论(变化拆解(可加),方法 decompose.additive@1)")
assert "判定:销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" in excerpt
assert "判定标准:一段占总变化一半以上算主要来源" in excerpt
assert "关键数:base_total=2,220 | target_total=2,026 | delta_total=-194" in excerpt
assert "结果表(共 4 行)列:地区 | 基期 | 本期 | 变化" in excerpt and "r0:华东 | 820 | 700 | -120" in excerpt
assert "输入:取自查询 Q1,用到 region、week、gmv 列" in excerpt
assert "查询 Q1 的 SQL:SELECT region, week, SUM(gmv) AS gmv FROM orders" in excerpt
assert "地区拖累" not in excerpt # agent 写的假设不进摘录
system = request.messages(request.cands)[0].content
assert judging.JUDGE_RULES in system and judging.ANALYSIS_JUDGE_PROMPT in system
def test_reports_without_analyses_see_exactly_the_old_rules():
store, catalog, doc = composed("华东本周 [[v:Q1.r1.gmv]]。[[see:Q1]]")
request = judging.prepare(doc, catalog, loader=store.load)
system = request.messages(request.cands)[0].content
assert system == f"{judging.JUDGE_ROLE}\n\n{judging.JUDGE_RULES}"
def test_excerpts_reload_the_result_from_the_stored_summary_and_say_refusals():
store, catalog, doc = composed("下降来自哪里还说不清。[[see:A1]]",
result=fake_result(status="refused", reason_code="too_few_segments"))
request = judging.prepare(doc, doc["catalog"], loader=store.load) # 文档里的目录只留摘要:按工件取回
assert "判定:算不了(历史只有 5 期,至少要 8 期)" in request.excerpts["A1"]
gone = judging.prepare(doc, doc["catalog"], loader=lambda _: None)
assert "A1" not in gone.excerpts
def test_drill_downs_name_the_parent_in_the_excerpt():
store, ledger, q, a = setup()
from app.analysis.contract import ledger_entry
child = fake_result(inputs=[{"ref": "Q1", "artifact": q, "role": "data", "columns": ["region"]},
{"ref": "A3", "artifact": a, "role": "parent"}])
ledger.append(ledger_entry(child, node_id="an", exec_no=2, artifact=store.put(child), item="drill"))
catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load)
doc = compose_doc("华东里主要来自上海。[[see:A2]]", catalog, loader=store.load)
excerpt = judging.prepare(doc, catalog, loader=store.load).excerpts["A2"]
assert "下钻自:A1" in excerpt
# --------------------------------------------------------------------------
# 口径账本:分析结论的数不标口径
# --------------------------------------------------------------------------
def test_analysis_numbers_get_no_caliber_label_and_are_not_counted():
store, catalog, doc = composed("[[a:A1]],华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]];"
"华东上周 [[v:Q1.r0.gmv]]。")
q = catalog["Q1"]["artifact"]
queries = {q: {"artifact": q, "status": "none", "candidates": []}}
labels = ledger_labels.label_doc(doc, queries, {}, lambda _: None)
labelled = {s["id"]: s.get("ref") for s in iter_segments(doc) if s["id"] in labels}
assert list(labelled.values()) == ["v:Q1.r0.gmv"]
whole = ledger_labels.whole_of(labels, {})
assert whole["counts"] == {"caliber": 0, "related": 0, "reasoning": 1, "unknown": 0}
assert ledger_labels.referenced_columns([doc], lambda _: None) == {q: {"gmv"}}
def test_a_report_citing_only_analyses_has_nothing_to_label():
store, catalog, doc = composed("[[a:A1]]。[[see:A1]]")
assert ledger_labels.label_doc(doc, {}, {}, lambda _: None) == {}
assert ledger_labels.referenced_columns([doc], lambda _: None) == {}
assert ledger_labels.whole_of({}, {})["reason"] == "报告里没有可标口径的数"
def test_the_judge_and_the_writer_see_the_comparability_of_a_metric():
"""口径卡指标上的可比性(口径卡执行时自动做的检查):裁判的摘录里原样给判定语;写作目录没记原因的照判定语写,不重复判定。"""
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import writer_catalog
card = {"kind": "metric_set", "caliber": "周报", "caliber_version": "v1", "metrics": [
{"id": "wow", "name": "环比", "value": -0.087, "unit": "", "expression": "1", "inputs": [],
"comparability": {"artifact": "d" * 64, "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据",
"reasons": []}}]}
sha = content_hash(canonical_json(card))
catalog = build_catalog(nodes={"card": {**card, "artifact": sha}}, ledger=[])
assert "环比 = -0.087(两期不可比:本期只有 3 天数据。这个比较不能当结论" in writer_catalog(catalog).text
doc = compose_doc("环比 [[m:wow]],需求走弱。", catalog)
request = judging.prepare(doc, catalog, loader={sha: card}.get)
[excerpt] = request.excerpts.values()
assert "两期可比性(系统检查):两期不可比:本期只有 3 天数据" in excerpt
+202
View File
@@ -0,0 +1,202 @@
"""分析结论在两份自包含页面里的渲染(第 1 波 W1-b):报告导出(report_export/runtime.js)点开判定语、分析范围里的编号;
可验证报告(bundle/verifier.js)点开判定语看分析结论一节、正文后面的分析范围。
浏览器里的完整检查(check-report-export、check-bundle)第 2 波由编排者跑;这里在 node 里用一个最小的 DOM 替身把两份脚本
真正跑一遍,挡住渲染分析结论那几步时的脚本错误(引用了不存在的文案、函数),并核对面板里写出来的字。
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
import app.bundle
import app.report_export
from app.main import app as asgi
from tests.test_analysis_bundle import bundle_of, card_bundle
from tests.test_analysis_evidence_api import LAST_WEEK, SCOPE, TEXT, THIS_WEEK, comparability_card, sealed_run, seg_id
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的脚本")
RUNTIME = Path(app.report_export.__file__).parent / "runtime.js"
VERIFIER = Path(app.bundle.__file__).parent / "verifier.js"
#: 最小的 DOM 替身:元素、文字、片段,getElementById 按 id 建元素;textContent 递归拼接
DOM = r"""
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
class Node_ {
constructor() { this.children = []; this.parent = null }
append(...kids) {
for (const k of kids) {
const n = typeof k === 'string' ? new Text_(k) : k
if (n instanceof Frag_) { for (const c of n.children) { c.parent = this; this.children.push(c) } n.children = [] }
else { n.parent = this; this.children.push(n) }
}
}
replaceChildren(...kids) { this.children = []; this.append(...kids) }
get textContent() { return this.children.map((c) => c.textContent).join('') }
set textContent(v) { this.children = [new Text_(String(v))] }
walk(f) { f(this); for (const c of this.children) c.walk(f) }
}
class Text_ extends Node_ {
constructor(t) { super(); this.text = t }
get textContent() { return this.text }
set textContent(v) { this.text = String(v) }
}
class Frag_ extends Node_ {}
class El_ extends Node_ {
constructor(tag) {
super(); this.tag = tag; this.attrs = {}; this.dataset = {}; this.style = {}; this.hidden = false; this.listeners = {}
this.className = ''; const cls = new Set(); this.classList = { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) }
}
setAttribute(k, v) { this.attrs[k] = String(v); if (k.startsWith('data-')) this.dataset[camel(k.slice(5))] = String(v) }
getAttribute(k) { return this.attrs[k] ?? null }
removeAttribute(k) { delete this.attrs[k] }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f) }
focus() {} scrollIntoView() {}
getBoundingClientRect() { return { top: 0, bottom: 0 } }
querySelector() { return new El_('span') }
closest() { return this }
}
globalThis.Node = Node_
const ids = {}
globalThis.window = { scrollBy() {} }
globalThis.document = {
readyState: 'complete', activeElement: null, documentElement: new El_('html'),
getElementById(id) { return ids[id] ||= new El_('div') },
createElement(tag) { return new El_(tag) }, createTextNode(t) { return new Text_(t) },
createDocumentFragment() { return new Frag_() }, addEventListener() {}, querySelectorAll() { return [] },
}
const fs = require('fs')
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
const find = (root, pred) => { const out = []; root.walk((n) => { if (n instanceof El_ && pred(n)) out.push(n) }); return out }
"""
RUN_RUNTIME = DOM + r"""
document.getElementById('report-data').textContent = input.data
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
const out = {}
for (const seg of input.segs) {
const el = new El_('button')
for (const [k, v] of Object.entries(seg)) el.setAttribute(`data-${k}`, v)
el.textContent = seg.text
for (const f of document.getElementById('report').listeners.click || []) f({ target: el })
out[seg.seg] = { title: document.getElementById('detail-title').textContent,
body: document.getElementById('detail-body').textContent,
steps: find(document.getElementById('detail-body'), (n) => n.attrs['data-step']).map((n) => n.attrs['data-step']) }
}
process.stdout.write(JSON.stringify(out))
"""
RUN_VERIFIER = DOM + r"""
document.getElementById('bundle-data').textContent = input.data
document.getElementById('seal-hash').textContent = input.seal
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
setTimeout(() => {
const reports = document.getElementById('reports')
const out = { scope: find(reports, (n) => 'data-scope' in n.attrs).map((n) => n.textContent), opened: {} }
for (const btn of find(reports, (n) => n.attrs['data-num'])) {
for (const f of btn.listeners.click || []) f()
out.opened[btn.attrs['data-num']] = document.getElementById('detail-body').textContent
}
out.verified = document.documentElement.attrs['data-verified'] ?? null
process.stdout.write(JSON.stringify(out))
}, 200)
"""
def run_node(script: str, path: Path, payload: dict[str, Any]) -> Any:
done = subprocess.run([NODE, "-e", script, str(path)], input=json.dumps(payload), capture_output=True, text=True,
timeout=120)
assert done.returncode == 0, done.stderr[-3000:]
return json.loads(done.stdout)
async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers():
made = await sealed_run(TEXT, doc_extra=SCOPE)
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client:
html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
data = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S).group(1)
phrase = seg_id(made["doc"], "a:A1")
cell = seg_id(made["doc"], "v:A1.r0.delta")
segs = [{"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]},
{"seg": cell, "kind": "number", "state": "deterministic", "text": "-120"},
{"seg": "scope:A1", "kind": "analysis", "state": "deterministic", "text": "A1"}]
# 现查结果的三种写法:按运行当天现算的界、一列一个值都没有、表的范围(结论里的 probes 原样交给页面)
parsed = json.loads(data)
method = next(s for s in parsed["chains"][parsed["refs"][phrase]] if s["step"] == "analysis_method")
method["probes"] = [
{"source": "shop", "expr": "date('now', '-7 days')", "value": "2026-10-14", "basis": "live",
"at": "2026-10-21T09:00:00"},
{"source": "shop", "table": "orders", "column": "refund_date", "min": None, "max": None, "basis": "live",
"at": "2026-10-21T09:00:00"},
{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01", "max": "2026-10-20",
"basis": "profile", "at": "2026-10-21T09:00:00"}]
out = run_node(RUN_RUNTIME, RUNTIME, {"data": json.dumps(parsed, ensure_ascii=False), "segs": segs})
opened = out[phrase]
assert opened["steps"] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query",
"analysis_recompute", "analysis_seal"]
assert opened["title"].startswith("判定「销售额下降")
body = opened["body"]
for text in ("判定标准一段占总变化的一半以上算主要来源", "Agent 写的假设华东拖累", "实际用的阈值", "「主要来源」的占比",
"华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%", "结果表(列出 4 行,共 4 行)",
"复算一致:按封存的输入重新计算,结论逐项相同", "封存范围内的台账记过这份分析结论",
"按运行当天现算的界 date('now', '-7 days') = 2026-10-14(现查 · 2026-10-21T09:00:00)",
"orders.refund_date:这一列一个值都没有(现查", "orders.order_date:2025-01-01 至 2026-10-20(剖析"):
assert text in body, text
assert "结果表第 1 行「delta」" in out[cell]["body"]
assert out["scope:A1"]["steps"] == opened["steps"]
async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope():
bundle, _, _ = bundle_of(doc_extra=SCOPE)
out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]})
assert out["verified"] == ""
[scope] = out["scope"]
assert scope.startswith("分析范围") and "现象A1:销售额下降" in scope and "拆过A1(按地区):主要来自华东" in scope
assert "没拆渠道" in scope and "这次做了 2 次差异检验" in scope
phrase = next(text for text in out["opened"].values() if "出处分析结论 A1 的判定语" in text)
assert "核对结果✓ 通过" in phrase and "分析结论里的值「销售额下降 194(-8.7%)" in phrase
assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本decompose.additive@1" in phrase
assert "输入数据 · Q1" in phrase # 输入的角色写叫法,不写 data
assert "复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论;本页只核哈希" in phrase
value = next(text for text in out["opened"].values() if "具名的数「top_share」" in text)
assert "引用的值0.618556701031" in value and "正文显示61.9%(与按显示规则渲染的结果一致)" in value
scope_item = next(text for key, text in out["opened"].items() if key.endswith("|scope:A1"))
assert "核对结果✓ 通过" in scope_item and "正文显示" not in scope_item
async def test_the_export_page_opens_the_comparability_on_a_metric():
"""口径卡指标上的可比性:指标那一步里写判定,点得开那份可比性结论(同分析结论的几步,收在一个可展开的块里)。"""
card, result = await comparability_card()
made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK])
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client:
html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
data = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S).group(1)
wow = seg_id(made["doc"], "m:wow")
out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [
{"seg": wow, "kind": "number", "state": "deterministic", "text": "-0.0874"}]})
opened = out[wow]
assert opened["steps"][:1] == ["metric"]
assert opened["steps"][opened["steps"].index("analysis"):] [:3] == ["analysis", "analysis_method", "analysis_calc"]
for text in ("两期可比性", "展开这份可比性结论", "可比性检查", "口径卡执行时自动做的可比性检查", result["phrase"],
"同口径", "复算一致:按封存的输入重新计算,结论逐项相同", "记在口径卡的指标集里,随卡封存(不进台账)"):
assert text in opened["body"], text
def test_the_bundle_page_shows_the_comparability_in_the_metric_detail():
bundle, _, _, _ = card_bundle()
out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]})
assert out["verified"] == ""
[detail] = out["opened"].values()
for text in ("两期可比性", "判定两期可比", "判定标准只差日期", "方法与版本comparable@1", "各项检查", "同口径",
"复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论"):
assert text in detail, text