证据接入按 W1-a、W1-c 的实际接口对齐:复算就是真的 app.analysis.recompute——load 交查询快照工件的原内容、load_result 交上一层结论(只认封存范围内交回过的查询、台账记过的结论,取不回照实说取不回哪一份),方法升没升版按结论里的 method 和现在的 contract.METHODS 比(升了版、复算成立的写结果同不同,现在的方法复算不了的写明原因),去掉为算子没落地准备的那一套;关键数的代入式按真的具名的数写(乘积拆解补上,波动基线按变化率比的按百分比写,负数加括号)。口径卡指标上的可比性结论(不进台账、只从卡上引用)点得开:证据面板的指标步骤带它整条出处链(判定 → 方法 → 计算 → 输入 → 复算 → 封存,封存按卡上记的 sha256 认),报告导出收在一个可展开的块里,可验证报告随附带的卡一起附上、④ 按已核对的卡认它、数字详情里展示各项检查和服务端复算;裁判的指标摘录、写作目录照卡上记的判定语写。现查结果分清按运行当天现算的界、一列一个值都没有、表的范围;可验证报告里分析工具的 tool.end、分析卡的 analysis.end 和结论同一个认法才带内容,输入的角色写叫法。测试改用真算的结论(run 交的输入照分析入口的写法,复算交工件原内容),下钻、总数闭合、方法升版、可比性端到端都过真的复算

