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