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