This commit is contained in:
ThearchyHelios
2026-10-21 13:49:09 +08:00
parent d2b5096e97
commit cb2fdd860f
11 changed files with 811 additions and 244 deletions
+64 -40
View File
@@ -113,7 +113,6 @@ from app.engine.evidence import (
RenderError,
analysis_card,
analysis_formulas,
analysis_input_snapshot,
analysis_recheck,
build_catalog,
closest_entities,
@@ -987,6 +986,11 @@ 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",
@@ -1051,7 +1055,8 @@ def _analysis_rendered(cite: dict[str, Any], result: dict[str, Any]) -> str | No
async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], doc: dict[str, Any],
sealed: _Sealed, masks: _Masks) -> list[dict[str, Any]]:
sealed: _Sealed, masks: _Masks, *, chained: bool | None = None,
queries: bool = True) -> list[dict[str, Any]]:
"""分析结论的出处链(00-总方案 §3.3):判定 → 方法 → 计算 → 输入 → 复算 → 封存。
- 判定(analysis):判定语、判定标准、判定码和点名的段、附带说明、算不了的原因,这一处引用的是哪一项(判定语、具名标量、
@@ -1064,19 +1069,24 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict
- 复算(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
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": "card" if analysis_card(entry) else "agent",
"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,
@@ -1086,7 +1096,10 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict
seal = {"step": "analysis_seal", "alias": alias, "node_id": entry.get("node_id"), "artifact": artifact or None,
"ledger": info is not None, "hash_ok": hash_ok, "eid_ok": judged["eid_ok"],
"sealed": in_ledger and hash_ok is True and judged["eid_ok"]}
if info is None:
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
@@ -1106,12 +1119,12 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict
for k in _OP_SETTINGS.get(str(result.get("op")), ()) if k in settings],
"probes": [p for p in result.get("probes") or [] if isinstance(p, dict)],
}
inputs, queries = await _analysis_inputs(alias, result, catalog, doc, sealed, masks)
hidden = {str(c).lower() for q in queries for c in q.get("masked") or []}
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, *queries, recheck, seal]
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]:
@@ -1154,10 +1167,11 @@ def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hi
async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str, Any], doc: dict[str, Any],
sealed: _Sealed, masks: _Masks) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sealed: _Sealed, masks: _Masks, *, queries: bool = True
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""输入:每份一步(analysis_input),再给每份输入快照一个查询步骤(_query_steps,高亮用到的列;整份用到的给前
MAX_WINDOW_ROWS 行)。headline 的那一格另核对和结论里的总数闭不闭合(照实写判定是不是 unclosed,不在这里重算容差);
下钻的上一层落到报告目录的哪个 A。"""
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"
@@ -1198,37 +1212,35 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str
rows = list(range(MAX_WINDOW_ROWS)) if picked == "all" else \
[r for r in picked if isinstance(r, int)][:MAX_WINDOW_ROWS] if isinstance(picked, list) else []
wanted.extend((r, column) for r in rows)
queries: list[dict[str, Any]] = []
for artifact, picked in cells.items():
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):
queries.append({**sub, "input_of": alias})
return steps, queries
found.append({**sub, "input_of": alias})
return steps, found
async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]:
"""复算:按 sha256 取回封存范围内的输入快照(evidence.analysis_input_snapshot)和下钻的上一层结论,在线程里复算、比较
(evidence.analysis_recheck)。不在封存范围内的输入一律当取不回来。"""
snapshots: dict[str, Any] = {}
parents: dict[str, Any] = {}
for item in result.get("inputs") or []:
artifact = str((item or {}).get("artifact") or "") if isinstance(item, dict) else ""
if not artifact or artifact in snapshots or artifact in parents:
continue
if item.get("role") == "parent":
parents[artifact] = _analysis_of(artifact)[0] if artifact in sealed.analyses else None
continue
info = sealed.queries.get(artifact)
content, ok = _fetch(artifact) if info is not None else (None, None)
snapshots[artifact] = analysis_input_snapshot(content, artifact, ref=item.get("ref"),
node_id=(info or {}).get("node_id")) if ok is True else None
"""复算(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 run() -> dict[str, Any]:
return analysis_recheck(result, snapshots.get, load_result=parents.get)
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
return await asyncio.to_thread(run)
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:
@@ -1288,7 +1300,9 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
# 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查(analysis.contract.MetricComparability),点得开那份
# 可比性结论(artifact)。没做过检查的指标没有这个键
if isinstance((metric or {}).get("comparability"), dict):
step["comparability"] = _comparability_view(metric["comparability"], sealed, chained=step["sealed"])
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):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说
@@ -1329,23 +1343,33 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s
return [step, *inputs, *upstream, *queries]
def _comparability_view(comparability: dict[str, Any], sealed: _Sealed, *, chained: bool) -> dict[str, Any]:
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)、判定语、原因(一句一个),连同那份可比性结论的
工件:取回、复验哈希,带上它的判定标准、方法和现查的表范围(点得开)。sealed:哈希复验通过,而且它记在封存过的口径卡
指标集里(chained:指标步骤本身封存了;内容寻址,卡上记着它的 sha256)或者封存范围内的台账里。"""
工件:取回、复验哈希,带上它的判定标准、方法和现查结果,再给一条点得开的出处链(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": sealed.trusted and hash_ok is True and (chained or artifact in sealed.analyses),
"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
+39 -4
View File
@@ -27,6 +27,9 @@
下钻的上一层结论;任一份输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id——结果表里的维度值取自输入快照,
这份文件宁可多挡。浏览器只核哈希、核判定语和数是不是结论里写的那些,不在页面里重算统计:结论随文件附带时,服务端在
导出时按封存的输入复算一遍(engine/evidence.analysis_recheck),结果记在这份工件的条目上(recheck),页面照它展示。
口径卡指标上的可比性结论(口径卡执行时自动做的,不进台账、只从卡上引用)随附带的卡一起附上,规则同上,引用链(④)
按「已核对的口径卡记着的可比性」认它。分析工具的 tool.end(预览里是结论的前一截)、分析卡一项的 analysis.end(带着
判定语)和结论本身同一个认法:结论随文件附带得了才带规范字符串。
- 报告文档带着系统写的「分析范围」(analysis_scope)的,页面在正文后面照它渲染,编号点得开那条结论。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
@@ -63,7 +66,7 @@ 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 analysis_input_snapshot, analysis_recheck, 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
@@ -93,6 +96,8 @@ _SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, Even
_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 同一个规则
@@ -1066,6 +1071,17 @@ def _analysis_inputs(result: Any) -> list[tuple[str, str]]:
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))
def _card_input_queries(card: Any) -> list[tuple[str, str | None]]:
"""口径卡记着的输入引用的查询快照:(工件 id, 输入来自的节点),按先后、按工件去重。
@@ -1169,7 +1185,7 @@ def _recheck(result: dict[str, Any], store: _Store) -> dict[str, Any]:
"""导出时服务端按封存的输入把这份分析结论复算一遍(engine/evidence.analysis_recheck,和证据面板同一个函数):输入快照、
上一层结论只认这份文件里哈希对得上的原字节。页面照这个结果写「导出时服务端的复算」,不在浏览器里重算统计。"""
def load(artifact: str) -> Any:
return analysis_input_snapshot(store.content(artifact), artifact) if store.intact(artifact) else None
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
@@ -1209,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),赋了变量的另有
@@ -1248,7 +1278,7 @@ def _analysis_shown(data: Any, store: _Store) -> bool:
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(store.raw(a) is not None and not store.analysis_masked(a) for a in results)
return bool(results) and all(_result_shown(a, store) for a in results)
def _masked_cards(doc: dict[str, Any]) -> set[str]:
@@ -1420,6 +1450,11 @@ def build_bundle(
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 = []
+54 -2
View File
@@ -22,6 +22,7 @@
* 分析结论(判定语 [[a:A3]]、具名的数 [[a:A3.键]]、结果表的格 [[v:A3.r0.列]])在 ③ ④ 里和查询快照一样核哈希、核引用链
* (它的输入快照按「已核对的分析结论的输入」认);⑤ 里核正文显示的判定语、数是不是结论里写的那几个字(同服务端的渲染
* 规则)。本页不重算统计:结论随文件附带时,出处面板展示导出时服务端按封存的输入复算的结论(工件条目上的 recheck)。
* 口径卡指标上的可比性结论不进台账、只从卡上引用:④ 按「已核对的口径卡记着的可比性」认它,指标的数字详情里展示它。
* 报告文档带着系统写的「分析范围」(analysis_scope)的,正文后面照它渲染,编号点得开那条结论。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、检索快照这类工件标着
* 遮罩)一律算不通过:只可能是文件被改过。报告文档的证据目录里写着取自遮罩列的指标的值时,服务端只给它的哈希
@@ -67,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': '数据目录变化',
@@ -259,6 +261,11 @@
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}`,
@@ -1378,6 +1385,13 @@
}
}
}
// 指标上的可比性结论不进台账、只从卡上引用(卡记着它的 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 })
}
}
}
// 已核对的分析结论记着的输入 → 输入快照、下钻的上一层结论。同口径卡:结论本身核过哈希,它记的输入和文档的证据目录
// 同一个分量;没核对过的(哈希对不上)不认
@@ -1958,6 +1972,7 @@
}
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,
@@ -1966,6 +1981,42 @@
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) {
@@ -1987,7 +2038,8 @@
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(String(i.role || ''), view.out.queryAliases.get(i.artifact)), ' ',
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)))))
}
}
+101 -67
View File
@@ -4255,8 +4255,11 @@ def _comparability_note(entry: dict[str, Any]) -> str:
comparability = entry.get("comparability")
if not isinstance(comparability, dict):
return ""
reasons = ";".join(str(r) for r in comparability.get("reasons") or [] if r) \
or str(comparability.get("phrase") or "")
reasons = ";".join(str(r) for r in comparability.get("reasons") or [] if r)
if not reasons:
# 没记原因的照判定语写(「两期不可比:原因;原因」),开头的判定不重复
phrase = str(comparability.get("phrase") or "")
reasons = phrase.split(":", 1)[1].strip() if ":" in phrase else ""
if comparability.get("verdict") == "incomparable":
return f"(两期不可比:{reasons or '原因见证据面板'}。这个比较不能当结论,引用时须如实说明两期不可比)"
if comparability.get("verdict") == "conditional":
@@ -4650,59 +4653,61 @@ def describe_violations(violations: list[dict[str, Any]], *, limit: int = 20) ->
#: 复算不一致时点名哪几项不同的叫法(analysis.contract.AnalysisResult 的字段)
_RESULT_FIELDS = {
"op": "算子", "mode": "模式", "method": "方法版本", "params": "参数", "settings": "阈值", "inputs": "输入",
"probes": "现查的表范围", "status": "算成没算成", "reason_code": "算不了的原因", "reason": "算不了的原因",
"probes": "现查结果", "status": "算成没算成", "reason_code": "算不了的原因", "reason": "算不了的原因",
"table": "结果表", "values": "具名的数", "verdict": "判定", "criterion": "判定标准", "phrase": "判定语",
"caveats": "附带说明",
}
#: 复算抛 NotImplementedError:这一版系统里还没有这种分析的算子
RECHECK_MISSING = "系统里还没有这种分析的复算实现"
#: app.analysis.recompute 取不回输入(CallError 的 code input_missing)时那句以「复算」开头(复算取不回 Q2 的查询快照):
#: 接在「复算失败:」后面时去掉,不说两遍
_RECHECK_HEAD = "复算"
def analysis_input_snapshot(content: Any, artifact: str, *, ref: str | None = None,
node_id: str | None = None) -> dict[str, Any] | None:
"""一份查询快照的内容 → 复算要的输入快照(analysis.contract.InputSnapshot:数据源查询工具的 to_payload 加上出处)。
不能当维度、度量的列(masked)按快照里记下的、查询当时的遮罩:复算按运行当时的条件重算;现在设的遮罩只管展示。
不是查询快照(没有 rows)的返回 None。"""
if not isinstance(content, dict) or not isinstance(content.get("rows"), list):
return None
return {"ref": str(ref or ""), "artifact": str(artifact), "node_id": str(node_id or ""),
"source": content.get("source"), "sql": str(content.get("sql") or ""),
"columns": [str(c) for c in content.get("columns") or []],
"column_types": content.get("column_types") if isinstance(content.get("column_types"), dict) else {},
"rows": content["rows"], "truncated": bool(content.get("truncated")),
"masked": sorted(_mask_names(content.get("mask_columns")))}
def _recheck_reason(error: Any) -> str:
text = str(getattr(error, "message", "") or "").strip() or "原因未知"
if getattr(error, "code", None) == "input_missing" and text.startswith(_RECHECK_HEAD) \
and len(text) > len(_RECHECK_HEAD):
return text[len(_RECHECK_HEAD):].lstrip()
return text
def analysis_recheck(result: dict[str, Any], load: Callable[[str], Any], *,
load_result: Callable[[str], Any] | None = None) -> dict[str, Any]:
"""把一份封存的分析结论按它记下的输入复算一遍(app.analysis.recompute:按 sha256 取回输入快照,按结论里记下的参数、
阈值和现查结果算,不再现查),和封存的逐字段比(contract.same,agent 写的假设不比)。
"""把一份封存的分析结论按它记下的输入复算一遍,和封存的逐字段比(contract.same,agent 写的假设不比)。
复算就是 app.analysis.recompute:load 按 sha256 交回查询快照工件的原内容,load_result 交回下钻的上一层结论的原内容,
取不回(不在封存范围内、哈希对不上)的交 None;算子按结论里记下的参数、阈值、现查结果算,不再现查、不读目录。
交回 {status, text, method, method_now?, fields?}:status 是 same(一致)/ different(不一致)/ failed(复算不了);
text 是给人看的一句,证据面板、报告导出、可验证报告照它写。方法升过版的照实写运行时用的是哪一版、现在是哪一版,
复算结果同不同。复算抛异常(算子还没有、输入取不回、调用的写法现在不认)不往外抛,照实写「复算失败:原因」。"""
text 是给人看的一句,证据面板、报告导出、可验证报告照它写。fields 是和封存的不同的那几项(结论的字段名)。
方法升过版(结论里记的 method 和现在的 contract.METHODS[(op, mode)] 不同)时 method_now 是现在的那一版:复算成立的照实写
复算结果同不同(方法版本那一项本来就不同,不算进去),现在的方法按封存的输入不成立(recompute 抛 CallError)的写明原因。
复算出错(输入取不回、调用的写法现在不认、出了意外)不往外抛,照实写「复算失败:原因」。"""
from app import analysis
from app.analysis.contract import NOT_RECOMPUTED, CallError, same
from app.analysis.contract import METHODS, NOT_RECOMPUTED, CallError, same
from app.core.errors import describe_exception
method = str(result.get("method") or "")
now = METHODS.get((result.get("op"), result.get("mode")))
upgraded = isinstance(now, str) and now != method
out: dict[str, Any] = {"method": method, **({"method_now": now} if upgraded else {})}
head = f"运行时用的是 {method},现在是 {now}" if upgraded else ""
try:
again = analysis.recompute(result, load, load_result=load_result) # type: ignore[arg-type]
except NotImplementedError:
return {"status": "failed", "method": method, "text": f"复算失败:{RECHECK_MISSING}"}
except CallError as e:
return {"status": "failed", "method": method, "text": f"复算失败:{e.message}"}
why = _recheck_reason(e)
text = f"{head}:现在的方法按封存的输入复算不了({why})" if upgraded else f"复算失败:{why}"
return {"status": "failed", **out, "text": text}
except Exception as e: # noqa: BLE001 - 复算出了意外:照实说复算不了,不能让证据面板、导出跟着出错
return {"status": "failed", "method": method, "text": f"复算失败:{describe_exception(e)}"}
return {"status": "failed", **out, "text": f"复算失败:{describe_exception(e)}"}
if not isinstance(again, dict):
return {"status": "failed", "method": method, "text": "复算失败:复算没有交回结论"}
ok = same(result, again)
fields = sorted(k for k in set(result) | set(again) if k not in NOT_RECOMPUTED and result.get(k) != again.get(k))
out: dict[str, Any] = {"status": "same" if ok else "different", "method": method}
now = str(again.get("method") or "")
if now != method:
out["method_now"] = now
out["text"] = f"运行时用的是 {method},现在是 {now},复算结果{'相同' if ok else '不同'}"
return {"status": "failed", **out, "text": "复算失败:复算没有交回结论"}
ok = same({**result, "method": now} if upgraded else result, again)
skip = NOT_RECOMPUTED | ({"method"} if upgraded else set())
fields = sorted(k for k in set(result) | set(again) if k not in skip and result.get(k) != again.get(k))
out["status"] = "same" if ok else "different"
if upgraded:
out["text"] = f"{head},复算结果{'相同' if ok else '不同'}"
elif ok:
out["text"] = "复算一致:按封存的输入重新计算,结论逐项相同"
else:
@@ -4713,31 +4718,56 @@ def analysis_recheck(result: dict[str, Any], load: Callable[[str], Any], *,
return out
def _decompose_row(result: dict[str, Any]) -> list[Any] | None:
"""变化拆解的结果表里判定点名的那一段(verdict.subjects 的第一段,按维度列 params.by 对),对不上就取第一行(结果表
按变化的绝对值从大到小排)。"""
#: 变化拆解结果表里不是「段」的那几列(细案 01 §2:可加、乘积两种模式的数列和说明列)
_DECOMPOSE_NUMBERS = ("base", "target", "delta", "pct", "share", "contrib", "flag")
def _decompose_row(result: dict[str, Any]) -> tuple[str, list[Any]] | None:
"""变化拆解的结果表里判定点名的那一段:(这一段的叫法, 那一行)。段按维度列(params.by;乘积模式是 factor 一列)对
verdict.subjects 的第一项,对不上就取第一行(结果表按变化的绝对值从大到小排)。叫法是维度值用「·」连起来。"""
table = result.get("table") if isinstance(result.get("table"), dict) else {}
rows = [r for r in table.get("rows") or [] if isinstance(r, list)]
columns = [str(c) for c in table.get("columns") or []]
if not rows:
return None
subjects = ((result.get("verdict") or {}).get("subjects") or []) if isinstance(result.get("verdict"), dict) else []
by = (result.get("params") or {}).get("by") if isinstance(result.get("params"), dict) else None
params = result.get("params") if isinstance(result.get("params"), dict) else {}
by = ["factor"] if result.get("mode") == "factor" else params.get("by")
by = [str(b) for b in (by if isinstance(by, list) else [by] if by else []) if str(b) in columns]
if subjects and by and isinstance(subjects[0], list):
if not by:
by = [c for c in columns if c not in _DECOMPOSE_NUMBERS][:1]
at = [columns.index(b) for b in by]
verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else {}
subjects = verdict.get("subjects") or []
picked = rows[0]
if subjects and at and isinstance(subjects[0], list):
wanted = [str(v) for v in subjects[0]]
for row in rows:
if [str(row[columns.index(b)]) for b in by if columns.index(b) < len(row)] == wanted:
return row
return rows[0]
picked = next((row for row in rows if [str(row[i]) for i in at if i < len(row)] == wanted), rows[0])
name = "·".join(str(picked[i]) for i in at if i < len(picked) and picked[i] is not None)
return name or "这一段", picked
def _paren(text: str) -> str:
"""代入式里减号、除号后面的负数加括号:「30.0% − (-1.0%)」。"""
return f"({text})" if text.startswith("-") else text
def analysis_formulas(result: dict[str, Any]) -> list[dict[str, str]]:
"""出处链「计算」一步里关键数的代入式:[{label, text}],数照单元格的规则渲染(系统写的,不是模型写的)。只给拿结论里
记下的数就写得出来的几条——变化拆解的总变化、变化率、主要来源的占比,比率拆解的结构效应、自身效应占比,波动基线的
常规波动范围和稳健 z;缺了哪个数、渲染不出来的那条不给。比如「华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ -194 = 61.9%」。"""
记下的数就写得出来的几条:
- 变化拆解(可加、乘积):总变化、变化率、点名那一段(因子)的占比——「华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷
(-194) = 61.9%」,乘积模式按那个因子的贡献
- 变化拆解(比率):结构效应、自身效应各占比率变化的多少
- 波动基线:常规波动范围、稳健 z(按变化率比的,几个数按百分比写)
量按单元格的规则写(千分位,小数照值本来的样子);百分比同判定语的写法(app.analysis.phrases.pct:一位小数,不是 0 的值
写出来成了 0.0% 的多给小数位)。缺了哪个数、渲染不出来的那条不给,不编。趋势、差异检验、可比性检查的数在结果表和具名的数
里,不另写代入式。"""
from app.analysis.phrases import pct
values = result.get("values") if isinstance(result.get("values"), dict) else {}
settings = result.get("settings") if isinstance(result.get("settings"), dict) else {}
params = result.get("params") if isinstance(result.get("params"), dict) else {}
op, mode = result.get("op"), result.get("mode")
out: list[dict[str, str]] = []
@@ -4747,34 +4777,38 @@ def analysis_formulas(result: dict[str, Any]) -> list[dict[str, str]]:
except (KeyError, IndexError, ValueError, TypeError, RenderError):
pass # 缺一个数、渲染不出来:这条不给,不编
def cell(raw: Any, conv: str | None = None) -> str:
return pct(raw, signed=False) if conv == "pct" else render_cell(raw)
if result.get("status") != "ok":
return out
if op == "decompose" and mode == "additive":
add("总变化", lambda: f"总变化 = 本期合计 − 基期合计 = {render_cell(values['target_total'])} − "
f"{render_cell(values['base_total'])} = {render_cell(values['delta_total'])}")
add("变化率", lambda: f"变化率 = 总变化 ÷ 基期合计 = {render_cell(values['delta_total'])} ÷ "
f"{render_cell(values['base_total'])} = {render_cell(values['pct_total'], 'pct')}")
table = result.get("table") or {}
if op == "decompose" and mode in ("additive", "factor"):
add("总变化", lambda: f"总变化 = 本期合计 − 基期合计 = {cell(values['target_total'])} − "
f"{_paren(cell(values['base_total']))} = {cell(values['delta_total'])}")
add("变化率", lambda: f"变化率 = 总变化 ÷ 基期合计 = {cell(values['delta_total'])} ÷ "
f"{_paren(cell(values['base_total']))} = {cell(values['pct_total'], 'pct')}")
table = result.get("table") if isinstance(result.get("table"), dict) else {}
columns = [str(c) for c in table.get("columns") or []]
row = _decompose_row(result)
if row is not None and {"delta", "share"} <= set(columns):
by = [c for c in columns if c not in ("base", "target", "delta", "pct", "share", "flag")]
name = " · ".join(str(row[columns.index(c)]) for c in by if columns.index(c) < len(row)) or "这一段"
add("占比", lambda: f"{name}的占比 = {name}的变化 ÷ 总变化 = "
f"{render_cell(row[columns.index('delta')])} ÷ {render_cell(values['delta_total'])} = "
f"{render_cell(row[columns.index('share')], 'pct')}")
part = "contrib" if mode == "factor" else "delta"
found = _decompose_row(result)
if found is not None and {part, "share"} <= set(columns):
name, row = found
word = "贡献" if mode == "factor" else "变化"
add("占比", lambda: f"{name}的占比 = {name}的{word} ÷ 总变化 = {cell(row[columns.index(part)])} ÷ "
f"{_paren(cell(values['delta_total']))} = {cell(row[columns.index('share')], 'pct')}")
elif op == "decompose" and mode == "ratio":
for part, label in (("mix", "结构效应"), ("rate", "自身效应")):
add(f"{label}占比", lambda part=part, label=label: (
f"{label}占比 = {label}合计 ÷ 整体的变化 = {render_cell(values[f'{part}_total'])} ÷ "
f"{render_cell(values['delta'])} = {render_cell(values[f'{part}_share'], 'pct')}"))
f"{label}占比 = {label}合计 ÷ 比率的变化 = {cell(values[f'{part}_total'])} ÷ "
f"{_paren(cell(values['delta']))} = {cell(values[f'{part}_share'], 'pct')}"))
elif op == "baseline":
conv = "pct" if params.get("basis") == "change" else None
add("常规波动范围", lambda: (
f"常规波动范围 = 中位数 ± {settings['k_warn']:g} 倍稳健标准差 = {render_cell(values['band_lo'])} 至 "
f"{render_cell(values['band_hi'])}(中位数 {render_cell(values['median'])},"
f"过去 {render_cell(values['n_history'])} 期)"))
add("稳健 z", lambda: f"稳健 z = (本期 − 中位数)÷ 稳健标准差 = ({render_cell(values['x'])} − "
f"{render_cell(values['median'])})÷ 稳健标准差 = {render_cell(values['z'])}")
f"常规波动范围 = 中位数 ± {settings['k_warn']:g} 倍稳健标准差 = {cell(values['band_lo'], conv)} 至 "
f"{cell(values['band_hi'], conv)}(中位数 {cell(values['median'], conv)},"
f"过去 {cell(values['n_history'])} 期)"))
add("稳健 z", lambda: f"稳健 z = (本期 − 中位数)÷ 稳健标准差 = ({cell(values['x'], conv)} − "
f"{_paren(cell(values['median'], conv))})÷ 稳健标准差 = {cell(values['z'])}")
return out
+4
View File
@@ -575,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)
+32 -9
View File
@@ -80,14 +80,18 @@
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 现场做的分析' },
citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的',
originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' },
dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致',
method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查的表范围',
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: '用到的行',
@@ -95,6 +99,7 @@
closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`,
dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`,
inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论',
viaCard: '记在口径卡的指标集里,随卡封存(不进台账)',
}
const MASKED = '已遮罩'
@@ -235,10 +240,17 @@
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, `${T.comparabilityLabel[c.verdict] || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`],
[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)) {
@@ -461,7 +473,8 @@
? [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, `${T.originLabel[step.origin] || NONE}${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`],
[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) {
@@ -475,10 +488,21 @@
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 || ''} · ${step.method_label || NONE}`), h('div', { class: 'part-body' }, ...body))
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) {
@@ -489,8 +513,7 @@
}
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.map((p) => h('li', {}, `${p.table || NONE}.${p.column || NONE}:${p.min || NONE} 至 ${p.max || NONE}`
+ `(${T.probeBasis[p.basis] || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})`))))
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))
}
@@ -550,7 +573,7 @@
/** 分析结论 · 封存:台账记没记过、哈希核对 */
function analysisSealPart(step) {
const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE],
[T.seal, step.ledger ? T.inLedger : T.notInLedger]])]
[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)
+110 -17
View File
@@ -1,6 +1,9 @@
"""分析结论进可验证报告(第 1 波 W1-b):bundle/build.py 带上结论工件和它的输入快照,verifier.js 在 node 里核对。
- 报告引用的分析结论按原字节嵌入,连同输入快照;服务端导出时按封存的输入复算一遍,结果记在工件条目上(recheck)
- 报告引用的分析结论按原字节嵌入,连同输入快照;服务端导出时按封存的输入复算一遍(真的 app.analysis.recompute),结果记在
工件条目上(recheck)
- 口径卡指标上的可比性结论(不进台账、只从卡上引用)随卡附带,④ 按「已核对的口径卡记着的可比性」认它
- 分析工具的 tool.end、分析卡一项的 analysis.end:结论随文件附带得了才带规范字符串
- 页面只核哈希、核判定语和数是不是结论里写的那几个字(不在浏览器里重算统计):原样导出全部通过,只改显示文字的伪造
点名不通过
- 输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id,引用它的判定语、数无法核对
@@ -14,13 +17,12 @@ from typing import Any
import pytest
from app import analysis
from app.analysis.contract import ARTIFACT_KIND, ledger_entry
from app.analysis.testing import fake_result
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
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,无法执行页面上的核对脚本")
@@ -47,14 +49,15 @@ class Files(dict):
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) -> tuple[dict[str, Any], Files, str]:
"""一次封存好的运行的页面数据:(bundle, 工件, 分析结论的 id)。"""
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 = fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data", "columns": ["region", "week", "gmv"],
"rows": "all"}])
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}
@@ -74,10 +77,14 @@ def bundle_of(text: str = TEXT, *, snapshot: dict[str, Any] | None = None, doc_e
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 {})
@@ -96,8 +103,9 @@ 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": "failed", "method": "decompose.additive@1",
"text": "复算失败:系统里还没有这种分析的复算实现"}
# 导出时服务端真的复算了一遍:按封存的输入快照(这份文件里哈希对得上的原字节)重算,逐字段相同
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
@@ -106,17 +114,17 @@ def test_the_analysis_and_its_input_travel_with_the_file_and_verify():
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 万,占 62%)"
assert numbers["a:A1"]["text"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
assert got["overall"]["result"] == "pass"
def test_the_server_recheck_is_recorded_when_the_operators_answer(monkeypatch):
monkeypatch.setattr(analysis, "recompute", lambda result, load, *, load_result=None: dict(result))
bundle, _, a = bundle_of()
assert item(bundle, a)["recheck"]["text"] == "复算一致:按封存的输入重新计算,结论逐项相同"
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 万,占 62%)"),
@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()
@@ -173,3 +181,88 @@ def test_an_analysis_card_event_shows_its_content_only_when_its_conclusions_trav
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
+174 -49
View File
@@ -8,23 +8,27 @@
- 写作目录的「分析结论」一段:判定语、判定标准、具名的数、结果表前 table_top_k 段、附带说明、算不了的原因、来自哪几个 Q,
放不下时结果表的各段排在查询的行前面补
- 分析结论的输入查询上的 SQL 检查问题随 A 条目出现;口径卡指标上的可比性照实写进写作目录
- 复算(analysis_recheck)、代入式(analysis_formulas)
- 复算(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.contract import CallError, ledger_entry
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_input_snapshot,
analysis_recheck,
build_catalog,
compose_doc,
@@ -58,6 +62,59 @@ class Store(dict):
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):(工件库, 台账, 查询快照, 分析结论)。"""
@@ -451,71 +508,139 @@ def test_sql_check_problems_of_the_input_queries_follow_the_analysis():
# --------------------------------------------------------------------------
def test_recheck_says_plainly_when_the_operators_are_not_there_yet():
store, ledger, q, a = setup()
out = analysis_recheck(store[a], lambda sha: analysis_input_snapshot(store.get(sha), sha))
assert out == {"status": "failed", "method": "decompose.additive@1",
"text": "复算失败:系统里还没有这种分析的复算实现"}
def test_recheck_compares_field_by_field_and_names_the_method_versions(monkeypatch):
store, _, q, a = setup()
sealed = store[a]
seen: list[Any] = []
def same(result, load, *, load_result=None):
seen.append(load(q))
return {**result, "hypothesis": "复算时没有假设"}
monkeypatch.setattr(analysis, "recompute", same)
out = analysis_recheck(sealed, lambda sha: analysis_input_snapshot(store.get(sha), sha, ref="Q2", node_id="fetch"))
assert out == {"status": "same", "method": "decompose.additive@1", "text": "复算一致:按封存的输入重新计算,结论逐项相同"}
assert seen[0]["rows"] == SNAPSHOT["rows"] and seen[0]["masked"] == [] and seen[0]["node_id"] == "fetch"
monkeypatch.setattr(analysis, "recompute", lambda r, load, *, load_result=None: {**r, "phrase": "别的判定"})
assert analysis_recheck(sealed, store.get)["text"] == "复算不一致:判定语和封存的不同"
monkeypatch.setattr(analysis, "recompute",
lambda r, load, *, load_result=None: {**r, "method": "decompose.additive@2"})
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"] == "different" and out["method_now"] == "decompose.additive@2"
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(r, load, *, load_result=None):
raise CallError("missing_column", "Q2 里没有列「gmv」")
def refuse(result, load, *, load_result=None):
raise CallError("unknown_mode", "变化拆解要写模式:additive / ratio / factor")
monkeypatch.setattr(analysis, "recompute", refuse)
assert analysis_recheck(sealed, store.get)["text"] == "复算失败:Q2 里没有列「gmv」"
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 broken(r, load, *, load_result=None):
raise RuntimeError("输入快照取不回来")
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)
assert analysis_recheck(sealed, store.get)["text"] == "复算失败:输入快照取不回来"
def test_input_snapshots_for_recompute_keep_the_masks_of_the_query_time():
snap = analysis_input_snapshot({**SNAPSHOT, "mask_columns": ["Phone", "email"]}, "a" * 64, ref="Q1")
assert snap["masked"] == ["email", "phone"] and snap["artifact"] == "a" * 64 and snap["ref"] == "Q1"
assert analysis_input_snapshot({"columns": []}, "a" * 64) is None
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():
formulas = {f["label"]: f["text"] for f in analysis_formulas(fake_result())}
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%",
"占比": "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%",
}
baseline = fake_result("baseline", verdict="outside_band", values={
"x": "-0.087", "median": "-0.004", "band_lo": "-0.061", "band_hi": "0.053", "z": "-2.9", "n_history": "12"})
texts = [f["text"] for f in analysis_formulas(baseline)]
assert texts == ["常规波动范围 = 中位数 ± 2 倍稳健标准差 = -0.061 至 0.053(中位数 -0.004,过去 12 期)",
"稳健 z = (本期 − 中位数)÷ 稳健标准差 = (-0.087 − -0.004)÷ 稳健标准差 = -2.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):分析卡的结果不是口径卡的原料
# --------------------------------------------------------------------------
+163 -46
View File
@@ -1,13 +1,14 @@
"""分析结论进证据链(第 1 波 W1-b):证据接口、审计、报告导出。
运行是直接落库的虚构封存运行(虚构零售库 shop):一次查询(Q1)、一份按地区拆的分析结论(A1,分析卡或 Agent 做的),
报告引用判定语、具名标量、结果表的格。算子第 1 波还没落地,复算按 monkeypatch 换。
报告引用判定语、具名标量、结果表的格。结论用 app.analysis 真算(tests.test_analysis_evidence.analyzed),出处链里的复算是
真的 recompute:按 sha256 取回封存的输入快照、下钻的上一层结论,逐字段比。只有要它出错、升版的几处用 monkeypatch。
- 证据图:A1 条目带算子、判定;「派生自」的边 A1 → Q1
- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;Agent 写的假设标出来;复算不了照实写,不让接口出错
- 审计:分析判定、分析结论的叫法;分析说法核对的违规单列一行
- 报告导出:数据块里有分析结论那几步;文档带「分析范围」的,正文后面渲染、编号点得开
- 指标步骤带可比性
- 指标步骤带可比性:口径卡指标上的可比性结论(不进台账)照卡上记的 sha256 取回,点得开(判定 → … → 复算 → 封存)
"""
from __future__ import annotations
@@ -16,21 +17,22 @@ 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.analysis.testing import fake_result
from app.core import artifact_store
from app.core.artifact_store import manifest_hash
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine.evidence import build_catalog, compose_doc, iter_segments
from app.main import app
from tests.test_analysis_evidence import SNAPSHOT
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", "周报口径"),
@@ -43,28 +45,38 @@ async def client():
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) -> dict[str, Any]:
"""落一次封存好的运行:查询快照、分析结论、(可选)口径卡、报告文档都按真实的工件写,事件按 runner 的算法封存。"""
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")
res = result or fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data",
"columns": ["region", "week", "gmv"], "rows": "all"}])
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")
qentry = {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop",
"source": "shop", "columns": SNAPSHOT["columns"], "rows": len(SNAPSHOT["rows"]), "truncated": False}
aentry = ledger_entry(res, node_id="an", exec_no=1, artifact=a, item=item, call_id=call_id)
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": [qentry]}),
(3, "node.finished", "an", {"evidence": [aentry]})]
(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=[qentry, aentry])
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")
@@ -78,7 +90,7 @@ async def sealed_run(text: str, *, item: str | None = "drop", call_id: str | Non
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}
return {"run": run_id, "doc": doc, "query": q, "analysis": a, "result": res, "queries": more}
def seg_id(doc: dict[str, Any], ref: str) -> str:
@@ -118,11 +130,13 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and
assert [s["step"] for s in chain] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query",
"analysis_recompute", "analysis_seal"]
judged, method, calc, given, query, again, seal = chain
assert judged["phrase"] == made["result"]["phrase"] and judged["criterion"] == "一段占总变化一半以上算主要来源"
assert judged["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 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"}
@@ -130,28 +144,30 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and
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%"
"华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%"
assert given == {"step": "analysis_input", "of": "A1", "ref": "Q2", "role": "data", "role_label": "数据",
"artifact": made["query"], "columns": ["region", "week", "gmv"], "rows": "all", "query": "Q1",
"node_id": "fetch", "sealed": True}
assert query["alias"] == "Q1" and query["input_of"] == "A1" and query["total_rows"] == 8
assert query["highlight"]["cols"] == ["region", "week", "gmv"]
assert again["status"] == "failed" and again["text"] == "复算失败:系统里还没有这种分析的复算实现"
# 真的复算:按 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, monkeypatch):
monkeypatch.setattr(analysis, "recompute", lambda result, load, *, load_result=None: dict(result))
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.6186", "conv": "pct"}
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.6186", "rendered": "0.6186", "cited": True}
"key": "top_share", "raw": "0.618556701031", "rendered": "0.618556701", "cited": True}
assert value["chain"][-2]["text"] == "复算一致:按封存的输入重新计算,结论逐项相同"
assert value["note"] == "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写"
@@ -163,20 +179,41 @@ async def test_named_values_and_result_cells_point_at_what_they_cite(client, mon
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 {**result, "method": "decompose.additive@2"}
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["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同"
assert seen["input"]["rows"] == SNAPSHOT["rows"] and seen["input"]["node_id"] == "fetch"
assert seen["missing"] is None
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):
@@ -194,7 +231,8 @@ async def test_recompute_errors_do_not_break_the_panel(client, monkeypatch):
async def test_a_tampered_result_is_said_and_not_shown(client):
# 工件按内容寻址:改的是这一次独有的那份结论(别的测试的结论内容相同、是同一个文件)
q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot")
own = fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data"}], criterion=f"只给这一次:{uuid.uuid4().hex}")
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")
@@ -210,27 +248,97 @@ async def test_agent_analyses_are_marked_as_such(client):
assert body["chain"][0]["origin"] == "agent" and body["chain"][0]["call_id"] == "call-9"
async def test_headline_inputs_show_the_closure_and_drill_downs_the_parent(client):
base = fake_result()
made = await sealed_run(TEXT)
q, a = made["query"], made["analysis"]
child = fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data", "columns": ["region", "gmv"],
"rows": [0, 1]},
{"ref": "Q2.r1.gmv", "artifact": q, "role": "headline_target", "row": 1,
"column": "gmv", "value": "2026"},
{"ref": "A3", "artifact": a, "role": "parent"}],
values={**base["values"], "target_total": "2026"})
drilled = await sealed_run("[[a:A1]]。", result=child)
body = (await client.get(f"/api/runs/{drilled['run']}/evidence/segments/{seg_id(drilled['doc'], 'a:A1')}")).json()
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] == ["data", "headline_target", "parent"]
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[2]["phrase"] == base["phrase"] and inputs[2]["role_label"] == "上一层拆解"
query = next(s for s in body["chain"] if s["step"] == "query")
assert [0, "region"] in query["highlight"]["cells"] and [1, "gmv"] in query["highlight"]["cells"]
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_the_metric_step_shows_the_comparability(client):
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 天数据",
@@ -242,6 +350,15 @@ async def test_the_metric_step_shows_the_comparability(client):
"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]
# --------------------------------------------------------------------------
# 审计
# --------------------------------------------------------------------------
@@ -286,7 +403,7 @@ def test_audit_labels_cover_the_new_violation_codes():
# --------------------------------------------------------------------------
SCOPE = {"analysis_scope": {
"phenomena": [{"alias": "A1", "op": "decompose", "phrase": "销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)"}],
"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 期"}],
@@ -97,3 +97,21 @@ def test_a_report_citing_only_analyses_has_nothing_to_label():
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
+52 -10
View File
@@ -19,8 +19,8 @@ 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
from tests.test_analysis_evidence_api import SCOPE, TEXT, sealed_run, seg_id
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,无法执行页面上的脚本")
@@ -129,15 +129,27 @@ async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers():
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"}]
out = run_node(RUN_RUNTIME, RUNTIME, {"data": json.loads(json.dumps(data)), "segs": segs})
# 现查结果的三种写法:按运行当天现算的界、一列一个值都没有、表的范围(结论里的 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 行)",
"复算失败:系统里还没有这种分析的复算实现", "封存范围内的台账记过这份分析结论"):
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"]
@@ -151,10 +163,40 @@ async def test_the_bundle_page_shows_the_analysis_detail_and_the_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 万" in phrase
assert "判定语销售额下降 194 万" in phrase and "方法与版本decompose.additive@1" in phrase
assert "复算失败:系统里还没有这种分析的复算实现(导出时服务端按封存的输入复算的结论;本页只核哈希" in phrase
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.6186" in value and "正文显示61.9%(与按显示规则渲染的结果一致)" in value
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