mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:01:59 +00:00
合入 W1-b 证据接入:台账 A、写作目录、a / v:A / table:A / see:A 引用、出处链与复算、证据图、审计、导出与可验证报告、裁判摘录、账本跳过
This commit is contained in:
@@ -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
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+613
-50
File diff suppressed because it is too large
Load Diff
+118
-3
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user