diff --git a/backend/app/analysis/contract.py b/backend/app/analysis/contract.py index c52c1865..cd7e9d1d 100644 --- a/backend/app/analysis/contract.py +++ b/backend/app/analysis/contract.py @@ -53,6 +53,26 @@ METHODS: dict[tuple[str, str | None], str] = { } #: agent 分析工具的名字(tools/builtin 里注册),一个算子一个工具:参数各自聚焦,模型少写错 TOOL_NAMES: dict[str, str] = {op: f"analysis_{op}" for op in OPS} +#: 算子、模式给人看的叫法:证据面板、写作目录、审计、导出、可验证报告、裁判摘录都用它(method_label 拼成一句) +OP_LABELS: dict[str, str] = { + "decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势", "differ": "差异检验", "comparable": "可比性检查", +} +MODE_LABELS: dict[str, str] = { + "additive": "可加", "ratio": "比率", "factor": "乘积", "rate": "比率", "mean": "均值", "rows": "逐行明细", +} + + +def method_label(op: Any, mode: Any = None) -> str: + """「变化拆解(可加)」「波动基线」。认不出的算子、模式原样写出来,不编叫法。""" + name = OP_LABELS.get(str(op or ""), str(op or "")) + return f"{name}({MODE_LABELS.get(str(mode), str(mode))})" if mode else name + + +def is_result(content: Any) -> bool: + """一件工件的内容是不是分析结论:按形状认(op 是五个算子之一、method 是文字、inputs 是列表)。证据接入取回 ARTIFACT_KIND + 的工件、SQL 问题顺着输入往下找、可验证报告认嵌进来的工件,都按这一个函数认。""" + return isinstance(content, Mapping) and content.get("op") in OPS and isinstance(content.get("method"), str) \ + and isinstance(content.get("inputs"), list) #: 每个算子可能给出的判定(细案 01 §2–§6) VERDICTS: dict[str, tuple[str, ...]] = { @@ -391,8 +411,8 @@ def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: __all__ = [ "ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "DEFAULTS", "LABELS", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES", - "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES", "VERDICTS", - "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot", + "MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES", + "VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot", "MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result", - "effective_settings", "ledger_entry", "same", "settings_problem", + "effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem", ] diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 1a88fc7d..bc76da20 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -9,6 +9,7 @@ - 运行输入:入口节点 node.finished 事件里的 node_output 工件 - 表结构快照:tool.end 事件里的 schema_artifact,或者 node.finished.evidence 里的 schema 条目 - 检索快照:retrieve.end 事件里的 artifact,或者 node.finished.evidence 里的 retrieval 条目 +- 分析结论:node.finished.evidence 里的 analysis 条目(Agent 分析工具、分析卡记的),它的输入快照照查询快照认 artifacts 表可以事后插行,run.output 是可以改写的一列,封存之后追加的事件不在 核对范围里——这三样都不当来源。解析数字层的链:口径卡指标 → 它的输入 → 查询快照里 @@ -70,6 +71,7 @@ from fastapi.responses import JSONResponse, Response from sqlalchemy import select, update from sqlalchemy.exc import StatementError +from app.analysis.contract import CATALOG_PREFIX, LABELS as ANALYSIS_LABELS, LEDGER_KIND, is_result, method_label from app.api.coded import CodedHTTPException from app.core.artifact_store import load from app.core.events import EventType @@ -109,6 +111,9 @@ from app.engine.evidence import ( UNKNOWN_ENTITY_REASON, UNVERIFIED_ENTITY_REASON, RenderError, + analysis_card, + analysis_formulas, + analysis_recheck, build_catalog, closest_entities, find_segment, @@ -119,6 +124,7 @@ from app.engine.evidence import ( make_eid, normalize_quote, query_entry_fields, + render_cell, render_metric, schema_column, schema_table, @@ -127,6 +133,7 @@ from app.engine.evidence import ( uncited_claims, withhold_metrics, ) +from app.engine.expressions import locate_cell from app.engine.judge import FIELDS_MAX, SETTLED, UNSUPPORTED, VERDICTS, candidates, outdated from app.engine.lineage import untraced from app.engine.merge_query import MERGE_SOURCE, trace_cell @@ -196,6 +203,8 @@ class _Sealed: schemas: dict[str, dict[str, Any]] = field(default_factory=dict) #: 封存范围内交回过的检索快照:工件 id → {node_id, source} retrievals: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 封存范围内台账里记过的分析结论:工件 id → {node_id, exec, call_id / item, inputs} + analyses: dict[str, dict[str, Any]] = field(default_factory=dict) #: 封存时记下的清单哈希(没封存的是 None):旧答案的猜测按 (run_id, 它) 缓存 manifest_hash: str | None = None #: 运行记录上的 manifest_seq 原值(老运行是 None):按需裁判追加判定前核对封存没换过 @@ -304,6 +313,10 @@ async def _sealed(run_id: str) -> _Sealed: out.schemas[artifact] = {"node_id": node_id, "source": entry.get("source")} elif entry.get("kind") == "retrieval": out.retrievals[artifact] = {"node_id": node_id, "source": entry.get("source")} + elif entry.get("kind") == LEDGER_KIND: + out.analyses.setdefault(artifact, { + "node_id": node_id, **{k: entry[k] for k in ("exec", "call_id", "item", "inputs") + if entry.get(k) is not None}}) return out @@ -517,6 +530,14 @@ def _graph_of(report: _Report, sealed: _Sealed, inputs: dict[str, Any]) -> tuple "report": report.node_id} for source in _merge_inputs(entry.get("artifact")) if (target := _query_alias(catalog, source.get("artifact")))) + elif entry.get("kind") == LEDGER_KIND: + # 分析结论:哪种分析、算成没有、判定码、谁做的(分析卡的那一项 / Agent 的那次调用);连向它的输入(「派生自」: + # 输入快照的 Q…,下钻的上一层 A…),图上看得出 A3 是从 Q2 算出来的 + item.update({k: entry[k] for k in ("op", "mode", "status", "verdict", "item", "call_id") + if entry.get(k) is not None}) + edges.extend({"from": alias, "to": target, "rel": "derived_from", "report": report.node_id} + for target in dict.fromkeys(_catalog_alias_of(catalog, a) for a in entry.get("inputs") or []) + if target and target != alias) elif entry.get("kind") in ENTITY_KINDS: # 表和字段:叫什么、出现在哪几次查询里、来历(表结构快照 / SQL / 结果列)各自封存了没有 item.update({k: entry[k] for k in ("name", "table", "tables", "qualified", "source") if entry.get(k)}) @@ -934,6 +955,9 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed, entry, doc, sealed, masks) if seg.get("state") != "deterministic" or cite.get("status") != "resolved": return [] + if cite.get("kind") == LEDGER_KIND or (cite.get("kind") == "cell" and entry.get("kind") == LEDGER_KIND): + # 分析结论:判定语、具名标量、结果表的一格都接同一条链(判定 → 方法 → 计算 → 输入 → 复算 → 封存) + return await _analysis_chain(seg, cite, entry, doc, sealed, masks) if cite.get("kind") == "cell": locator = cite.get("locator") or {} # 只有直接引用的单元格才可能推断来源(P4-SPEC 2.8.2)。指标链里的查询步骤不带:指标片段的推断来源只会 @@ -953,6 +977,272 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed, return await _metric_chain(seg, cite, entry, doc, sealed, masks) +# -------------------------------------------------------------------------- +# 分析结论的出处链 +# -------------------------------------------------------------------------- + +#: Agent 写的假设(hypothesis)旁边那句:它不是结论,只在证据面板显示(细案 01 §7.3) +HYPOTHESIS_NOTE = "Agent 写的假设:说明这次在验证什么,不是分析结论,也不进报告正文" +UNSEALED_ANALYSIS = "这份分析结论不属于封存范围内的任何事件(封存前的台账里没有它),不能作为证据展示" +TAMPERED_ANALYSIS = "分析结论与哈希不一致,疑似被修改,不展示其中的判定和数" +MISSING_ANALYSIS = "无法读取分析结论" +#: 口径卡指标上的可比性结论不进台账(00-总方案 §3.4):它记在封存过的口径卡指标集里,卡上记着它的 sha256,属于封存链的 +#: 延伸(同推演认 value_artifact)。指标那一步没封存的,这份结论也不算封存 +COMPARABILITY_CHAINED = ("这份可比性结论不进台账:口径卡执行时自动做的,记在封存过的口径卡指标集里(卡上记着它的 sha256)," + "随卡一起封存") +UNSEALED_COMPARABILITY = "这份可比性结论记在口径卡上,可这张卡的指标集不在封存范围内,不能作为证据展示" +#: 每个算子用到的阈值(细案 01 §10「用在」一栏):方法一步只列这几个,判定标准照它们写 +_OP_SETTINGS: dict[str, tuple[str, ...]] = { + "decompose": ("main_share", "concentration", "offset_ratio", "flat_pct", "min_denominator", "max_drill_depth", + "table_top_k"), + "baseline": ("k_warn", "k_alert", "min_history", "max_history"), + "trend": ("trend_min_points", "trend_alpha", "min_history", "max_history"), + "differ": ("differ_alpha", "min_n", "min_expected"), + "comparable": (), +} +#: 输入的角色(analysis.contract.AnalysisInput.role)给人看的叫法 +_INPUT_ROLES = {"data": "数据", "base": "基期", "target": "本期", "headline_base": "基期总数", + "headline_target": "本期总数", "parent": "上一层拆解"} +#: 闭合核对:headline 的那一格对着结论里的哪个总数(细案 01 §2.1) +_HEADLINE_TOTALS = {"headline_base": "base_total", "headline_target": "target_total"} +#: 计算一步最多给结果表的几行(前 table_top_k 段,加上被引用的那一行前后各 WINDOW 行) +MAX_ANALYSIS_ROWS = MAX_WINDOW_ROWS + + +def _analysis_of(artifact: str) -> tuple[dict[str, Any] | None, bool | None]: + """取回一份分析结论:(结论, hash_ok)。哈希对不上的 (None, False),取不回来的 (None, None),内容不是分析结论的 (None, None)。""" + content, ok = _fetch(artifact) if artifact else (None, None) + return (content, ok) if ok is True and is_result(content) else (None, False if ok is False else None) + + +def _catalog_alias_of(catalog: dict[str, Any], artifact: Any) -> str | None: + """这份工件在报告目录里的编号:查询快照是 Q…,分析结论是 A…。""" + if not artifact: + return None + return next((alias for alias, e in catalog.items() + if isinstance(e, dict) and e.get("kind") in ("query", LEDGER_KIND) and e.get("artifact") == artifact), None) + + +def _analysis_cited(cite: dict[str, Any], result: dict[str, Any] | None) -> dict[str, Any]: + """这一处引用的是结论的哪一项:判定语(phrase)、具名标量(value:键、原值、换算)、结果表的一格(cell:行、列、原值)。""" + locator = cite.get("locator") or {} + if cite.get("kind") == "cell": + row, column = locator.get("row"), locator.get("column") + raw = None + table = (result or {}).get("table") if isinstance((result or {}).get("table"), dict) else None + if table is not None: + with contextlib.suppress(Exception): + raw, column = locate_cell(table, row, column) + return {"what": "cell", "row": row, "column": column, "raw": raw} + if locator.get("key"): + values = (result or {}).get("values") if isinstance((result or {}).get("values"), dict) else {} + return {"what": "value", "key": locator["key"], "raw": values.get(locator["key"]), + **({"conv": cite["conv"]} if cite.get("conv") else {})} + return {"what": "phrase"} + + +def _analysis_rendered(cite: dict[str, Any], result: dict[str, Any]) -> str | None: + """按封存的结论重新渲染这一处引用(判定语按文本格、具名标量和结果表的格按单元格的规则)。渲染不出来是 None。""" + try: + cited = _analysis_cited(cite, result) + if cited["what"] == "phrase": + return render_cell(result.get("phrase"), kind="text") if isinstance(result.get("phrase"), str) else None + if cited["what"] == "value": + return render_cell(cited["raw"], cite.get("conv")) + return render_cell(cited["raw"], cite.get("conv"), plain=cite.get("plain") is True) + except RenderError: + return None + + +async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], doc: dict[str, Any], + sealed: _Sealed, masks: _Masks, *, chained: bool | None = None, + queries: bool = True) -> list[dict[str, Any]]: + """分析结论的出处链(00-总方案 §3.3):判定 → 方法 → 计算 → 输入 → 复算 → 封存。 + + - 判定(analysis):判定语、判定标准、判定码和点名的段、附带说明、算不了的原因,这一处引用的是哪一项(判定语、具名标量、 + 结果表的一格);Agent 写的假设另列,标成「Agent 写的假设」(HYPOTHESIS_NOTE) + - 方法(analysis_method):方法与版本、规范化后的参数、实际用的阈值(只列这个算子用到的)、现查的表范围(probes) + - 计算(analysis_calc):结果表(前 table_top_k 段,加上被引用的那一行附近)、具名的数、关键数的代入式 + (evidence.analysis_formulas) + - 输入(analysis_input,每份一步):落到报告目录的哪个 Q、用了哪些列和行、headline 那一格和结论里总数的闭合核对、 + 下钻的上一层是哪个 A;后面接上每份输入快照的查询步骤(只认封存范围内交回过的,遮罩照查询步骤的办法) + - 复算(analysis_recompute):按 sha256 取回封存的输入、用同一个函数复算,和封存的逐字段比(evidence.analysis_recheck); + 方法升过版、复算不了都照实写 + - 封存(analysis_seal):这件工件是不是封存范围内的台账记过的、哈希复验通过 + 结论取不回、哈希不符的只给判定和封存两步,照实说为什么。 + + 口径卡指标上的可比性结论(_comparability_view)不进台账:chained 是那个指标的一步封存了没有(卡上记着这份结论的 sha256, + 属于封存链的延伸),封存一步照它认;谁做的写口径卡(origin metric)。queries 为假时不接输入快照的查询步骤(可比性的 + 两份输入就是指标输入的那两份,指标链里已经有了)。""" + catalog = doc.get("catalog") or {} + alias = str(cite.get("alias") or entry.get("alias") or "") + artifact = str(entry.get("artifact") or "") + result, hash_ok = _analysis_of(artifact) + info = sealed.analyses.get(artifact) + in_ledger = sealed.trusted and (str(entry.get("node_id") or ""), artifact) in sealed.ledger \ + if chained is None else sealed.trusted and chained + eid = make_eid(LEDGER_KIND, artifact or None, {}) + rendered = _analysis_rendered(cite, result) if result is not None else None + judged: dict[str, Any] = { + "step": "analysis", "alias": alias, "op": entry.get("op"), "mode": entry.get("mode"), + "method_label": method_label(entry.get("op"), entry.get("mode")), + "origin": "metric" if chained is not None else "card" if analysis_card(entry) else "agent", + **{k: entry[k] for k in ("item", "call_id") if entry.get(k)}, + "node_id": entry.get("node_id"), "label": _node_label(sealed, entry.get("node_id")) or None, + "artifact": artifact or None, "eid": entry.get("eid"), "eid_ok": entry.get("eid") == eid, + "cited": _analysis_cited(cite, result), "rendered": seg.get("text"), + "render_ok": rendered is not None and rendered == seg.get("text"), "hash_ok": hash_ok, + } + seal = {"step": "analysis_seal", "alias": alias, "node_id": entry.get("node_id"), "artifact": artifact or None, + "ledger": info is not None, "hash_ok": hash_ok, "eid_ok": judged["eid_ok"], + "sealed": in_ledger and hash_ok is True and judged["eid_ok"]} + if chained is not None: + # 封存的路子是口径卡的指标集(卡上记着这份结论的 sha256),不是台账 + seal.update(via="metric_set", note=COMPARABILITY_CHAINED if chained else UNSEALED_COMPARABILITY) + elif info is None: + seal["note"] = UNSEALED_ANALYSIS + if result is None: + judged["note"] = TAMPERED_ANALYSIS if hash_ok is False else MISSING_ANALYSIS + return [judged, seal] + verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else None + judged.update( + status=result.get("status"), reason_code=result.get("reason_code"), reason=result.get("reason"), + phrase=result.get("phrase"), criterion=result.get("criterion"), verdict=verdict, + caveats=[{k: c.get(k) for k in ("code", "text")} for c in result.get("caveats") or [] if isinstance(c, dict)]) + if isinstance(result.get("hypothesis"), str) and result["hypothesis"].strip(): + judged.update(hypothesis=result["hypothesis"], hypothesis_note=HYPOTHESIS_NOTE) + settings = result.get("settings") if isinstance(result.get("settings"), dict) else {} + method = { + "step": "analysis_method", "alias": alias, "method": result.get("method"), + "method_label": judged["method_label"], "params": result.get("params") or {}, + "settings": [{"key": k, "label": ANALYSIS_LABELS.get(k, k), "value": settings[k]} + for k in _OP_SETTINGS.get(str(result.get("op")), ()) if k in settings], + "probes": [p for p in result.get("probes") or [] if isinstance(p, dict)], + } + inputs, steps = await _analysis_inputs(alias, result, catalog, doc, sealed, masks, queries=queries) + hidden = {str(c).lower() for q in steps for c in q.get("masked") or []} + calc = _analysis_calc(alias, result, judged["cited"], hidden) + again = await _analysis_recheck(result, sealed) + recheck = {"step": "analysis_recompute", "alias": alias, **again} + return [judged, method, calc, *inputs, *steps, recheck, seal] + + +def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hidden: set[str]) -> dict[str, Any]: + """计算一步:结果表(前 table_top_k 段加上被引用的那一行附近,最多 MAX_ANALYSIS_ROWS 行,高亮被引用的格)、具名的数(原值、 + 渲染出来的字)、关键数的代入式。结果表里万一有取自遮罩列的列(输入查询步骤里遮着的列名),照查询步骤写「已遮罩」。""" + values = result.get("values") if isinstance(result.get("values"), dict) else {} + shown_values = [] + for key, raw in values.items(): + try: + text = render_cell(raw) + except RenderError: + text = MISSING + shown_values.append({"key": str(key), "raw": raw, "rendered": text, + **({"cited": True} if cited.get("what") == "value" and cited.get("key") == key else {})}) + step: dict[str, Any] = {"step": "analysis_calc", "alias": alias, "values": shown_values, + "formulas": analysis_formulas(result)} + table = result.get("table") if isinstance(result.get("table"), dict) else None + if table is None or not isinstance(table.get("rows"), list): + return step + columns = [str(c) for c in table.get("columns") or []] + rows = table["rows"] + settings = result.get("settings") if isinstance(result.get("settings"), dict) else {} + top = settings.get("table_top_k") if isinstance(settings.get("table_top_k"), int) else 10 + index = set(range(min(top, len(rows)))) + row = cited.get("row") if cited.get("what") == "cell" else None + if isinstance(row, int): + index |= {i for i in range(row - WINDOW, row + WINDOW + 1) if 0 <= i < len(rows)} + picked = sorted(index)[:MAX_ANALYSIS_ROWS] + masked = {i for i, c in enumerate(columns) if c.lower() in hidden} + step["table"] = { + "columns": columns, "labels": dict(table.get("labels") or {}) if isinstance(table.get("labels"), dict) else {}, + "total_rows": len(rows), "row_index": picked, + "rows": [[MASKED if i in masked else v for i, v in enumerate(rows[r])] if isinstance(rows[r], list) else rows[r] + for r in picked], + "masked": [columns[i] for i in sorted(masked)], + "highlight": {"cells": [[row, str(cited.get("column"))]] if isinstance(row, int) else []}, + **({"window_truncated": True} if len(index) > MAX_ANALYSIS_ROWS else {}), + } + return step + + +async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str, Any], doc: dict[str, Any], + sealed: _Sealed, masks: _Masks, *, queries: bool = True + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """输入:每份一步(analysis_input),再给每份输入快照一个查询步骤(_query_steps,高亮用到的列;整份用到的给前 + MAX_WINDOW_ROWS 行;queries 为假时不给)。headline 的那一格另核对和结论里的总数闭不闭合(照实写判定是不是 + unclosed,不在这里重算容差);下钻的上一层落到报告目录的哪个 A。""" + values = result.get("values") if isinstance(result.get("values"), dict) else {} + verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else {} + unclosed = verdict.get("code") == "unclosed" + steps: list[dict[str, Any]] = [] + cells: dict[str, list[tuple[Any, Any]]] = {} + for item in result.get("inputs") or []: + if not isinstance(item, dict): + continue + artifact = str(item.get("artifact") or "") + role = str(item.get("role") or "") + step: dict[str, Any] = { + "step": "analysis_input", "of": alias, "ref": item.get("ref"), "role": role, + "role_label": _INPUT_ROLES.get(role, role or "输入"), "artifact": artifact or None, + **{k: item[k] for k in ("columns", "rows", "row", "column", "value") if k in item}, + } + if role == "parent": + step["alias"] = _catalog_alias_of(catalog, artifact) + parent, ok = _analysis_of(artifact) + step.update(sealed=sealed.trusted and artifact in sealed.analyses, hash_ok=ok, + **({"phrase": parent.get("phrase")} if parent else {})) + steps.append(step) + continue + query = sealed.queries.get(artifact) + step.update(query=_catalog_alias_of(catalog, artifact), node_id=(query or {}).get("node_id"), + sealed=sealed.trusted and query is not None) + if role in _HEADLINE_TOTALS and _HEADLINE_TOTALS[role] in values: + total = values[_HEADLINE_TOTALS[role]] + with contextlib.suppress(RenderError): + step["closure"] = {"total_key": _HEADLINE_TOTALS[role], "total": render_cell(total), + "headline": render_cell(item.get("value")), "closed": not unclosed} + steps.append(step) + if artifact and query is not None: + wanted = cells.setdefault(artifact, []) + if isinstance(item.get("row"), int) and item.get("column"): + wanted.append((item["row"], item["column"])) + for column in item.get("columns") or []: + picked = item.get("rows") + rows = list(range(MAX_WINDOW_ROWS)) if picked == "all" else \ + [r for r in picked if isinstance(r, int)][:MAX_WINDOW_ROWS] if isinstance(picked, list) else [] + wanted.extend((r, column) for r in rows) + found: list[dict[str, Any]] = [] + for artifact, picked in cells.items() if queries else (): + snap, _ = masks.fetch(artifact) + total = len(snap["rows"]) if isinstance(snap, dict) and isinstance(snap.get("rows"), list) else 0 + picked = list(dict.fromkeys((r, c) for r, c in picked if not isinstance(r, int) or r < total)) + for sub in await _query_steps(artifact, picked, doc, sealed, masks): + found.append({**sub, "input_of": alias}) + return steps, found + + +async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]: + """复算(evidence.analysis_recheck):输入快照交工件原内容,下钻的上一层交那份结论,都按 sha256 取回、复验哈希,在线程里 + 复算、比较。只认封存范围内的——输入快照要是这次运行交回过的查询(sealed.queries),上一层要是台账记过的分析结论; + 不在范围内、哈希对不上的一律当取不回(复算照实说取不回哪一份)。""" + snapshots = {str(i["artifact"]) for i in result.get("inputs") or [] + if isinstance(i, dict) and i.get("artifact") and i.get("role") != "parent"} + parents = {str(i["artifact"]) for i in result.get("inputs") or [] + if isinstance(i, dict) and i.get("artifact") and i.get("role") == "parent"} + + def load(artifact: str) -> Any: + if artifact not in snapshots or artifact not in sealed.queries: + return None + content, ok = _fetch(artifact) + return content if ok is True else None + + def load_result(artifact: str) -> Any: + return _analysis_of(artifact)[0] if artifact in parents and artifact in sealed.analyses else None + + return await asyncio.to_thread(analysis_recheck, result, load, load_result=load_result) + + def _missing_input(cite: dict[str, Any], entry: dict[str, Any]) -> bool: """引用的指标存在,只是这次缺输入没有值。引用写错(目录里没有、同名指标有歧义)的不算。""" return (cite.get("kind") == "metric" and cite.get("status") == "unresolved" and entry.get("kind") == "metric" @@ -1007,6 +1297,12 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 期 4:这个指标做没做过双盲复算、结果如何。没有双盲复算复核过这份指标集时没有这个键,老运行一字不差 if checked := _dual_check_of(sealed, str(entry.get("node_id") or ""), artifact, metric_id, metric): step["dual_check"] = checked + # 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查(analysis.contract.MetricComparability),点得开那份 + # 可比性结论(artifact)。没做过检查的指标没有这个键 + if isinstance((metric or {}).get("comparability"), dict): + step["comparability"] = await _comparability_view( + metric["comparability"], sealed, masks, chained=step["sealed"], node_id=str(entry.get("node_id") or ""), + doc=doc) card_node = str(entry.get("node_id") or "") if metric is not None and metric.get("inputs"): # 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说 @@ -1047,6 +1343,36 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s return [step, *inputs, *upstream, *queries] +async def _comparability_view(comparability: dict[str, Any], sealed: _Sealed, masks: _Masks, *, chained: bool, + node_id: str, doc: dict[str, Any]) -> dict[str, Any]: + """指标步骤里的可比性:判定(comparable / conditional / incomparable)、判定语、原因(一句一个),连同那份可比性结论的 + 工件:取回、复验哈希,带上它的判定标准、方法和现查结果,再给一条点得开的出处链(steps:判定 → 方法 → 计算 → 输入 → + 复算 → 封存,同分析结论的引用,_analysis_chain;两份输入快照的查询步骤指标链里已经有了,不再给)。 + + 这份结论不进台账,只从口径卡的指标集里引用:sealed 认的是哈希复验通过,而且记着它的指标集封存了(chained:指标步骤 + 本身封存了;内容寻址,卡上记着它的 sha256——同推演认 value_artifact),或者它恰好也在封存范围内的台账里。""" + artifact = str(comparability.get("artifact") or "") + result, hash_ok = _analysis_of(artifact) + scoped = sealed.trusted and bool(artifact) and (chained or artifact in sealed.analyses) + view: dict[str, Any] = { + "verdict": comparability.get("verdict"), "phrase": comparability.get("phrase"), + "reasons": [str(r) for r in comparability.get("reasons") or [] if r], "artifact": artifact or None, + "hash_ok": hash_ok, "sealed": scoped and hash_ok is True, + } + if result is not None: + view.update(method=result.get("method"), criterion=result.get("criterion"), + caveats=[{k: c.get(k) for k in ("code", "text")} for c in result.get("caveats") or [] + if isinstance(c, dict)], + probes=[p for p in result.get("probes") or [] if isinstance(p, dict)]) + if artifact: + entry = {"kind": LEDGER_KIND, "artifact": artifact, "node_id": node_id, "op": "comparable", "mode": None, + "eid": make_eid(LEDGER_KIND, artifact, {})} + cite = {"kind": LEDGER_KIND, "alias": None, "locator": {}, "status": "resolved"} + view["steps"] = await _analysis_chain({"text": str(comparability.get("phrase") or "")}, cite, entry, doc, + sealed, masks, chained=scoped, queries=False) + return view + + async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Masks", catalog: dict[str, Any], wanted: dict[str, list[tuple[Any, Any]]], seen: set[tuple[str, str]], depth: int = 1, *, card_node: str = "") -> list[dict[str, Any]]: @@ -2905,16 +3231,51 @@ async def evidence_provenance(run_id: str, segment_id: str, report: str | None = def _export_cite_key(seg: dict[str, Any]) -> tuple | None: - """数字 / 取值片段的引用键:同一处引用(同一个指标、同一格、同样的换算)共用一条出处链。 - 别的片段(表名、引文)的链跟着片段走,不归并。""" - if seg.get("kind") not in ("number", "value"): + """数字 / 取值 / 判定语片段的引用键:同一处引用(同一个指标、同一格、同一条分析结论的同一项、同样的换算)共用一条 + 出处链。别的片段(表名、引文)的链跟着片段走,不归并。具名标量的键另接在后面:没有它的引用键和以前一模一样。""" + if seg.get("kind") not in ("number", "value", LEDGER_KIND): return None cite = seg.get("cite") if not isinstance(cite, dict): return None loc = cite.get("locator") if isinstance(cite.get("locator"), dict) else {} - return (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"), - loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue")) + key = (cite.get("kind"), cite.get("alias"), cite.get("status"), cite.get("conv"), + loc.get("metric"), loc.get("row"), loc.get("column"), loc.get("field"), seg.get("issue")) + return (*key, loc["key"]) if loc.get("key") else key + + +#: 报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):导出页、可验证报告按它渲染,编号点得开。各组的键 +SCOPE_GROUPS = ("phenomena", "decomposed", "excluded", "refused") +#: 分析范围里一个编号在导出页数据块里的片段键:refs、notes 按它找出处链(不和片段 id s0、s1… 撞) +SCOPE_REF = "scope:{alias}" + + +def scope_aliases(doc: dict[str, Any]) -> list[str]: + """报告文档的「分析范围」里点名的分析结论编号,按出现的先后、去重。没有这一段的文档是空列表。""" + scope = doc.get("analysis_scope") if isinstance(doc.get("analysis_scope"), dict) else None + if scope is None: + return [] + out = [str(item["alias"]) for group in SCOPE_GROUPS for item in scope.get(group) or [] + if isinstance(item, dict) and isinstance(item.get("alias"), str) and item["alias"]] + return list(dict.fromkeys(out)) + + +async def _scope_chains(doc: dict[str, Any], sealed: _Sealed, report_node: str) -> dict[str, list[dict[str, Any]]]: + """分析范围里每个编号的出处链(和点开正文里的判定语同一条 _chain,按那条结论的判定语认):{编号: 链}。目录里没有的 + 编号不给(文档里写着、目录里却没有,只能是文档有问题,范围那一项点不开)。""" + catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {} + out: dict[str, list[dict[str, Any]]] = {} + for alias in scope_aliases(doc): + entry = catalog.get(alias) + if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND: + continue + result, _ = _analysis_of(str(entry.get("artifact") or "")) + seg = {"id": SCOPE_REF.format(alias=alias), "kind": LEDGER_KIND, "state": "deterministic", + "text": render_cell(result.get("phrase"), kind="text") if result and result.get("phrase") else alias, + "cite": {"alias": alias, "kind": LEDGER_KIND, "status": "resolved", "locator": {}, + "eid": entry.get("eid")}} + out[alias] = await _chain(seg, doc, sealed, report_node) + return out async def _export_withheld(sealed: _Sealed, doc: dict[str, Any]) -> frozenset[str]: @@ -3022,6 +3383,14 @@ async def export_report_html(run_id: str, report: str | None = None) -> Response refs[sid] = str(ck) if key is not None: memo[key] = str(ck) + # 系统写的「分析范围」:每个编号点得开,链和点开正文里的判定语是同一条(引用键相同的共用) + for alias, chain in (await _scope_chains(doc, sealed, chosen.node_id)).items(): + sid = SCOPE_REF.format(alias=alias) + ck = (LEDGER_KIND, alias, "resolved", None, None, None, None, None, None) + if ck not in memo: + memo[ck] = str(ck) + chains[str(ck)] = chain + refs[sid] = memo[ck] async with SessionLocal() as session: run = await session.get(Run, run_id) if run is None: # 两次读之间被删掉了 @@ -3739,6 +4108,14 @@ def _note(seg: dict[str, Any], unit: dict[str, Any]) -> str: "仅核对它存在且本次用到,不核对其业务含义") if seg.get("state") == "deterministic" and seg.get("kind") == "quote": return "这段原话在知识库检索命中的片段中逐字出现(空白归一化后比对),已由系统核对" + if seg.get("state") == "deterministic" and cite.get("kind") == LEDGER_KIND: + if (cite.get("locator") or {}).get("key"): + return "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写" + return ("判定语由系统按平台的方法算出判定、按模板写成,未经模型转写;判定标准、计算过程、输入和复算见出处链。" + "判定只说明变化来自哪里,不说明原因") + if seg.get("state") == "deterministic" and cite.get("kind") == "cell" \ + and str(cite.get("alias") or "").startswith(CATALOG_PREFIX): + return "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写" if seg.get("state") == "deterministic" and cite.get("kind") == "cell": return "该值由系统从查询快照中取出、按固定规则显示,未经模型转写" if seg.get("state") == "deterministic": @@ -3994,21 +4371,30 @@ async def _judge_loader(doc: dict[str, Any], catalog: dict[str, Any], units: lis read = loader or load masks = _Masks() shown: dict[str, Any] = {} + wanted: list[tuple[str, Any]] = [] for alias in dict.fromkeys(a for c in candidates(doc, units) for a in c.aliases): entry = catalog.get(alias) - if not isinstance(entry, dict) or entry.get("kind") != "query" or not entry.get("artifact"): + if not isinstance(entry, dict) or not entry.get("artifact"): continue - artifact = str(entry["artifact"]) + if entry.get("kind") == "query": + wanted.append((str(entry["artifact"]), entry.get("source"))) + elif entry.get("kind") == LEDGER_KIND: + # 分析结论的摘录给它输入查询的 SQL:输入快照同样逐份遮(结论本身不经这里,摘录照它自己的办法取) + with contextlib.suppress(Exception): + result = read(str(entry["artifact"])) + wanted.extend((str(i["artifact"]), None) for i in (result or {}).get("inputs") or [] + if isinstance(i, dict) and i.get("artifact") and i.get("role") != "parent") + for artifact, source in wanted: if artifact in shown: continue try: snap = read(artifact) except Exception: # noqa: BLE001 - 读不出来、不在封存范围里:摘录本来就不给它,交给 prepare 照它的办法处理 continue - if not isinstance(snap, dict): + if not isinstance(snap, dict) or not isinstance(snap.get("rows"), list): continue recorded = _mask_names(snap.get("mask_columns")) - sure = set((await masks.sets(artifact, entry.get("source") or snap.get("source"), snap)).sure) + sure = set((await masks.sets(artifact, source or snap.get("source"), snap)).sure) shown[artifact] = {**snap, "mask_columns": sorted(recorded | sure)} if sure - recorded else snap def reader(artifact: str) -> Any: @@ -4262,8 +4648,11 @@ GROUPS = (("cited", "有出处"), ("none", "无证据"), ("suspicious", "可疑 _INTEGRITY = frozenset({"bad_schema", "segment_mismatch", "structural_text", "render_mismatch", "eid_mismatch", "state_mismatch"}) #: 落在已经列出的片段上也单列一行的违规:文档对不上的,和引用了遮罩列的格子的(masked_cell:引用的那一格落在的正是一个 -#: 「有出处」的单元格片段,不单列的话审计表、导出的 CSV 里只有一行普通的「有出处」) -_LISTED_ALWAYS = _INTEGRITY | {"masked_cell"} +#: 「有出处」的单元格片段,不单列的话审计表、导出的 CSV 里只有一行普通的「有出处」);分析说法核对的三种(判断词没挂分析 +#: 结论、判断和结论对不上、因果说法没有依据,engine/analysis_claims.py):句子里的判定语、数照样是「有出处」的片段,说法本身 +#: 有问题要单独看得到 +ANALYSIS_CLAIM_CODES = frozenset({"analysis_claim_uncited", "analysis_claim_mismatch", "causal_unsupported"}) +_LISTED_ALWAYS = _INTEGRITY | {"masked_cell"} | ANALYSIS_CLAIM_CODES UNCITED_CLAIM = "这句结论没有附依据(既没有引用,也没有注明依据)" UNCITED_CLAIM_COUNTED = "这句结论没有附依据。本报告要求结论句附依据,出具时计入缺口" UNCITED_CLAIM_WITHHELD = "这句结论没有附依据,按出具契约不予出具" @@ -4297,15 +4686,17 @@ SEAL_NONE = "未封存:运行尚未结束,或正停在人工审批" _CSV_LABELS: dict[str, dict[Any, str]] = { "group": dict(GROUPS), "kind": {"number": "数字", "value": "值", "entity": "表名、字段名", "quote": "引文", "claim": "结论句", - "violation": "违规"}, + "violation": "违规", LEDGER_KIND: "分析判定"}, "state": {"deterministic": "有出处", "none": "无证据", "candidate": "候选"}, "issue": {"uncited_number": "没有出处的数字", "unresolved_ref": "引用无法解析", "unknown_entity": "疑似不存在的名称", "unverified_entity": "无法核实", "uncited_claim": "结论句未附依据", "no_candidate": "未找到候选", "contradicted_claim": "证据相矛盾", "insufficient_claim": "证据不足", "unsupported_claim": "证据不支持", - "masked_cell": "引用了遮罩列", + "masked_cell": "引用了遮罩列", "inline_see": "依据写在句子中间", "absence_claim": "缺失断言", + "analysis_claim_uncited": "判断词未挂分析结论", "analysis_claim_mismatch": "判断与分析结论不符", + "causal_unsupported": "因果说法没有依据", **{code: "文档不一致" for code in _INTEGRITY}}, "evidence_kind": {"metric": "口径卡指标", "cell": "查询单元格", "input": "运行输入", "table": "表", "column": "字段", - "quote": "知识库原话", "query": "查询结果"}, + "quote": "知识库原话", "query": "查询结果", LEDGER_KIND: "分析结论"}, "sealed": {True: "是", False: "否", None: "—"}, } #: 电子表格会把以这些字符开头的格子当公式算:报告、答案里的字来自模型,照原样写进去就能被人利用 @@ -4507,10 +4898,20 @@ def _segment_row(group: str, report: _Report, field_name: str | None, seg: dict[ if group != "cited": return row kind = cite.get("kind") - if kind == "cell": + if kind == "cell" and entry.get("kind") == LEDGER_KIND: + # 分析结论结果表里的一格:证据种类记分析结论,封存按台账认(结论不是查询快照) + row.update(evidence=_cell_name(alias, cite.get("locator") or {}), evidence_kind=LEDGER_KIND, + artifact=entry.get("artifact"), node_id=entry.get("node_id"), + sealed=_entry_sealed(sealed, entry, inputs)) + elif kind == "cell": artifact = str(entry.get("artifact") or "") row.update(evidence=_cell_name(alias, cite.get("locator") or {}), artifact=artifact or None, node_id=entry.get("node_id"), sealed=sealed.trusted and artifact in sealed.queries) + elif kind == LEDGER_KIND: + # 判定语写结论的叫法(「变化拆解(可加) · 判定语」),具名标量写到键(A3.top_share) + key = (cite.get("locator") or {}).get("key") + row.update(evidence=f"{alias}.{key}" if key else entry.get("label") or alias, artifact=entry.get("artifact"), + node_id=entry.get("node_id"), sealed=_entry_sealed(sealed, entry, inputs) if entry else False) elif kind == "quote": source = cite.get("source") or {} artifact = str(source.get("artifact") or entry.get("artifact") or "") diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 61219418..949d3053 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -23,6 +23,14 @@ 规则同上):点开数字时看得到每个输入的 SQL 和被引用的行,引用链(④)按「已核对的口径卡的输入」认它们。只给 id 的 工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的判不通过)。没被 报告引用、也没被附带的口径卡的输入引用的工件不嵌。 +- 分析结论([[a:A3]]、[[v:A3.r0.列]]、[[see:A3]] 引用的结论工件)照样按原字节嵌入,连同它的输入快照(遮罩规则同上)和 + 下钻的上一层结论;任一份输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id——结果表里的维度值取自输入快照, + 这份文件宁可多挡。浏览器只核哈希、核判定语和数是不是结论里写的那些,不在页面里重算统计:结论随文件附带时,服务端在 + 导出时按封存的输入复算一遍(engine/evidence.analysis_recheck),结果记在这份工件的条目上(recheck),页面照它展示。 + 口径卡指标上的可比性结论(口径卡执行时自动做的,不进台账、只从卡上引用)随附带的卡一起附上,规则同上,引用链(④) + 按「已核对的口径卡记着的可比性」认它。分析工具的 tool.end(预览里是结论的前一截)、分析卡一项的 analysis.end(带着 + 判定语)和结论本身同一个认法:结论随文件附带得了才带规范字符串。 +- 报告文档带着系统写的「分析范围」(analysis_scope)的,页面在正文后面照它渲染,编号点得开那条结论。 - 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。 它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次 @@ -55,9 +63,10 @@ from pathlib import Path from typing import Any from urllib.parse import quote +from app.analysis.contract import ARTIFACT_KIND, LEDGER_KIND, NODE_TYPE as ANALYSIS_NODE, is_result from app.core.artifact_store import canonical_json, content_hash from app.core.events import EventType -from app.engine.evidence import iter_units, query_entry_fields +from app.engine.evidence import analysis_recheck, iter_units, query_entry_fields from app.engine.lineage import in_scope, scoped_id from app.engine.masking import OPAQUE_SQL, SYSTEM_SCHEMAS, indirect_reads from app.engine.merge_query import MERGE_SOURCE @@ -84,7 +93,11 @@ _SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, Even #: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文,文档只给哈希的不带)、口径卡(指标集的 id #: 记在台账里;卡上有值不给的指标、有输入取自遮罩列的不带,见 _card_shown)。成果节点在运行里没有带遮罩的快照时带 #: 目录条目的种类 → 嵌进来的工件种类。运行输入、检索、表和字段的条目不嵌:前者没有工件,后几样不在核对范围里 -_CITED_KINDS = {"metric": "metric_set", "query": "query"} +_CITED_KINDS = {"metric": "metric_set", "query": "query", LEDGER_KIND: ARTIFACT_KIND} +#: 下钻的上一层结论最多跟几层(同分析设置里下钻层数的上限) +_PARENT_DEPTH = 6 +#: 分析卡里一项做完的事件(core/events.EventType.ANALYSIS_END):带着那一项的判定、判定语和结论工件(analysis_artifact) +_ANALYSIS_END = "analysis.end" #: 没挂在工作流上的运行(画布、问数据当场搭的):老数据叫「临时图」,新的叫「未保存的工作流」,也可能是空的。 #: 和前端 lib/terms.ts 的 runName 同一个规则 @@ -202,6 +215,22 @@ class _Store: self._seen = seen return self._seen + def analysis_masked(self, artifact: str, depth: int = 0) -> bool: + """这份分析结论算不算带遮罩(只给 id):结果表、判定语里的维度值取自它的输入快照。遮罩列不能当维度、度量 + (app/analysis 在调用时就挡下),可这份文件按自己宁可多挡的认法:任一份输入快照证明不了没碰遮罩列(masked), + 结论也只给 id;下钻的上一层结论同样认(最多 _PARENT_DEPTH 层)。读得出字节、却认不出是一份分析结论的,按带遮罩算 + (同口径卡)。""" + key = f"analysis:{artifact}" + if key not in self._masked: + result = self.content(artifact) + if not is_result(result): + self._masked[key] = self.raw(artifact) is not None + return self._masked[key] + self._masked[key] = any( + (self.analysis_masked(a, depth + 1) if depth < _PARENT_DEPTH else True) if role == "parent" + else self.masked(a) for a, role in _analysis_inputs(result)) + return self._masked[key] + def card_masked(self, artifact: str, events: Sequence[tuple[int, str, str | None, Any]]) -> bool: """这张口径卡算不算带遮罩:卡上有一个输入取自遮罩列,或者遮没遮核对不了。卡上记着每个输入的值(cell() 取的格、 Agent 字段、按位置取的 vars.res.rows[0][2])和代入式,整张卡不随文件附带——没法只拿掉几个值,拿掉了哈希就对不上。 @@ -1003,10 +1032,11 @@ def _report_events(events: Iterable[tuple[int, str, str | None, Any]]) -> list[d def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]: - """报告正文引用到的口径卡和查询快照:(工件 id, 种类, 来源节点),按正文顺序、去重。 + """报告正文引用到的口径卡、查询快照和分析结论:(工件 id, 种类, 来源节点),按正文顺序、去重。 - 看每个解析成功的引用(行内的数字、单元格,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡, - 查询条目指向快照。整表在组装时已经展开成每格一个单元格引用,不用另看。""" + 看每个解析成功的引用(行内的数字、单元格、判定语,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡, + 查询条目指向快照,分析结论条目指向结论工件。整表在组装时已经展开成每格一个单元格引用,不用另看。报告文档里系统写的 + 「分析范围」点名的结论也算(页面上点得开它们)。""" catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {} out: list[tuple[str, str, str | None]] = [] for _, unit in iter_units(doc): @@ -1021,6 +1051,34 @@ def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]: if kind and isinstance(entry.get("artifact"), str) and entry["artifact"]: owner = entry.get("node_id") out.append((entry["artifact"], kind, str(owner) if owner else None)) + scope = doc.get("analysis_scope") if isinstance(doc.get("analysis_scope"), dict) else {} + for group in ("phenomena", "decomposed", "excluded", "refused"): + for item in scope.get(group) or []: + entry = catalog.get(item.get("alias")) if isinstance(item, dict) else None + if isinstance(entry, dict) and entry.get("kind") == LEDGER_KIND and isinstance(entry.get("artifact"), str) \ + and entry["artifact"]: + owner = entry.get("node_id") + out.append((entry["artifact"], ARTIFACT_KIND, str(owner) if owner else None)) + return list(dict.fromkeys(out)) + + +def _analysis_inputs(result: Any) -> list[tuple[str, str]]: + """一份分析结论记着的输入:(工件 id, 角色),按先后、按工件去重。角色是 parent 的是下钻的上一层结论,别的是查询快照。""" + out: dict[str, str] = {} + for item in (result.get("inputs") or []) if isinstance(result, dict) else []: + if isinstance(item, dict) and isinstance(item.get("artifact"), str) and item["artifact"]: + out.setdefault(item["artifact"], "parent" if item.get("role") == "parent" else "data") + return list(out.items()) + + +def _card_comparability(card: Any) -> list[str]: + """口径卡指标上记着的可比性结论(contract.MetricComparability 的 artifact),按先后、去重。这份结论不进台账,只从卡上 + 引用:卡随文件附带时一并附上,引用链(④)按「已核对的口径卡记着的可比性」认它。""" + out: list[str] = [] + for metric in (card.get("metrics") or []) if isinstance(card, dict) else []: + found = metric.get("comparability") if isinstance(metric, dict) else None + if isinstance(found, dict) and isinstance(found.get("artifact"), str) and found["artifact"]: + out.append(found["artifact"]) return list(dict.fromkeys(out)) @@ -1085,7 +1143,8 @@ class _Artifacts: # 服务端的文件不在了:只给 id,浏览器照实说核对不了 item["missing"] = True elif masked or (kind == "query" and self.store.masked(artifact)) \ - or (kind == "metric_set" and self.store.card_masked(artifact, self.events)): + or (kind == "metric_set" and self.store.card_masked(artifact, self.events)) \ + or (kind == ARTIFACT_KIND and self.store.analysis_masked(artifact)): item["masked"] = True # 不嵌内容,浏览器就算不出这份文件对不对得上:服务端自己看到的照实带上,核对页据此判不通过、说清原因。 # 以前被改过的卡原样嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢 @@ -1101,6 +1160,40 @@ class _Artifacts: return item +def _attach_analysis(artifacts: _Artifacts, store: _Store, item: dict[str, Any] | None, artifact: str, + owner: str | None, cited_queries: list[str], depth: int = 0) -> None: + """随文件附带的分析结论:它的输入快照(种类 query,遮罩照常认)、下钻的上一层结论(同样附带它的输入,最多 + _PARENT_DEPTH 层)一并列进清单,再把服务端的复算结论记在这份结论的条目上(recheck)。只给 id 的结论、被改过的 + (哈希对不上)不顺着它去附带别的:被改过的结论说输入是哪几份,不能替它圆。""" + if item is None or "b64" not in item or not store.intact(artifact) or "recheck" in item: + return + result = store.content(artifact) + if not is_result(result): + return + for input_id, role in _analysis_inputs(result): + if role == "parent": + if depth < _PARENT_DEPTH: + parent = artifacts.add(input_id, ARTIFACT_KIND, owner) + _attach_analysis(artifacts, store, parent, input_id, owner, cited_queries, depth + 1) + continue + artifacts.add(input_id, "query", owner) + cited_queries.append(input_id) + item["recheck"] = _recheck(result, store) + + +def _recheck(result: dict[str, Any], store: _Store) -> dict[str, Any]: + """导出时服务端按封存的输入把这份分析结论复算一遍(engine/evidence.analysis_recheck,和证据面板同一个函数):输入快照、 + 上一层结论只认这份文件里哈希对得上的原字节。页面照这个结果写「导出时服务端的复算」,不在浏览器里重算统计。""" + def load(artifact: str) -> Any: + return store.content(artifact) if store.intact(artifact) else None + + def load_result(artifact: str) -> Any: + content = store.content(artifact) if store.intact(artifact) else None + return content if is_result(content) else None + + return analysis_recheck(result, load, load_result=load_result) + + # -------------------------------------------------------------------------- # 事件 # -------------------------------------------------------------------------- @@ -1121,6 +1214,8 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi kind = node_types.get(node_id or "") if kind == NodeType.METRICS: return _card_shown(data, store, masked_run, events, forced) + if kind == ANALYSIS_NODE: + return _analysis_shown(data, store) if kind == NodeType.REPORT: # 预览就是那份文档正文的前一截:文档只给哈希的,这条也只给哈希 preview = data.get("preview") if isinstance(data, dict) else None @@ -1130,10 +1225,24 @@ def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mappi if etype == EventType.TOOL_END: # 查询类:把查询快照的 id 和运行记录连起来。预览里是查询结果的前一截,数据源带遮罩的只给哈希 artifact = data.get("query_artifact") if isinstance(data, dict) else None - return isinstance(artifact, str) and bool(artifact) and not store.masked(artifact) + if isinstance(artifact, str) and artifact: + return not store.masked(artifact) + # 分析工具:预览里是结论的前一截(判定语、具名的数、结果表),和结论本身同一个认法 + found = data.get("analysis_artifact") if isinstance(data, dict) else None + return isinstance(found, str) and bool(found) and _result_shown(found, store) + if etype == _ANALYSIS_END: + # 分析卡的一项:带着判定、判定语,结论随文件附带得了(输入没碰遮罩列)才带 + found = data.get("analysis_artifact") if isinstance(data, dict) else None + return isinstance(found, str) and bool(found) and _result_shown(found, store) return False +def _result_shown(artifact: str, store: _Store) -> bool: + """一份分析结论的内容能不能出现在页面上(事件里带着它的判定语、预览):文件在、认得出是分析结论、输入都没碰遮罩列 + (_Store.analysis_masked)。和这份结论随文件附带时同一个认法。""" + return store.raw(artifact) is not None and not store.analysis_masked(artifact) + + def _card_shown(data: Any, store: _Store, masked_run: bool, events: Sequence[tuple[int, str, str | None, Any]], forced: frozenset[str] = frozenset()) -> bool: """口径卡的节点完成事件带不带规范字符串。预览里逐条写着「指标(名称)= 值」(metrics 节点的 text),赋了变量的另有 @@ -1160,6 +1269,18 @@ def _card_shown(data: Any, store: _Store, masked_run: bool, events: Sequence[tup return not any(store.card_masked(card, events) for card in known) +def _analysis_shown(data: Any, store: _Store) -> bool: + """分析卡的节点完成事件带不带规范字符串。产出是每一项的判定、具名的数、判定语(results),台账里记着每份结论的 id: + 这几份结论都随文件附带(输入没碰遮罩列、文件在)才带,有一份只给 id、读不出来,或者一份都没记的,这条只给哈希—— + 和结论本身同一个认法(_Store.analysis_masked),预览里的维度值不比结论里的多。""" + if not isinstance(data, dict): + return False + results = [str(e["artifact"]) for e in data.get("evidence") or [] + if isinstance(e, dict) and e.get("kind") == LEDGER_KIND and isinstance(e.get("artifact"), str) + and e["artifact"]] + return bool(results) and all(_result_shown(a, store) for a in results) + + def _masked_cards(doc: dict[str, Any]) -> set[str]: """报告文档的证据目录里写成「已遮罩」的指标(withhold_metrics 记了 masked)所在的口径卡。""" catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {} @@ -1318,12 +1439,22 @@ def build_bundle( if kind == "query": cited_queries.append(artifact) continue + if kind == ARTIFACT_KIND: + # 分析结论随文件附带了(输入都没碰遮罩列、文件在、哈希对得上):它的输入快照、下钻的上一层结论一并附上, + # 引用链(④)按「已核对的分析结论的输入」认它们;服务端按封存的输入复算一遍,结果记在条目上(recheck) + _attach_analysis(artifacts, store, item, artifact, owner, cited_queries) + continue # 口径卡随文件附带了(没遮、文件在):它记着的输入——哪个指标的值取自哪次查询的哪一格—— # 那几次查询的快照也一并附上(设了遮罩的照旧只给 id)。点开数字看得到输入的 SQL 和被引用的行; # 引用链(④)按「已核对的口径卡的输入」认它们,被改过的卡(intact 不过)不带,免得替它圆 if kind == "metric_set" and item is not None and "b64" in item and store.intact(artifact): for query, query_owner in _card_input_queries(store.content(artifact)): artifacts.add(query, "query", query_owner) + # 指标上的可比性结论(口径卡执行时自动做的,不进台账、只从卡上引用)同样附上:它的输入快照、服务端的复算 + # 跟着它(_attach_analysis),输入带遮罩的照旧只给 id + for found in _card_comparability(store.content(artifact)): + _attach_analysis(artifacts, store, artifacts.add(found, ARTIFACT_KIND, owner), found, owner, + cited_queries) masked_run = _masked_run(sealed, store, cited_queries, source_masks or {}) shown_events = [] diff --git a/backend/app/bundle/template.html b/backend/app/bundle/template.html index 65166965..9f3f3985 100644 --- a/backend/app/bundle/template.html +++ b/backend/app/bundle/template.html @@ -77,6 +77,9 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } .doc pre code { padding: 0; background: none; } .doc hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; } .doc .link { color: var(--accent); } +.scope { margin-top: 14px; padding: 12px 16px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); } +.scope h4 { margin: 0 0 4px; font-size: 15px; } +.scope ul { margin: 0; padding-left: 1.2em; } .table-wrap { max-width: 100%; margin: 8px 0; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } .table-wrap table { width: 100%; border-collapse: collapse; font-size: 13px; } .table-wrap th, .table-wrap td { padding: 5px 9px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index c760352b..0ba72100 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -19,6 +19,11 @@ * 有格的列,伪造的缩进、列号挤不走核对过的数; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 + * 分析结论(判定语 [[a:A3]]、具名的数 [[a:A3.键]]、结果表的格 [[v:A3.r0.列]])在 ③ ④ 里和查询快照一样核哈希、核引用链 + * (它的输入快照按「已核对的分析结论的输入」认);⑤ 里核正文显示的判定语、数是不是结论里写的那几个字(同服务端的渲染 + * 规则)。本页不重算统计:结论随文件附带时,出处面板展示导出时服务端按封存的输入复算的结论(工件条目上的 recheck)。 + * 口径卡指标上的可比性结论不进台账、只从卡上引用:④ 按「已核对的口径卡记着的可比性」认它,指标的数字详情里展示它。 + * 报告文档带着系统写的「分析范围」(analysis_scope)的,正文后面照它渲染,编号点得开那条结论。 * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、检索快照这类工件标着 * 遮罩)一律算不通过:只可能是文件被改过。报告文档的证据目录里写着取自遮罩列的指标的值时,服务端只给它的哈希 * (升级前生成的文档,或者生成之后数据源才设的遮罩),页面照实说明,这份报告算未能核对。总览只有一项无法核对都没有、 @@ -46,7 +51,10 @@ const RESULT = { pass: '通过', fail: '不通过', na: '无法核对', partial: '部分无法核对' } const MARK = { pass: '✓', fail: '✗', na: '?', partial: '?' } - const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' } + const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照', analysis_result: '分析结论' } + /** 分析结论(app/analysis):工件种类、目录条目种类、分析范围里的几组 */ + const ANALYSIS = 'analysis_result' + const SCOPE_GROUPS = [['phenomena', '现象'], ['decomposed', '拆过'], ['excluded', '排除'], ['refused', '算不了']] const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' } const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' } // rejected:被审批人驳回结束(运行记录 cancelled + end_reason=rejected)。封存里它和手动停止是同一种结束事件(运行取消), @@ -60,7 +68,8 @@ 'node.failed': '节点失败', 'node.skipped': '节点跳过', 'edge.taken': '分支走向', 'llm.start': '模型调用开始', 'llm.end': '模型调用结束', 'llm.thinking': '模型思考', 'tool.start': '工具调用', 'tool.end': '工具返回', 'tool.error': '工具出错', 'tool.gated': '门控判定', 'retrieve.end': '知识检索', 'memory.end': '记忆读写', - 'merge.end': '合并查询', 'sandbox.start': '沙箱开始', 'sandbox.end': '沙箱结束', 'agent.step.start': 'Agent 步骤开始', + 'merge.end': '合并查询', 'analysis.end': '分析卡的一项', + 'sandbox.start': '沙箱开始', 'sandbox.end': '沙箱结束', 'agent.step.start': 'Agent 步骤开始', 'agent.step.end': 'Agent 步骤结束', 'agent.route.start': '调度决策开始', 'agent.route.end': '调度决策结束', 'human.requested': '请求人工审批', 'human.resolved': '人工审批结果', 'approval.inquiry': '审批质询', 'issuance': '出具判定', 'caliber.upgrade': '口径卡升版处置', 'catalog.drift': '数据目录变化', @@ -111,7 +120,7 @@ sumEvents: (n) => `${n} 条带内容的事件;其余事件只附哈希,参与 ② 的清单核对`, sumManifest: (n) => `按 ${n} 条事件的哈希重算`, sumCount: (n, unit) => `${n} ${unit}`, - sumNumbers: (numbers, values) => `数字 ${numbers} 个 · 取值 ${values} 个`, + sumNumbers: (numbers, values, phrases = 0) => `数字 ${numbers} 个 · 取值 ${values} 个${phrases ? ` · 判定语 ${phrases} 个` : ''}`, sumMeta: '与封存范围内已核对的事件对照', showAll: (n) => `展开全部 ${n} 项`, eventMismatch: '内容与记录的哈希不一致', @@ -136,13 +145,16 @@ maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带', /** 报告文档只给哈希:证据目录里写着值取自遮罩列的指标的原值(升级前的报告撰写节点生成的,或者生成之后数据源才设的遮罩) */ maskedDoc: '证据目录里写着取自遮罩列的指标的值(生成时没有按遮罩换掉),未随文件附带,只能核对到哈希', - maskedOdd: '只有查询快照、口径卡和报告文档会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过', + maskedOdd: '只有查询快照、口径卡、分析结论和报告文档会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过', + /** 分析结论只给哈希:它的输入快照有一份证明不了没碰设了遮罩的列(结果表里的维度值取自输入快照) */ + maskedAnalysis: '这份分析结论的输入查询用到或可能用到设了遮罩的列,结论未随文件附带,只能核对到哈希', artifactMissing: '导出时服务端已没有这份工件的文件', artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过', chainDoc: (label) => `报告「${label}」的文档`, chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`, chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`, chainByCard: (label, metric) => `口径卡「${label}」的输入(指标 ${metric})`, + chainByAnalysis: (label) => `分析结论(${label})的输入`, chainFrom: '引用自', chainNoCheckEvent: '找不到这个报告节点的报告自查事件', chainCheckEventFailed: '报告自查事件的内容没有通过核对', @@ -159,6 +171,9 @@ srcCell: (alias, row, column) => `查询 ${alias} · 第 ${row + 1} 行 · ${column}`, srcQuery: (alias) => `查询 ${alias}`, srcInput: (field) => `运行输入「${field}」`, + srcAnalysis: (alias) => `分析结论 ${alias} 的判定语`, + srcAnalysisValue: (alias, key) => `分析结论 ${alias} 的具名的数「${key}」`, + srcAnalysisCell: (alias, row, column) => `分析结论 ${alias} 的结果表 · 第 ${row + 1} 行 · ${column}`, srcNone: '没有出处', srcOther: '其他引用', numUncited: '该数字未使用引用标记,无法核对其来源', @@ -175,6 +190,11 @@ numMetricDiffers: '引用的值与口径卡里的值不一致', numNoCell: (why) => `快照里找不到这一格:${why}`, numCellDiffers: '引用的值与快照里那一格的值不一致', + numNoPhrase: '分析结论里没有判定语', + numNoValue: (key) => `分析结论里没有名为「${key}」的数`, + numValueDiffers: '引用的值与分析结论里记的值不一致', + numNoTable: '分析结论里没有结果表', + numAnalysisCell: '引用的值与分析结论结果表里那一格的值不一致', /** cited 已经加好引号(空值写「空值(NULL)」);expected 和引用的值写法一样时不写(文字取值) */ numShownDiffers: (shown, cited, expected) => `正文显示「${shown}」,引用的值是${cited}` + (expected == null ? '' : `,按显示规则应显示为「${expected}」`), @@ -206,7 +226,7 @@ shownLookalike: '紧挨着长得像数字的字母或符号(l、I、O、上标数字、圈码这类),读起来可能多出一位', shownOdd: '紧挨着这个数的字不在常见写法里,无法确认读者看到的数', numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过', - numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`, + numAria: (kind, text, result) => `${kind === 'value' ? '取值' : kind === 'analysis' ? '判定语' : '数字'} ${text},${result},查看出处`, // 数字详情里口径卡一节:指标、计算方式(原式、代入式)、输入、复算。遮罩未附带的卡没有这一节(原因见核对明细 ③) dMetric: '指标', dUnit: '单位', dExpression: '原式(计算方式)', dSubstituted: '代入式(代入输入后的算式)', dInputs: '输入', dRecompute: '复算', dSourceName: '数据源', dInputValue: '取值', dFrom: '取自', @@ -236,6 +256,19 @@ dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值', dCell: '快照里的值', dShown: '正文显示', dShownSame: '与按显示规则渲染的结果一致', dExpected: '按显示规则应为', dArtifact: '所在工件', dReport: '所在报告', + dPhrase: '判定语', dCriterion: '判定标准', dMethod: '方法与版本', dAnalysis: '分析结论里的值', dRecheck: '复算', + dAnalysisInputs: '输入', dHypothesis: 'Agent 写的假设', dCaveats: '附带说明', + recheckBy: '导出时服务端按封存的输入复算的结论;本页只核哈希,不在浏览器里重算统计', + recheckNone: '导出时没有复算(结论未随文件附带)', + analysisInput: (role, alias) => `${role}${alias ? ` · ${alias}` : ''}`, + // 分析结论输入的角色(analysis.contract.AnalysisInput.role),同证据面板的叫法 + roleLabel: { data: '数据', base: '基期', target: '本期', headline_base: '基期总数', headline_target: '本期总数', + parent: '上一层拆解' }, + dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' }, + dJudged: '判定', dChecks: '各项检查', comparabilityNotAttached: '可比性结论未随文件附带(见核对明细 ③ ④),这里按口径卡的记录展示', + scopeTitle: '分析范围', scopeNote: '这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定语、判定标准、输入和复算。', + scopeMissing: '没拆', scopeAll: '候选维度都拆过了', scopeUnknown: '候选维度核对不了', scopeTests: '检验', + scopeTestsText: (n) => `这次做了 ${n} 次差异检验`, scopeParent: (alias) => `下钻自 ${alias}`, scopeDims: (dims) => `按${dims}`, dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`, nullValue: '空值(NULL)', appendedNote: '这些事件追加在封存之后,不在封存范围内,不参与清单核对,也不能当作证据。', @@ -1260,7 +1293,7 @@ // 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来 // (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了。报告文档 // 只给哈希时页面上没有正文可核,总览不是绿色的通过(overallOf 按报告文档没核对上算) - if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc') { + if (a.kind !== 'query' && a.kind !== 'metric_set' && a.kind !== 'report_doc' && a.kind !== ANALYSIS) { item.result = FAIL item.reason = T.maskedOdd } else if (a.mismatch) { @@ -1268,7 +1301,7 @@ item.reason = T.maskedMismatch } else { item.reason = a.unparsable ? T.maskedUnparsable : a.kind === 'query' ? T.maskedQuery - : a.kind === 'report_doc' ? T.maskedDoc : T.masked + : a.kind === 'report_doc' ? T.maskedDoc : a.kind === ANALYSIS ? T.maskedAnalysis : T.masked } } else if (a.missing) { item.reason = T.artifactMissing @@ -1337,6 +1370,7 @@ if (refs.length && !out.queryAliases.has(id)) out.queryAliases.set(id, refs[0].alias) } out.arts = arts + out.chained = chained // 已核对的口径卡记着的输入 → 输入指向的查询快照。卡本身核过哈希(③ 通过),它记的引用和文档的证据目录 // 同一个分量:报告只引用指标、不直接引用查询时,查询快照就是从这条路嵌进来、也是从这条路认的。 // 没核对过的卡(哈希对不上)不认:被改过的卡说输入来自哪,不能替它圆 @@ -1351,14 +1385,33 @@ } } } + // 指标上的可比性结论不进台账、只从卡上引用(卡记着它的 sha256):已核对的卡记着的,同卡的输入一样认 + for (const metric of Array.isArray(item.json.metrics) ? item.json.metrics : []) { + const c = isObj(metric) && isObj(metric.comparability) ? metric.comparability : null + if (c && typeof c.artifact === 'string' && c.artifact) { + push(byCard, c.artifact, { card: item, metric: metric.id }) + } + } + } + // 已核对的分析结论记着的输入 → 输入快照、下钻的上一层结论。同口径卡:结论本身核过哈希,它记的输入和文档的证据目录 + // 同一个分量;没核对过的(哈希对不上)不认 + const byAnalysis = new Map() // 工件 id → [{ analysis }] + for (const item of out.artifacts) { + if (item.a.kind !== ANALYSIS || item.result !== PASS) continue + for (const input of Array.isArray(item.json.inputs) ? item.json.inputs : []) { + if (isObj(input) && typeof input.artifact === 'string' && input.artifact && input.artifact !== item.a.id) { + push(byAnalysis, input.artifact, { analysis: item }) + } + } } for (const item of out.artifacts) { if (item.a.kind === 'report_doc') continue const evs = byEvent.get(item.a.id) || [] const docs = byDoc.get(item.a.id) || [] const cards = byCard.get(item.a.id) || [] - const ok = evs.length > 0 || docs.length > 0 || cards.length > 0 - out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, cards, result: ok ? PASS : FAIL, + const analyses = byAnalysis.get(item.a.id) || [] + const ok = evs.length > 0 || docs.length > 0 || cards.length > 0 || analyses.length > 0 + out.chain.push({ kind: item.a.kind, art: item, events: evs, docs, cards, analyses, result: ok ? PASS : FAIL, reason: ok ? '' : T.chainUnreferenced }) if (ok) chained.add(item.a.id) } @@ -1371,7 +1424,7 @@ out.docs.push({ report, art, link, doc, trusted: trusted.has(report) }) if (!doc) continue for (const [seg, path] of segmentsOf(doc)) { - if (seg.kind !== 'number' && seg.kind !== 'value') continue + if (seg.kind !== 'number' && seg.kind !== 'value' && seg.kind !== 'analysis') continue // 引用的值在文档 JSON 里的原文:超过 2^53 的整数照原文写进原因和出处面板 const citedToken = () => tokenAt(tokensOf(art), [...path, 'cite', 'value']) const check = trusted.has(report) ? checkNumber(seg, doc, arts, chained, citedToken) @@ -1394,10 +1447,16 @@ const entry = own(doc.catalog, cite.alias) const loc = isObj(cite.locator) ? cite.locator : {} if (cite.kind === 'metric') return T.srcMetric(own(entry, 'name') || loc.metric || cite.ref || '') + if (cite.kind === 'cell' && own(entry, 'kind') === 'analysis' && Number.isInteger(loc.row)) { + return T.srcAnalysisCell(cite.alias, loc.row, String(loc.column ?? '')) + } if (cite.kind === 'cell') { return Number.isInteger(loc.row) ? T.srcCell(cite.alias, loc.row, String(loc.column ?? '')) : T.srcQuery(cite.alias) } if (cite.kind === 'input') return T.srcInput(loc.field || cite.ref || '') + if (cite.kind === 'analysis') { + return loc.key != null ? T.srcAnalysisValue(cite.alias, String(loc.key)) : T.srcAnalysis(cite.alias) + } return T.srcOther } @@ -1418,10 +1477,14 @@ { fmt: 'plain', conv: convOf(cite) })) return shown.result === FAIL ? shown : { ...shown, result: NA, reason: T.numInput } } - if (cite.kind !== 'metric' && cite.kind !== 'cell') return { result: NA, reason: T.numOther, source } + if (cite.kind !== 'metric' && cite.kind !== 'cell' && cite.kind !== 'analysis') { + return { result: NA, reason: T.numOther, source } + } const entry = own(doc.catalog, cite.alias) const info = { source, kind: cite.kind, cite } - const wanted = cite.kind === 'metric' ? 'metric' : 'query' + // 分析结论:判定语、具名的数(cite.kind analysis),和它结果表里的一格(cite.kind cell,目录条目是 analysis) + const analysis = cite.kind === 'analysis' || (cite.kind === 'cell' && own(entry, 'kind') === 'analysis') + const wanted = cite.kind === 'metric' ? 'metric' : analysis ? 'analysis' : 'query' if (!isObj(entry) || entry.kind !== wanted || typeof entry.artifact !== 'string') { return { ...info, result: FAIL, reason: T.numNoEntry } } @@ -1436,6 +1499,7 @@ } if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained } info.citedToken = citedToken() + if (analysis) return checkAnalysis(info, seg, doc, art) if (cite.kind === 'metric') { const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric') const metrics = Array.isArray(art.json.metrics) ? art.json.metrics : [] @@ -1478,6 +1542,51 @@ /** 引用上记的换算(|万、|pct 这类),没写是 null */ const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv) + /** + * 分析结论的一处引用(checkNumber 已核过工件的哈希和引用链):引用的值等于结论里记的那个值,正文显示的字等于按服务端 + * 的规则渲染出来的字。判定语按文本格渲染(engine/evidence.render_cell 的 kind=text:压成一行、竖线换成 ¦),具名的数按 + * 单元格的规则(不做日期时间的改写,同服务端),结果表的一格同查询快照的一格(显示规则照文档记的版本)。不重算统计 + */ + function checkAnalysis(info, seg, doc, art) { + const cite = info.cite + const result = art.json + const loc = isObj(cite.locator) ? cite.locator : {} + info.analysis = result + if (cite.kind === 'cell') { + const table = isObj(result.table) ? result.table : null + if (!table) return { ...info, result: FAIL, reason: T.numNoTable } + let cell + try { + const [raw, column, key] = locateCell(table, loc.row, loc.column) + info.row = loc.row + info.column = column + info.truth = cellValue(raw, columnKind(table, column)) + info.truthToken = tokenAt(tokensOf(art), ['table', 'rows', loc.row, key]) + if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw) + cell = { raw, column } + } catch (e) { + return { ...info, result: FAIL, reason: T.numNoCell(e.message) } + } + if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numAnalysisCell } + const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1 + return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite), + columnKind(table, cell.column), display, own(cite, 'plain') === true)) + } + if (loc.key != null) { + const values = isObj(result.values) ? result.values : {} + if (!Object.prototype.hasOwnProperty.call(values, loc.key)) return { ...info, result: FAIL, reason: T.numNoValue(loc.key) } + const raw = values[loc.key] + info.truth = cellValue(raw) + info.truthToken = tokenAt(tokensOf(art), ['values', loc.key]) + if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw) + if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numValueDiffers } + return checkShown(info, seg, () => renderCell(raw, info.truthToken, convOf(cite), null, 1)) + } + if (typeof result.phrase !== 'string' || !result.phrase.trim()) return { ...info, result: FAIL, reason: T.numNoPhrase } + info.truth = result.phrase + return checkShown(info, seg, () => renderCell(result.phrase, null, null, 'text', 1)) + } + /** 口径卡的单位:服务端是 str(unit or ""),不是文字的本页不认 */ function unitOf(unit) { if (!unit) return '' @@ -1752,7 +1861,7 @@ const eventWhat = (ev) => [EVENT_LABEL[ev.type] || ev.type, ev.node_id ? nodeLabel(ev.node_id) : null].filter(Boolean).join(' · ') function numberButton(number, body) { - const kind = number.seg.kind === 'value' ? 'value' : 'number' + const kind = number.seg.kind === 'value' ? 'value' : number.seg.kind === 'analysis' ? 'analysis' : 'number' const btn = h('button', { type: 'button', class: 'num', 'data-num': number.key, 'data-kind': kind, 'data-result': number.result, 'aria-label': T.numAria(kind, String(number.seg.text ?? ''), RESULT[number.result]), @@ -1811,10 +1920,12 @@ metric ? [T.dMetric, [metric.name || metric.id || '—', metric.id && metric.name ? h('span', { class: 'muted' }, `(${metric.id})`) : null]] : null, metric && metric.unit ? [T.dUnit, String(metric.unit)] : null, cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, exactText(cite.value, number.citedToken)] : null, - 'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, + 'truth' in number ? [number.kind === 'metric' ? T.dCard : number.analysis ? T.dAnalysis : T.dCell, number.truthText ?? exactText(number.truth, number.truthToken)] : null, checked && !same ? [T.dExpected, number.expected] : null, - [T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]], + // 分析范围里的编号不是正文里的字:没有「正文显示」可核 + number.scope ? null + : [T.dShown, [String(number.seg.text ?? ''), same ? h('span', { class: 'muted' }, `(${T.dShownSame})`) : null]], number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null, [T.dReport, nodeLabel(number.report.node_id)], ] @@ -1861,13 +1972,128 @@ } parts.push(kv([[T.dRecompute, metric.recompute_ok == null ? T.recomputeNone : metric.recompute_ok ? T.recomputeOk : T.recomputeBad]])) + if (isObj(metric.comparability)) parts.push(...comparabilityBlock(metric.comparability)) } const snap = number.snapshot if (snap) parts.push(...snapshotBlock(snap, number.row, number.column, view.out.artifacts.find((a) => a.a.id === number.artifact))) + if (number.analysis || number.kind === 'analysis') parts.push(...analysisBlock(number)) return parts } + /** 口径卡指标上的可比性(口径卡执行时自动做的可比性检查):卡上记的判定和原因;那份结论随文件附带、核对通过的,再给 + * 判定标准、方法与版本、各项检查和导出时服务端的复算。没附带的照卡上的记录写,说明为什么没有更多 */ + function comparabilityBlock(c) { + const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.map(String).join(';')}` : '' + const label = own(T.comparabilityLabel, c.verdict) || String(c.phrase || '—') + const artifact = typeof c.artifact === 'string' && c.artifact ? c.artifact : null + const art = artifact ? view.out.arts.get(artifact) : null + const ok = art && art.result === PASS && view.out.chained && view.out.chained.has(artifact) + const result = ok && isObj(art.json) ? art.json : null + const parts = [h('p', { class: 'sub' }, T.dComparability), kv([ + [T.dJudged, `${label}${c.verdict === 'comparable' ? '' : why}`], + artifact ? [T.dArtifact, [h('code', {}, short(artifact)), artifactState(artifact)]] : null, + ])] + if (!result) { + if (artifact) parts.push(h('p', { class: 'note' }, T.comparabilityNotAttached)) + return parts + } + parts.push(kv([ + result.criterion ? [T.dCriterion, String(result.criterion)] : null, + [T.dMethod, String(result.method || '—')], + ])) + const table = isObj(result.table) ? result.table : null + const columns = table && Array.isArray(table.columns) ? table.columns.map(String) : [] + const labels = table && isObj(table.labels) ? table.labels : {} + if (columns.length && Array.isArray(table.rows) && table.rows.length) { + parts.push(h('p', { class: 'sub' }, T.dChecks), h('div', { class: 'table-wrap' }, h('table', {}, + h('thead', {}, h('tr', {}, columns.map((col) => h('th', {}, String(own(labels, col) || col))))), + h('tbody', {}, table.rows.filter(Array.isArray).map((row) => h('tr', {}, + columns.map((_, i) => h('td', {}, row[i] == null ? '—' : textOf(row[i]))))))))) + } + const recheck = isObj(art.a.recheck) ? art.a.recheck : null + parts.push(kv([[T.dRecheck, recheck + ? [String(recheck.text || '—'), h('span', { class: 'muted' }, `(${T.recheckBy})`)] : T.recheckNone]])) + return parts + } + + /** 数字详情里分析结论一节:判定语、判定标准、方法与版本、附带说明、Agent 写的假设、输入(各自的核对状态)、导出时服务端 + * 的复算结论。结论没随文件附带(遮罩、文件不在)时只写复算一句 */ + function analysisBlock(number) { + const result = isObj(number.analysis) ? number.analysis : null + const art = view.out.artifacts.find((a) => a.a.id === number.artifact) + const recheck = art && isObj(art.a.recheck) ? art.a.recheck : null + const parts = [] + if (result) { + parts.push(kv([ + [T.dPhrase, String(result.phrase || '—')], + result.criterion ? [T.dCriterion, String(result.criterion)] : null, + [T.dMethod, String(result.method || '—')], + ])) + const caveats = (Array.isArray(result.caveats) ? result.caveats : []).filter(isObj).map((c) => String(c.text || '')) + if (caveats.length) parts.push(h('p', { class: 'sub' }, T.dCaveats), h('ul', { class: 'items' }, caveats.map((c) => h('li', {}, c)))) + if (typeof result.hypothesis === 'string' && result.hypothesis.trim()) { + parts.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', {}, result.hypothesis)) + } + const inputs = (Array.isArray(result.inputs) ? result.inputs : []).filter((i) => isObj(i) && typeof i.artifact === 'string') + if (inputs.length) { + parts.push(h('p', { class: 'sub' }, T.dAnalysisInputs), h('ul', { class: 'items' }, inputs.map((i) => h('li', {}, + h('span', { class: 'what' }, [T.analysisInput(own(T.roleLabel, i.role) || String(i.role || ''), + view.out.queryAliases.get(i.artifact)), ' ', + h('code', {}, short(i.artifact))]), artifactState(i.artifact))))) + } + } + parts.push(kv([[T.dRecheck, recheck ? [String(recheck.text || '—'), h('span', { class: 'muted' }, `(${T.recheckBy})`)] + : T.recheckNone]])) + return parts + } + + /** 报告文档里系统写的「分析范围」:现象、拆过(下钻按链缩进)、排除、算不了、没拆、检验了几次。编号点得开那条结论 */ + function renderScope(doc, report) { + const scope = isObj(doc.analysis_scope) ? doc.analysis_scope : null + if (!scope) return null + const rows = [] + for (const [key, title] of SCOPE_GROUPS) { + const items = (Array.isArray(scope[key]) ? scope[key] : []).filter(isObj) + if (!items.length) continue + const depth = new Map() + rows.push(h('dt', {}, title), h('dd', {}, h('ul', {}, items.map((item) => { + const level = item.parent ? (depth.get(String(item.parent)) ?? -1) + 1 : 0 + depth.set(String(item.alias || ''), level) + const dims = Array.isArray(item.dims) && item.dims.length ? T.scopeDims(item.dims.join('、')) : null + const lead = [dims, item.parent ? T.scopeParent(String(item.parent)) : null].filter(Boolean).join(',') + const said = item.phrase || item.reason || '' + const li = h('li', {}, scopeButton(String(item.alias || ''), doc, report), lead ? `(${lead})` : '', said ? `:${said}` : '') + if (level) li.style.marginLeft = `${level * 14}px` + return li + })))) + } + if (Array.isArray(scope.not_decomposed)) { + rows.push(h('dt', {}, T.scopeMissing), h('dd', {}, scope.not_decomposed.length ? scope.not_decomposed.join('、') : T.scopeAll)) + } else if (Object.prototype.hasOwnProperty.call(scope, 'not_decomposed')) { + rows.push(h('dt', {}, T.scopeMissing), h('dd', {}, String(scope.note || T.scopeUnknown))) + } + if (Number.isInteger(scope.tests) && scope.tests > 0) rows.push(h('dt', {}, T.scopeTests), h('dd', {}, T.scopeTestsText(scope.tests))) + if (!rows.length) return null + return h('section', { class: 'scope', 'data-scope': '' }, h('h4', {}, T.scopeTitle), h('p', { class: 'note' }, T.scopeNote), + h('dl', { class: 'kv' }, rows)) + } + + /** 分析范围里的一个编号:点开那条结论(工件的核对状态同 ③ ④;没有这条结论、没核对上的照实写) */ + function scopeButton(alias, doc, report) { + if (!alias) return '' + const entry = own(doc.catalog, alias) + const artifact = isObj(entry) && typeof entry.artifact === 'string' ? entry.artifact : null + const art = artifact ? view.out.arts.get(artifact) : null + const ok = art && art.result === PASS && view.out.chained && view.out.chained.has(artifact) + const number = { seg: { text: alias, kind: 'analysis', cite: { alias, kind: 'analysis', status: 'resolved', locator: {} } }, + key: `${report.node_id}|scope:${alias}`, report, kind: 'analysis', scope: true, artifact, + node: isObj(entry) ? entry.node_id : null, + source: T.srcAnalysis(alias), result: ok ? PASS : art && art.result === FAIL ? FAIL : NA, + reason: ok ? '' : !art ? T.numArtifactNotListed : art.reason || T.numNotChained, analysis: ok ? art.json : null } + return numberButton(number, alias) + } + function openNumber(number) { const panel = $('detail') if (panel.hidden) view.back = document.activeElement @@ -1978,6 +2204,8 @@ box.append(h('p', { class: 'warn', 'data-why': why.key }, why.text)) } if (item.doc) box.append(renderDoc(item.doc, { report: item.report, numbers })) + const scope = item.doc && item.trusted ? renderScope(item.doc, item.report) : null + if (scope) box.append(scope) root.append(box) } } @@ -2026,12 +2254,14 @@ } const refs = [...c.docs.map((d) => T.chainByDoc(nodeLabel(d.report.node_id), d.alias)), ...c.cards.map((k) => T.chainByCard(nodeLabel(k.card.a.node_id), k.metric)), + ...(c.analyses || []).map((k) => T.chainByAnalysis(view.out.queryAliases.get(k.analysis.a.id) || short(k.analysis.a.id))), ...c.events.map((ev) => T.chainByEvent(ev.seq, eventWhat(ev)))] return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)), refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : ''] })) const values = out.numbers.filter((n) => n.seg.kind === 'value').length - root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values, values), out.numbers, + const phrases = out.numbers.filter((n) => n.seg.kind === 'analysis').length + root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values - phrases, values, phrases), out.numbers, (n) => h('button', { type: 'button', 'data-num-item': n.key, onclick: () => { if (n.button) n.button.scrollIntoView({ block: 'center' }) openNumber(n) diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index b06b96e7..d1aeeecd 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -17,8 +17,16 @@ [[i:<输入字段>]] 运行输入 [[t:<表>]] / [[c:<表>.<列>]] 实体:表 / 字段,显示名字本身(反引号里写的已知名字也会自动链接) [[q:K|<逐字引文>]] 引文:必须在那次检索命中的片段里逐字出现,显示引文本身 + [[a:A]] 分析结论的判定语:系统按模板渲染好的一整句(判定由平台的算子算出,写作者只引用) + [[a:A.<键>|万]] 分析结论的具名标量(values 里的一项),换算同 m: + [[v:A.r<行>.<列>]] 分析结论结果表里的一格;[[table:A …]] 整表,表头写结果表的列名 [[see:,…]] 依据:挂在句末,不渲染 +分析结论(docs/design/analysis-round/)在台账里是 kind analysis 的条目,写作目录编号 A1…(和 Q、K 同一套按台账先后 +编号的规则,只收报告祖先里的)。目录条目带着从工件取回、复验过哈希的整份结论(result,app/analysis/contract.catalog_result +读它);存进报告文档的目录只留摘要,整份结论按工件取回。受管级别的正式出具里,agent 现场做的分析(条目带 call_id)和 +agent 查的格同一个规矩:出具契约没开「单元格引用」就和直接引用单元格一样解析不了;分析卡做的(带 item)总能引用。 + 实体只在这次运行冻结了表结构(schema_snapshot)时才核对:目录里没有表和字段条目的, t / c 仍按一期判为解析不了(「这种引用在后续版本支持」),反引号里的名字也不查;报告节点 写了 entities: off 的,照实说是它关掉的。表结构快照不全(库里的表太多、只存了一部分)时, @@ -54,6 +62,7 @@ from datetime import date, timedelta from decimal import ROUND_HALF_UP, Context, Decimal from typing import Any, Literal, TypedDict +from app.analysis.contract import CATALOG_PREFIX, LEDGER_KIND, MARKER_KIND, catalog_result, is_result, method_label from app.core.artifact_store import canonical_json, content_hash from app.core.artifact_store import load as load_artifact from app.data.names import FILLERS, name_key @@ -98,13 +107,21 @@ _ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2}(?:\.\d+) class EvidenceEntry(TypedDict, total=False): """台账条目:state["evidence"] 里的一行,同时写进 node.finished.evidence。""" - kind: Literal["query", "metric_set", "retrieval", "tool", "node_output", "schema"] + kind: Literal["query", "metric_set", "retrieval", "tool", "node_output", "schema", "analysis"] node_id: str exec: int # 这个节点第几次执行(循环里会有多次),从 1 数 artifact: str # 被引用的那件工件 via: str # 外层包装的工件(query_snapshot 外面的 tool_snapshot) - call_id: str + call_id: str # agent 工具记下的那次调用(查询、分析) tool: str + #: analysis 专有(app/analysis/contract.ledger_entry):算子、模式、算成没算成(ok / refused)、判定码、输入快照的 + #: sha256(去重、保序);分析卡来的另记那一项的 id(item),agent 工具来的记 call_id + op: str + mode: str | None + status: str + verdict: str | None + inputs: list[str] + item: str source: str columns: list[str] rows: int @@ -126,7 +143,7 @@ class Citation(TypedDict, total=False): alias: str # 目录里的键:m:gmv、i:week、Q4、t:orders、K1 locator: dict[str, Any] eid: str - kind: str # metric / input / query / cell / table / column / quote + kind: str # metric / input / query / cell / table / column / quote / analysis role: Literal["value", "entity", "quote", "support"] status: Literal["resolved", "unresolved", "mismatch"] reason: str # unresolved 时为什么 @@ -139,7 +156,8 @@ class Citation(TypedDict, total=False): class Segment(TypedDict, total=False): id: str - kind: Literal["text", "number", "value", "entity", "quote", "structural"] + #: analysis:[[a:A3]] 渲染出的判定语(一整句,系统按模板写的);[[a:A3.键]] 的具名标量照数字、取值算 + kind: Literal["text", "number", "value", "entity", "quote", "structural", "analysis"] text: str span: list[int] # [start, end),在 doc.markdown 里的码点偏移 ref: str # 带前缀的原始引用:m:gmv、i:week、t:orders @@ -745,7 +763,7 @@ _MARKER_GUARD = rf"(?=[^\[\]\n]{{0,{MARKER_MAX - 4}}}\]\])" #: [[kind:body]]。body 里不许有方括号和换行——这样一个没闭合的 [[ 最多吞到行尾 MARKER_RE = re.compile( r"\[\[" + _MARKER_GUARD + r"[ \t]*(?P[A-Za-z]+)[ \t]*:(?P[^\[\]\n]*?)\]\]") -SUPPORTED_KINDS = frozenset({"m", "i", "see", "v", "table", "t", "c", "q"}) +SUPPORTED_KINDS = frozenset({"m", "i", "see", "v", "table", "t", "c", "q", MARKER_KIND}) #: 三期起没有「以后才支持」的标记了;留着这个名字给老代码 import LATER_KINDS: frozenset[str] = frozenset() #: 这次运行没冻结表结构(没查过库、升级前的运行)时,t / c 仍按一期判为解析不了,原因是这一句 @@ -759,6 +777,9 @@ PHASE_TWO_REASON = "这份报告生成于系统升级前,当时不解析这类 CODE_REASON = "沙箱代码计算出的数字不能直接引用,需先登记到口径卡" #: 受管级别的正式出具,契约里没写 "cells": true 时复核按这个原因拒掉单元格引用 CELLS_REASON = "受管级别出具需要在出具契约中开启「单元格引用」" +#: 受管级别的正式出具、契约里没开「单元格引用」时,Agent 现场做的分析(台账条目带 call_id)和它查的格同一个规矩:判定语、 +#: 具名标量、结果表一律解析不了(依据 [[see:A1]] 照样可以写)。分析卡做的(带 item)是模板定好的,总能引用 +ANALYSIS_CELLS_REASON = "受管级别出具中,Agent 现场做的分析和它查到的单元格同一个规矩:需要在出具契约中开启「单元格引用」" #: 节点字段引用:给人看的原因,和交回写作者改写时的指令(_Reason 的 fix) NODE_FIELD_REASON = "不支持引用节点字段:数字需来自查询单元格或口径卡指标" NODE_FIELD_FIX = "节点字段引用在后续版本支持:数要从查询单元格 [[v:Q1.r0.列]] 或口径卡指标 [[m:…]] 来" @@ -864,14 +885,17 @@ def build_catalog( inputs: dict[str, Any] | None = None, metrics_from: list[str] | str | None = None, allowed: set[str] | frozenset[str] | list[str] | None = None, + loader: Loader | None = None, ) -> dict[str, dict[str, Any]]: """从证据台账、节点产出、运行输入建目录。 - nodes:state["nodes"],口径卡的指标值从这里取(和台账里的工件是同一份内容) - - ledger:state["evidence"],决定顺序、提供工件 id;查询、检索按台账顺序编 Q1… / K1… + - ledger:state["evidence"],决定顺序、提供工件 id;查询、检索、分析结论按台账顺序编 Q1… / K1… / A1… - metrics_from:只收这几张口径卡;不传(None、空列表、空字符串)就收全部——和 validate_graph 的理解一致,那边空列表就是「所有上游口径卡」 - allowed:只收这些节点的证据(报告节点传它的祖先),不传不限 + - loader:取分析结论工件的函数(缺省 artifact_store.load,取回时复验哈希)。A 条目照 analysis.contract.catalog_result + 的约定带着整份结论(result);取不回、哈希不符、形状不对的不带 result,另记 unreadable(为什么),引用它时照实说 同一个指标 id 出现在两张卡里时不登记 m:,只登记 m:<卡的节点 id>.—— 按先来后到默认给一个,正是旧契约错配的那种错。 @@ -932,9 +956,13 @@ def build_catalog( # 只在有问题时有这两个键 **({"no_data": True, "no_data_reason": metric.get("no_data_reason") or ""} if metric.get("no_data") else {}), + # 跨两份同形查询算的环比、同比,口径卡执行时自动做的可比性检查(analysis.contract.MetricComparability): + # 写作目录照实写有条件可比、不可比的原因,证据面板的指标步骤展示它。没做过检查的指标没有这个键 + **({"comparability": metric["comparability"]} if isinstance(metric.get("comparability"), dict) else {}), } - queries = retrievals = 0 + queries = retrievals = analyses = 0 + snaps: _Snapshots | None = None # 取分析结论的工件用,有 A 条目才建 schemas: list[dict[str, Any]] = [] shaped: list[tuple[dict[str, Any], str]] = [] # 带表结构快照的查询条目和它的全局编号 for entry in entries: @@ -954,6 +982,12 @@ def build_catalog( "label": f"沙箱代码 {node_id}({option_label('evidence_role', entry.get('role') or 'compute')})", } continue + if entry.get("kind") == LEDGER_KIND: + analyses += 1 + alias = f"{CATALOG_PREFIX}{analyses}" + snaps = snaps or _Snapshots(loader) + catalog[alias] = _analysis_entry(entry, alias, snaps) + continue if entry.get("kind") == "query": queries += 1 alias, kind = f"Q{queries}", "query" @@ -989,6 +1023,44 @@ def build_catalog( return catalog +#: A 条目从台账条目照抄的键(app/analysis/contract.ledger_entry 的形状) +_ANALYSIS_KEYS = ("node_id", "exec", "artifact", "call_id", "item", "op", "mode", "status", "verdict", "inputs") + + +def _analysis_entry(entry: dict[str, Any], alias: str, snaps: _Snapshots) -> dict[str, Any]: + """台账里的一条分析结论 → 写作目录的 A 条目:照抄台账的键,带上从工件取回、复验过哈希的整份结论(result, + analysis.contract.catalog_result 读它)。取不回、哈希不符、形状不对的不带 result,记 unreadable(以编号开头的一句,引用 + 它时照实说)。rows 是结果表的行数:依据 [[see:A1.r3]] 按它核对,存进报告文档的摘要里也留着。""" + out: dict[str, Any] = {"alias": alias, "kind": LEDGER_KIND, "eid": make_eid(LEDGER_KIND, entry.get("artifact"), {}), + "locator": {}, **{k: entry[k] for k in _ANALYSIS_KEYS if k in entry}} + result, why = snaps.analysis(alias, {"artifact": entry.get("artifact")}) + if result is None: + out["unreadable"] = why + else: + out["result"] = result + table = result.get("table") + if isinstance(table, dict) and isinstance(table.get("rows"), list): + out["rows"] = len(table["rows"]) + phrase = _clean(str((result or {}).get("phrase") or "")) + name = method_label(out.get("op"), out.get("mode")) + out["label"] = f"{name} · {phrase}" if phrase else name + return out + + +def analysis_card(entry: dict[str, Any] | None) -> bool: + """这条分析结论是不是分析卡做的(台账条目带 item、不带 call_id):模板定好的分析总能引用(00-总方案 D10)。agent 工具 + 现场做的(带 call_id)在受管级别的正式出具里和 agent 查的格同一个规矩(cells_allowed)。""" + return isinstance(entry, dict) and bool(entry.get("item")) and not entry.get("call_id") + + +def stored_entry(entry: dict[str, Any]) -> dict[str, Any]: + """存进报告文档的目录条目:分析结论只留摘要(编号、种类、判定码、工件、输入、行数……),不带整份结论(result)——它在 + 工件里,取回时复验哈希;别的条目原样。""" + if entry.get("kind") == LEDGER_KIND and "result" in entry: + return {k: v for k, v in entry.items() if k != "result"} + return entry + + def withhold_metrics(catalog: dict[str, Any], aliases: Iterable[str], word: str) -> dict[str, Any]: """目录里值不给的指标(aliases,指标的别名):值、渲染、标签里的值都换成 word(「已遮罩」),另记 masked。交回一份新的 目录,原来那份不动;没有要换的原样交回。 @@ -1376,6 +1448,38 @@ class _Snapshots: why = "的快照里没有表格数据" return (None, f"{alias} {why}") if why else (content, None) + def analysis(self, alias: str, entry: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]: + """分析结论:(整份结论, 取不到的原因,以 alias 开头)。写作目录的条目带着 result(build_catalog 取回、复验过哈希的, + analysis.contract.catalog_result)就用它;存进报告文档的目录只留摘要,按工件取回、复验哈希。build_catalog 当时就没取回 + 来的(条目记着 unreadable)照它说,不再取一遍。""" + result = catalog_result(entry) + if result is not None: + return result, None + if entry.get("unreadable"): + return None, str(entry["unreadable"]) + content, why = self._fetch(entry.get("artifact"), "分析结论") + if why is None and not is_result(content): + why = "的工件不是分析结论" + return (None, f"{alias} {why}") if why else (content, None) + + def table(self, alias: str, entry: dict[str, Any]) -> tuple[Any, str | None]: + """按单元格、整表取的那张表:(表, 取不到的原因)。查询结果是查询快照本身;分析结论是它的结果表(形状同查询快照: + columns 是稳定的列 id,另带 labels 给人看的列名,整表的表头写它)。结果表另记了列类型(column_types)的照着渲染, + 没记的按值认(同老快照)。""" + if entry.get("kind") != LEDGER_KIND: + return self.get(alias, entry.get("artifact")) + result, why = self.analysis(alias, entry) + if why: + return None, why + table = result.get("table") if isinstance(result, dict) else None + if not isinstance(table, dict) or not isinstance(table.get("rows"), list): + return None, f"{alias} 没有结果表" + ("(这次分析没有做成)" if result.get("status") == "refused" else "") + labels = table.get("labels") + types = table.get("column_types") + return {"columns": [str(c) for c in table.get("columns") or []], "rows": table["rows"], + "labels": {str(k): str(v) for k, v in labels.items()} if isinstance(labels, dict) else {}, + **({"column_types": types} if isinstance(types, dict) else {})}, None + def hits(self, alias: str, artifact: Any) -> tuple[list[Any], str | None]: """检索快照里的命中:(命中列表, 取不到的原因)。""" content, why = self._fetch(artifact, "检索快照") @@ -1418,27 +1522,49 @@ def _snapshots(loader: Loader | _Snapshots | None, *, entities: bool = True, return _Snapshots(loader, entities=entities, syntax=syntax, display=display) -_CELL_REF = re.compile(r"^(?P[QK]\d+)\.r(?P\d+)\.(?P.+)$") +_CELL_REF = re.compile(rf"^(?P[QK{CATALOG_PREFIX}]\d+)\.r(?P\d+)\.(?P.+)$") _NODE_REF = re.compile(r"^N:(?P[^.\s]+)(?:\.(?P.+))?$") def parse_cell_ref(ref: str) -> dict[str, Any] | None: - """Q4.r0.amount → {alias: "Q4", row: 0, column: "amount"};写法不对返回 None。""" + """Q4.r0.amount → {alias: "Q4", row: 0, column: "amount"};写法不对返回 None。分析结论的结果表同一种写法(A3.r0.share)。""" m = _CELL_REF.match((ref or "").strip()) return {"alias": m.group("alias"), "row": int(m.group("row")), "column": m.group("column")} if m else None +def _is_analysis_alias(alias: Any) -> bool: + return isinstance(alias, str) and alias.startswith(CATALOG_PREFIX) and alias[len(CATALOG_PREFIX):].isdigit() + + def _query_entry(alias: str, catalog: dict[str, Any]) -> tuple[dict[str, Any] | None, str]: + """能按单元格、整表引用的条目:查询结果,或者分析结论(引用它的结果表)。""" entry = catalog.get(alias) if entry is None: + if _is_analysis_alias(alias): + return None, _Reason(f"引用的分析结论 {alias} 不存在", f"目录里没有 {alias}(能引用的分析结论见证据目录)") return None, _Reason(f"引用的查询结果 {alias} 不存在", f"目录里没有 {alias}(能引用的查询见证据目录)") - if entry.get("kind") != "query": + if entry.get("kind") not in ("query", LEDGER_KIND): return None, f"{alias} 不是查询结果,不能按单元格引用" if entry.get("code"): return None, CODE_REASON return entry, "" +def _cells_refused(alias: str, catalog: dict[str, Any], cells_allowed: bool) -> str | None: + """单元格、整表引用这一次能不能解析:受管级别的正式出具、契约没开「单元格引用」时(cells_allowed=False)解析不了, + 原因是 CELLS_REASON;分析结论的结果表同一个规矩,只是分析卡做的总能引用(analysis_card,00-总方案 D10)。""" + if cells_allowed: + return None + if _is_analysis_alias(alias): + return None if analysis_card(catalog.get(alias)) else ANALYSIS_CELLS_REASON + return CELLS_REASON + + +def _noun(entry: dict[str, Any] | None) -> str: + """按格引用的那张表在说法里叫什么:查询结果,或者分析结论的结果表。""" + return "结果表" if (entry or {}).get("kind") == LEDGER_KIND else "查询结果" + + def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool) -> dict[str, Any]: ref = marker.get("ref") or "" @@ -1454,12 +1580,12 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps return _unresolved(marker, ref.split(".")[0], "cell", _Reason( "单元格引用格式无法识别", "单元格要写成 Q<编号>.r<行>.<列>(行号从 0 数),比如 [[v:Q1.r0.amount]]")) alias = parsed["alias"] - if not cells_allowed: - return _unresolved(marker, alias, "cell", CELLS_REASON) + if refused := _cells_refused(alias, catalog, cells_allowed): + return _unresolved(marker, alias, "cell", refused) entry, why = _query_entry(alias, catalog) if entry is None: return _unresolved(marker, alias, "cell", why) - snapshot, why = snaps.get(alias, entry.get("artifact")) + snapshot, why = snaps.table(alias, entry) if why: return _unresolved(marker, alias, "cell", why) try: @@ -1474,7 +1600,7 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps rendered = render_cell(shown, conv, kind=kind, plain=True) snaps.needed = max(snaps.needed, 3) except CellError as e: - return _unresolved(marker, alias, "cell", f"{alias} {e}") + return _unresolved(marker, alias, "cell", f"{alias} {str(e).replace('查询结果', _noun(entry))}") except RenderError as e: return _unresolved(marker, alias, "cell", str(e)) locator = {"row": parsed["row"], "column": column} @@ -1506,8 +1632,8 @@ def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapsho def fail(reason: str, fix: str | None = None) -> tuple[None, dict[str, Any]]: return None, _unresolved(marker, alias, "query", _Reason(reason, fix) if fix else reason) - if not cells_allowed: - return fail(CELLS_REASON) + if refused := _cells_refused(alias, catalog, cells_allowed): + return fail(refused) _, *rest = (marker.get("body") or "").split() or [""] odd = [p for p in rest if p.partition("=")[0] not in _TABLE_OPTIONS or "=" not in p] if odd: @@ -1516,18 +1642,20 @@ def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapsho entry, why = _query_entry(alias, catalog) if entry is None: return fail(why) - snapshot, why = snaps.get(alias, entry.get("artifact")) + snapshot, why = snaps.table(alias, entry) if why: return fail(why) columns = [str(c) for c in snapshot.get("columns") or []] rows = snapshot["rows"] + #: 分析结论的结果表:表头写给人看的列名(labels),格子照样按列 id 引用 + labels = snapshot.get("labels") if entry.get("kind") == LEDGER_KIND else None if "cols" in options: wanted = [c.strip() for c in options["cols"].split(",") if c.strip()] if not wanted: return fail("整表引用的 cols 没有写列名", "cols= 后面要写列名,用逗号分开") missing = [c for c in wanted if c not in columns] if missing: - return fail(f"引用的查询结果 {alias} 中没有列「{missing[0]}」", + return fail(f"引用的{_noun(entry)} {alias} 中没有列「{missing[0]}」", f"{alias} 没有列「{missing[0]}」,有:{'、'.join(columns[:12])}") if len(wanted) > TABLE_MAX_COLS: return fail(f"整表最多 {TABLE_MAX_COLS} 列,当前选了 {len(wanted)} 列", @@ -1564,7 +1692,7 @@ def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapsho return fail(f"列名「{_clean(c)[:30]}」无法放进表格(为空、首尾有空格、含方括号 / 竖线 / 换行,或过长)", f"列名「{_clean(c)[:30]}」写不进表格(空的、首尾有空格、带方括号竖线换行,或者太长):" "在 SQL 里给它起个别名再查") - lines = ["| " + " | ".join(_clean(c) for c in wanted) + " |", + lines = ["| " + " | ".join(_header_text(c, labels) for c in wanted) + " |", "| " + " | ".join("---" for _ in wanted) + " |"] lines += ["| " + " | ".join(f"[[v:{alias}.r{r}.{c}]]" for c in wanted) + " |" for r in range(first, last + 1)] if snapshot.get("truncated") is True: @@ -1572,6 +1700,14 @@ def _table_plan(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapsho return "\n".join(lines), None +def _header_text(column: str, labels: dict[str, str] | None) -> str: + """整表表头的一格:查询结果写列名本身;分析结论的结果表写给人看的列名(labels,竖线换成 ¦,同单元格里的文本),没写 + 叫法的列写列 id。复核时表头核对(_header_masks)按同一个写法认。""" + if labels and labels.get(column): + return _clean(str(labels[column])).replace("|", "¦") + return _clean(column) + + def _truncated_note(first: int, last: int) -> str: """截断的查询结果生成的表格下面那行说明。表里每一格都是真实的,但读的人会把它当成全部数据。 @@ -1620,10 +1756,11 @@ def _expand_tables(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_ def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_allowed: bool = True, loader: Loader | _Snapshots | None = None) -> dict[str, Any]: - """行内标记(m / i / v / t / c / q / table)→ Citation,带 rendered(正文里显示的字)。 + """行内标记(m / i / v / t / c / q / a / table)→ Citation,带 rendered(正文里显示的字)。 - cells_allowed=False 时单元格和整表一律解析不了,原因是 CELLS_REASON(受管出具没在契约里声明 cells)。 - loader 是取查询快照的函数,缺省 artifact_store.load。 + cells_allowed=False 时单元格和整表一律解析不了,原因是 CELLS_REASON(受管出具没在契约里声明 cells);Agent 现场做的 + 分析结论同一个规矩(ANALYSIS_CELLS_REASON),分析卡做的照常解析。loader 是取查询快照、分析结论的函数,缺省 + artifact_store.load。 """ kind = marker["kind"] ref = marker.get("ref") or "" @@ -1635,17 +1772,20 @@ def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_all if bad is not None: return bad # 整篇渲染时整表已经按上下文展开过了,走到这里的是复核一个片段:给出不带上下文的样子 - return {"ref": ref, "alias": ref, "locator": {}, "eid": catalog[ref]["eid"], "kind": "query", + return {"ref": ref, "alias": ref, "locator": {}, "eid": catalog[ref]["eid"], + "kind": LEDGER_KIND if catalog[ref].get("kind") == LEDGER_KIND else "query", "role": "value", "status": "resolved", "rendered": _render(table or "", catalog, snaps, cells_allowed)} if kind in ("t", "c"): return _resolve_entity(marker, catalog, _snapshots(loader)) if kind == "q": return _resolve_quote(marker, catalog, _snapshots(loader)) + if kind == MARKER_KIND: + return _resolve_analysis(marker, catalog, _snapshots(loader), cells_allowed) if kind not in ("m", "i"): return _unresolved(marker, f"{kind}:{ref}", kind, _Reason( f"引用类型「{kind}」无法识别", f"不认识的引用类型 {kind}:只支持 m(口径卡指标)、v(查询单元格)、table(整表)、" - "i(运行输入)、t / c(表 / 字段)、q(引文)、see(依据)")) + "i(运行输入)、t / c(表 / 字段)、q(引文)、a(分析结论)、see(依据)")) conv = marker.get("conv") if kind == "m": @@ -1686,6 +1826,68 @@ def resolve_marker(marker: dict[str, Any], catalog: dict[str, Any], *, cells_all return cite +#: [[a:A3]]、[[a:A3.top_share]]:编号,后面可以接一个具名标量的键 +_ANALYSIS_REF = re.compile(rf"^(?P{CATALOG_PREFIX}\d+)(?:\.(?P[^\s.|]+))?$") +_ANALYSIS_FIX = (f"分析结论的判定语写 [[{MARKER_KIND}:A1]],具名的数写 [[{MARKER_KIND}:A1.键]](键见证据目录,比如 " + f"[[{MARKER_KIND}:A1.top_share|pct]]),结果表的一格写 [[v:A1.r0.列]]") + + +def _resolve_analysis(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, + cells_allowed: bool) -> dict[str, Any]: + """[[a:A3]] → 判定语:分析结论里系统按模板渲染好的那一整句(判定是平台的算子算的,写作者只引用,00-总方案 D5)。 + [[a:A3.top_share|pct]] → 具名标量:结论 values 里的一项(数一律十进制字符串),按单元格的规则渲染,换算同 [[m:]]。 + + 判定语是一个片段(种类 analysis),里面的数和字都是系统写的:组装、复核都按带引用的片段整段涂白,不算裸数字。受管级别的 + 正式出具、契约没开「单元格引用」时,Agent 现场做的分析解析不了(ANALYSIS_CELLS_REASON),分析卡做的照常解析。""" + ref = (marker.get("ref") or "").strip() + conv = marker.get("conv") + parsed = _ANALYSIS_REF.match(ref) + if parsed is None: + return _unresolved(marker, ref.split(".")[0] or ref, LEDGER_KIND, + _Reason("分析结论的引用格式无法识别", _ANALYSIS_FIX)) + alias, key = parsed.group("alias"), parsed.group("key") + entry = catalog.get(alias) + if entry is None: + return _unresolved(marker, alias, LEDGER_KIND, _Reason( + f"引用的分析结论 {alias} 不存在", f"目录里没有 {alias}(能引用的分析结论见证据目录)")) + if entry.get("kind") != LEDGER_KIND: + return _unresolved(marker, alias, LEDGER_KIND, f"{alias} 不是分析结论") + if not cells_allowed and not analysis_card(entry): + return _unresolved(marker, alias, LEDGER_KIND, ANALYSIS_CELLS_REASON) + result, why = snaps.analysis(alias, entry) + if result is None: + return _unresolved(marker, alias, LEDGER_KIND, why or f"{alias} 无法读取") + if key is None: + if conv: + return _unresolved(marker, alias, LEDGER_KIND, _Reason( + "判定语是一整句话,不能换算", + f"判定语 [[{MARKER_KIND}:{alias}]] 不能加换算:要换算就引用具名的数,比如 [[{MARKER_KIND}:{alias}.top_share|pct]]")) + phrase = result.get("phrase") + if not isinstance(phrase, str) or not phrase.strip(): + return _unresolved(marker, alias, LEDGER_KIND, f"{alias} 没有判定语") + # 判定语按文本格的规则写进正文:压成一行,竖线换成 ¦(同单元格里的文本,表格里不会多切出一格) + return {"ref": ref, "alias": alias, "locator": {}, "eid": make_eid(LEDGER_KIND, entry.get("artifact"), {}), + "kind": LEDGER_KIND, "role": "value", "status": "resolved", + "rendered": render_cell(phrase, kind="text")} + values = result.get("values") if isinstance(result.get("values"), dict) else {} + if key not in values: + names = "、".join(str(k) for k in list(values)[:12]) or "没有" + return _unresolved(marker, alias, LEDGER_KIND, _Reason( + f"{alias} 中没有名为「{key}」的数", f"{alias} 里没有「{key}」,能引用的数:{names}(写成 [[{MARKER_KIND}:{alias}.键]])")) + raw = values[key] + try: + rendered = render_cell(raw, conv) + except RenderError as e: + return _unresolved(marker, alias, LEDGER_KIND, str(e)) + locator = {"key": key} + cite = {"ref": ref, "alias": alias, "locator": locator, + "eid": make_eid(LEDGER_KIND, entry.get("artifact"), locator), "kind": LEDGER_KIND, "role": "value", + "status": "resolved", "value": cell_value(raw), "rendered": rendered} + if conv: + cite["conv"] = conv + return cite + + def _resolve_entity(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots) -> dict[str, Any]: """[[t:orders]] / [[c:orders.amount]]:显示名字本身(照写作者写的大小写),出处是目录里的表或字段。 @@ -1855,12 +2057,15 @@ def _resolve_quote(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snap "source": {k: v for k, v in source.items() if v is not None}} -_ROW_REF = re.compile(r"^(?P[QK]\d+)(?:\.r(?P\d+))?$") +_ROW_REF = re.compile(rf"^(?P[QK{CATALOG_PREFIX}]\d+)(?:\.r(?P\d+))?$") def resolve_support(ref: str, catalog: dict[str, Any], *, loader: Loader | _Snapshots | None = None) -> dict[str, Any]: - """[[see:]] 里的一个依据:m:gmv、i:week、Q4、Q4.r0、K2、t:orders、c:orders.amount。只核对「这件证据存在」。""" + """[[see:]] 里的一个依据:m:gmv、i:week、Q4、Q4.r0、K2、A3、A3.r0、t:orders、c:orders.amount。只核对「这件证据存在」。 + 分析结论当依据和查询编号一样写([[see:A3]]),受管级别的出具里也照样可以写;顺手写成 a:A3 的照 A3 认。""" ref = ref.strip() + if ref.startswith(f"{MARKER_KIND}:") and _is_analysis_alias(ref.partition(":")[2].strip()): + ref = ref.partition(":")[2].strip() kind, sep, body = ref.partition(":") base = {"ref": ref, "role": "support"} if sep and kind in ("t", "c"): @@ -1880,7 +2085,8 @@ def resolve_support(ref: str, catalog: dict[str, Any], *, loader: Loader | _Snap alias = ref if ref in catalog else None why = f"运行输入中没有「{body}」" elif sep: - why = _Reason(f"依据「{ref}」的写法无法识别", f"不认识的依据写法 {ref}:写 m:指标、i:输入,或者 Q1 / K1 这样的编号") + why = _Reason(f"依据「{ref}」的写法无法识别", + f"不认识的依据写法 {ref}:写 m:指标、i:输入,或者 Q1 / K1 / A1 这样的编号") elif m := _ROW_REF.match(ref): alias = m.group("alias") if m.group("alias") in catalog else None if alias and m.group("row") is not None: @@ -1910,7 +2116,7 @@ def resolve_ref(ref: str, catalog: dict[str, Any], *, cells_allowed: bool = True _UNIT_GAP = 2 _UNIT_MAX = 10 UNIT_LOOKAHEAD = _UNIT_GAP + _UNIT_MAX + 1 -_UNIT_KINDS = frozenset({"m", "i", "v"}) +_UNIT_KINDS = frozenset({"m", "i", "v", MARKER_KIND}) #: 以一个字的单位打头的更长的单位:口径卡是「人」、正文紧跟着写「人次」,是另一个单位,不当重复的去掉(_tidy) _LONGER_UNIT = re.compile( r"人次|人天|人日|人月|人年|人份|件次|台次|台套|台时|车次|架次|班次|户次|批次|个月|个季度|个工作日|个百分点" @@ -2802,6 +3008,14 @@ def _link_entities(segments: list[dict[str, Any]], catalog: dict[str, Any], return out +def _analysis_segment(marker: dict[str, Any], cite: dict[str, Any]) -> str | None: + """[[a:A3]] 的片段种类是 analysis(一整句判定语);[[a:A3.键]] 是具名标量,照 [[v:]] 按值分数字、取值(交回 None)。""" + if marker.get("kind") != MARKER_KIND: + return None + parsed = _ANALYSIS_REF.match(str(marker.get("ref") or "").strip()) + return LEDGER_KIND if parsed is None or parsed.group("key") is None else None + + def _auto_entity(seg: dict[str, Any]) -> bool: """系统在正文里自动切出来的实体片段(自动链接的名字、反引号里的可疑名字),不是写作者写的标记。""" return seg.get("kind") == "entity" and (bool(seg.get("auto")) or not seg.get("ref")) @@ -2922,7 +3136,7 @@ def compose_doc( # 紧跟在同一张表名后面:只显示列名,引用上记 short,复核照它核对(老文档没有这一项,照全名) cite = {**cite, "rendered": short, "short": True} seg = { - "kind": _SEG_KIND.get(marker["kind"]) + "kind": _SEG_KIND.get(marker["kind"]) or _analysis_segment(marker, cite) or ("number" if marker["kind"] == "m" or (ok and is_number(cite.get("value"))) else "value"), "text": cite["rendered"], "ref": f"{marker['kind']}:{marker['body']}", @@ -2958,7 +3172,7 @@ def compose_doc( unit["segments"] = _link_entities(unit["segments"], catalog, index) all_segments = [s for block in out_blocks for unit in block["units"] for s in unit["segments"]] # 裸数字单独切成片段:前端要在那几个字底下画「无出处」 - refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog) \ + refs = [tuple(s["span"]) for s in all_segments if s.get("ref")] + _header_masks(out_blocks, catalog, snaps) \ + _name_masks(markdown, catalog, snaps) syntax = [tuple(s["span"]) for s in all_segments if s["kind"] == "structural"] bare = _bare_numbers(markdown, refs, allow_numbers, syntax) @@ -2996,7 +3210,8 @@ def compose_doc( used = {c["alias"] for block in out_blocks for unit in block["units"] for c in [*(s["cite"] for s in unit["segments"] if s.get("cite")), *unit["see"]] if c.get("status") == "resolved" and c.get("alias")} - kept = {a: e for a, e in catalog.items() if e.get("kind") not in ENTITY_KINDS or a in used} + # 分析结论只留摘要(stored_entry):整份结论在工件里,按工件取回、复验哈希 + kept = {a: stored_entry(e) for a, e in catalog.items() if e.get("kind") not in ENTITY_KINDS or a in used} # entity_syntax:这份文档按哪一版实体语法组装,复核照它走(verify_doc)。在文档顶层,参与内容哈希 # provenance:期 4 的文档标记(P4-SPEC 1.4)。带它的文档才有查询步骤的提示字段、推断来源、裁判摘录的新行; # 没有它的(期 4 之前组装的)一律按原样展示。写在顶层、参与内容哈希,事后补不上:补了哈希就变,封存链就断 @@ -3018,10 +3233,19 @@ def compose_doc( return doc -def _header_masks(blocks: list[dict[str, Any]], catalog: dict[str, Any]) -> list[tuple[int, int]]: +def _header_masks(blocks: list[dict[str, Any]], catalog: dict[str, Any], + snaps: _Snapshots | None = None) -> list[tuple[int, int]]: """表头里恰好是某次查询的列名的格:整表的表头是系统照快照列名写的,「销售额2025」这种列名 - 里的数字不是结论数字,写作者也改不了它。只放过表头、只放过和列名一字不差的格。""" + 里的数字不是结论数字,写作者也改不了它。只放过表头、只放过和列名一字不差的格。 + + 分析结论的结果表同理:整表的表头写的是结果表给人看的列名(_header_text),「基期(2026-W41)」里的数是系统写的。 + 结论按 snaps 取(目录条目带着的直接用,存进文档的摘要按工件取回);没给 snaps 的只认查询的列名。""" names = {_clean(str(c)) for e in catalog.values() if e.get("kind") == "query" for c in e.get("columns") or []} + for alias, entry in catalog.items() if snaps is not None else (): + if isinstance(entry, dict) and entry.get("kind") == LEDGER_KIND: + table, why = snaps.table(alias, entry) + if why is None: + names.update(_header_text(str(c), table.get("labels")) for c in table.get("columns") or []) out: list[tuple[int, int]] = [] for block in blocks if names else []: if not isinstance(block, dict) or block.get("type") != "table": @@ -3049,12 +3273,13 @@ _UNIT_VALUE = re.compile( def _text_names(catalog: dict[str, Any], snaps: _Snapshots) -> set[str]: """目录里每次查询的快照中,文本格里带数字的值(「冲压5线」「KX-205 线」):至少有一个数字、至少有一个不是数字的字, - 本身不是数、也不是带单位的数(_UNIT_VALUE)。按写作目录里显示的样子(压成一行)收。取不回的快照跳过。""" + 本身不是数、也不是带单位的数(_UNIT_VALUE)。按写作目录里显示的样子(压成一行)收。取不回的快照跳过。分析结论的结果表 + 同样收(维度值照输入快照的原样,写作目录里也列着它们)。""" names: set[str] = set() for alias, entry in catalog.items(): - if not isinstance(entry, dict) or entry.get("kind") != "query": + if not isinstance(entry, dict) or entry.get("kind") not in ("query", LEDGER_KIND): continue - snapshot, why = snaps.get(alias, entry.get("artifact")) + snapshot, why = snaps.table(alias, entry) if why or not isinstance(snapshot, dict): continue columns = [str(c) for c in snapshot.get("columns") or []] @@ -3360,7 +3585,7 @@ def verify_doc( if tiled and pos != len(markdown): violations.append(_violation("segment_mismatch", "正文末尾有一段不属于任何片段,文档可能被修改过")) - masks += _header_masks(list(doc.get("blocks") or []), catalog) + _name_masks(markdown, catalog, snaps) + masks += _header_masks(list(doc.get("blocks") or []), catalog, snaps) + _name_masks(markdown, catalog, snaps) for token in _bare_numbers(markdown, masks, allow_numbers, syntax): owner = next(((sid, uid) for s, e, sid, uid in owners if s <= token.start < e), (None, None)) violations.append(_violation( @@ -3708,11 +3933,12 @@ _PROMPT_HITS = 8 _PROMPT_HIT_CHARS = 300 -def _sql_check_note(artifact: Any, snaps: _Snapshots) -> str | None: +def _sql_check_note(artifact: Any, snaps: _Snapshots, who: str | None = None) -> str | None: """这次查询(合并查询则是它的输入)没通过 SQL 检查时,写作目录里跟在它后面的那句提醒;没问题返回 None。 口径卡的指标早就标「存疑」了,直接引用查询格子的写作者却看不到:报告照样把一对多关联后求和的数写成定论。 - 出具契约会按缺口降档(issuance.sql_check_gaps),这里先让写作者知道、引用时如实交代。 + 出具契约会按缺口降档(issuance.sql_check_gaps),这里先让写作者知道、引用时如实交代。分析结论的输入查询同样提醒 + (sql_problems.query_problems 顺着结论的输入往下找),who 是「这次分析的输入查询」。 """ from app.engine.sql_problems import query_problems @@ -3720,7 +3946,7 @@ def _sql_check_note(artifact: Any, snaps: _Snapshots) -> str | None: if not found: return None problems = list(dict.fromkeys(text for p in found for text in p.problems)) - who = "这次查询" if all(p.artifact == artifact for p in found) else "这次合并的输入查询" + who = who or ("这次查询" if all(p.artifact == artifact for p in found) else "这次合并的输入查询") return (f" ({who}未通过 SQL 检查:{';'.join(problems[:3]) or '有错误级的问题'}。其中的数存疑:" "引用时须如实说明,不要当作确定的结论)") @@ -3776,6 +4002,141 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, return lines +#: 写作目录里「分析结论」一段开头的写法(给模型看)。判定由平台的算子算好、系统按模板渲染,写作者只引用(00-总方案 D5) +ANALYSIS_PROMPT = ( + "分析结论(系统按平台的方法算好的判定。判定语、具名的数、结果表都只能用引用标记写,系统照结论渲染、逐字核对):\n" + "写法:判定语单独写 [[a:A1]],系统换成整句判定;具名的数写 [[a:A1.键]],换算同指标,比重、变化率这类比率写成 " + "[[a:A1.top_share|pct]] 显示成百分数;结果表的一格写 [[v:A1.r0.列]](行号从 0 数),要列出多行多列,单独一行写 " + "[[table:A1 cols=列a,列b rows=0-4]];拿它当结论的依据,句末写 [[see:A1]]。" + "「主要来自」「超出常规波动」「呈上升趋势」这类判断已经写在判定语里:引用判定语,不要自己改写判定;" + "算不了的照实写算不了的原因。下面没有的编号、键、列不要编造。" +) +#: 受管级别的出具、契约没开「单元格引用」时跟在上面那段后面(给模型看) +ANALYSIS_SEE_ONLY_PROMPT = ("这次出具不允许直接引用 Agent 现场做的分析:标着只能当依据的几条,判定语、数和结果表都不能写进" + "正文,只能在句末写 [[see:A1]] 这样的依据;分析卡做的照常引用。") +#: 一条分析结论最多列几个具名的数 +_PROMPT_VALUES = 16 + + +def _analysis_sources(entry: dict[str, Any], catalog: dict[str, Any]) -> str: + """「,来自 Q2、Q3」「,下钻自 A1」:按输入的工件对到这份目录里的编号(agent 调用时写的是它自己的编号,对不上目录)。 + 目录里没有的输入照实说有几份不在。""" + by_artifact = {str(e.get("artifact")): alias for alias, e in catalog.items() + if isinstance(e, dict) and e.get("kind") in ("query", LEDGER_KIND) and e.get("artifact")} + inputs = [str(a) for a in entry.get("inputs") or [] if a and str(a) != str(entry.get("artifact") or "")] + named = [by_artifact[a] for a in inputs if a in by_artifact] + queries = [a for a in named if not _is_analysis_alias(a)] + parents = [a for a in named if _is_analysis_alias(a)] + missing = len(inputs) - len(named) + return "".join([f",来自 {'、'.join(queries)}" if queries else "", + f",下钻自 {'、'.join(parents)}" if parents else "", + f",另有 {missing} 份输入不在这份目录里" if missing else ""]) + + +def _analysis_prompt(entry: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, *, + table: bool = True) -> list[str]: + """一条分析结论在写作目录里的样子:编号、方法、来自哪几个查询,判定语([[a:A1]] 渲染出来的那一句)、判定标准、具名的数 + (可引用的键,值按渲染规则写)、附带说明、输入查询的 SQL 检查问题、算不了的原因;table 为真时再列结果表的前 + table_top_k 段,其余写「其余 n 段」。agent 写的假设不列:它不是结论,只在证据面板显示。 + + 受管级别的正式出具、契约没开「单元格引用」时,Agent 现场做的分析只列判定语和判定标准,写明只能当依据([[see:A1]]), + 不列数和表:列出来的数写作者只能照抄成没有出处的数。""" + alias = entry["alias"] + head = f"- {alias}:{method_label(entry.get('op'), entry.get('mode'))}{_analysis_sources(entry, catalog)}" + result, why = snaps.analysis(alias, entry) + if result is None: + return [f"{head}({why},不要引用)"] + lines = [head] + if note := _sql_check_note(entry.get("artifact"), snaps, "这次分析的输入查询"): + lines.append(note) + phrase = _clean(str(result.get("phrase") or "")) + see_only = not cells_allowed and not analysis_card(entry) + if see_only: + lines.append(f" 判定语:{phrase or '没有'}(这次出具不允许直接引用 Agent 现场做的分析,只能在句末写 " + f"[[see:{alias}]] 作依据)") + else: + lines.append(f" 判定语 [[a:{alias}]] = {phrase}" if phrase else f" (没有判定语,不要写 [[a:{alias}]])") + if result.get("status") == "refused": + lines.append(f" 算不了:{_clean(str(result.get('reason') or '没有写原因'))}。这条结论不能当「异常」「主要来自」" + "「呈上升趋势」这类判断的依据,只能照实写算不了的原因") + elif criterion := _clean(str(result.get("criterion") or "")): + lines.append(f" 判定标准:{criterion}") + caveats = [_clean(str(c.get("text") or "")) for c in result.get("caveats") or [] if isinstance(c, dict)] + if caveats := [c for c in caveats if c]: + lines.append(f" 附带说明:{';'.join(caveats)}") + if see_only: + return lines + values = result.get("values") if isinstance(result.get("values"), dict) else {} + shown = [] + for key, raw in list(values.items())[:_PROMPT_VALUES]: + try: + shown.append(f"{key}={render_cell(raw)}") + except RenderError: + shown.append(f"{key}={MISSING}") + if shown: + more = f",另有 {len(values) - _PROMPT_VALUES} 个" if len(values) > _PROMPT_VALUES else "" + lines.append(f" 具名的数(写 [[a:{alias}.键]]):{','.join(shown)}{more}") + snapshot, why = snaps.table(alias, entry) + if why is not None: + return lines + columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS] + labels = snapshot.get("labels") or {} + rows = snapshot["rows"] + named = "、".join(f"{c}({_clean(str(labels[c]))})" if labels.get(c) else c for c in columns) + if not table: + lines.append(f" 结果表共 {len(rows)} 段,列 {named}(这里没列出各段;要列出就单独一行写 [[table:{alias}]])") + return lines + settings = result.get("settings") if isinstance(result.get("settings"), dict) else {} + top = settings.get("table_top_k") + top = top if isinstance(top, int) and not isinstance(top, bool) and top > 0 else _TOP_K + lines.append(f" 结果表(共 {len(rows)} 段):列 {named}") + plain = snaps.plain(entry.get("artifact"), snapshot) + for r in range(min(top, len(rows))): + cells = [] + for c in columns: + try: + raw, _ = locate_cell(snapshot, r, c) + cells.append(f"{c}={render_cell(snaps.shown(raw), kind=column_kind(snapshot, c), plain=c in plain)}") + except (CellError, RenderError): + cells.append(f"{c}={MISSING}") + lines.append(f" r{r}:" + ",".join(cells)) + if len(rows) > top: + lines.append(f" …其余 {len(rows) - top} 段(这里只列出前 {top} 段;后面的行也能按行号引用)") + return lines + + +#: 结论里没记 table_top_k 时写作目录列几段(同分析设置的缺省) +_TOP_K = 10 + + +@dataclass +class _AnalysisLines: + """写作目录里「分析结论」一段:每条结论两种样子——full 连同结果表的前 table_top_k 段,compact 只有判定、判定标准、 + 具名的数和说明。放得下时全按 full;放不下时先按 compact 列出,再按顺序补上结果表,排在查询的行前面(_trimmed_catalog)。""" + + order: list[str] + full: dict[str, list[str]] + compact: dict[str, list[str]] + #: 有 Agent 现场做的分析只能当依据(受管级别的出具、契约没开「单元格引用」) + see_only: bool = False + + @classmethod + def of(cls, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool) -> "_AnalysisLines": + entries = [e for e in catalog.values() if isinstance(e, dict) and e.get("kind") == LEDGER_KIND] + return cls([e["alias"] for e in entries], + {e["alias"]: _analysis_prompt(e, catalog, snaps, cells_allowed) for e in entries}, + {e["alias"]: _analysis_prompt(e, catalog, snaps, cells_allowed, table=False) for e in entries}, + see_only=not cells_allowed and any(not analysis_card(e) for e in entries)) + + def lines(self, tier: dict[str, str] | None = None) -> list[str]: + """这一段的文字:tier 里记成 compact 的按 compact,其余按 full。没有分析结论时是空的(目录和以前一字不差)。""" + if not self.order: + return [] + shapes = [self.compact[a] if (tier or {}).get(a) == "compact" else self.full[a] for a in self.order] + head = [f"\n{ANALYSIS_PROMPT}", *([ANALYSIS_SEE_ONLY_PROMPT] if self.see_only else [])] + return [*head, *(line for shape in shapes for line in shape)] + + #: 写作目录的字数预算(catalog_budget)按写作模型的上下文窗口定,不再写死 12000: #: - 占窗口的 40%。其余留给写作规则和指令(约 4 千字)、重写轮次(每轮把上一稿和违规清单追加进对话,最多 3 轮)、 #: 推理模型的思考和报告正文(输出额度)。字数按 1 字 ≈ 1 token 算:中文一个字约 0.6–1 个 token,目录里的数字、 @@ -3879,6 +4240,7 @@ def _metric_prompt(catalog: dict[str, Any]) -> list[str]: caveat += "(所依据的查询未通过 SQL 检查,结果存疑:引用时须如实说明,不要当作确定的结论)" if e.get("no_data"): caveat += NO_DATA_METRIC_NOTE + caveat += _comparability_note(e) lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}{caveat}") inputs = [e for e in catalog.values() if e.get("kind") == "input"] if inputs: @@ -3887,6 +4249,24 @@ def _metric_prompt(catalog: dict[str, Any]) -> list[str]: return lines +def _comparability_note(entry: dict[str, Any]) -> str: + """指标上记的可比性(analysis.contract.MetricComparability)在写作目录里跟在值后面的那句:有条件可比、不可比照实写出 + 原因(一句一个),可比的不写。不可比的环比、同比不能当结论,引用时要照实说。""" + comparability = entry.get("comparability") + if not isinstance(comparability, dict): + return "" + 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": + return f"(两期有条件可比:{reasons or '原因见证据面板'}。引用时须如实说明这几点)" + return "" + + def _dig_path(data: Any, path: str) -> Any: for key in path.split("."): data = data.get(key) if isinstance(data, dict) else None @@ -4011,13 +4391,15 @@ def writer_catalog(catalog: dict[str, Any], *, budget: int = DEFAULT_CATALOG_BUD [{node_id, title, data, evidence}],titles 是节点在画布上的名字(分组标题用)。 放得下就整份给(没有结构化字段时和以前一字不差)。放不下时按优先级、按来源均衡(_trimmed_catalog): - 口径卡指标、运行输入、结构化字段一律列出;查询按产出它的节点分组、轮流展开;没展开的列编号和摘要; - 开头照实说目录有省略、各来源省略了几次。 + 口径卡指标、运行输入、结构化字段一律列出;分析结论的判定、具名的数一律列出,结果表的各段排在查询的行前面补; + 查询按产出它的节点分组、轮流展开;没展开的列编号和摘要;开头照实说目录有省略、各来源省略了几次。 + 没有分析结论的目录和以前一字不差。 """ snaps = _Snapshots(loader) head = ["可引用的证据(写数字只能用下面的引用标记,系统会换成真实数值):"] field_lines, empty, cited = _fields_prompt(fields or [], catalog, snaps, cells_allowed) first = [*_metric_prompt(catalog), *field_lines] + analyses = _AnalysisLines.of(catalog, snaps, cells_allowed) queries = [e for e in catalog.values() if e.get("kind") == "query"] rules = ([f"\n{CELL_RULES}" if cells_allowed else "\n查询结果(这次出具不允许直接引用单元格,只能写在 [[see:Q1]] 这样的依据里):"] if queries else []) @@ -4030,26 +4412,31 @@ def writer_catalog(catalog: dict[str, Any], *, budget: int = DEFAULT_CATALOG_BUD for e in retrievals: tail.extend(_retrieval_prompt(e, snaps)) full = {e["alias"]: _query_prompt(e, snaps, cells_allowed) for e in queries} - text = "\n".join([*head, *first, *rules, *(line for e in queries for line in full[e["alias"]]), *tail]) + text = "\n".join([*head, *first, *analyses.lines(), *rules, *(line for e in queries for line in full[e["alias"]]), + *tail]) if len(text) <= budget: return WriterCatalog(text=text, budget=budget, queries=len(queries), empty_sources=empty) return _trimmed_catalog(catalog, head, first, rules, queries, full, tail, budget=budget, snaps=snaps, - cells_allowed=cells_allowed, titles=titles or {}, empty=empty, pinned=cited) + cells_allowed=cells_allowed, titles=titles or {}, empty=empty, pinned=cited, + analyses=analyses) def _trimmed_catalog(catalog: dict[str, Any], head: list[str], first: list[str], rules: list[str], queries: list[dict[str, Any]], full: dict[str, list[str]], tail: list[str], *, budget: int, snaps: _Snapshots, cells_allowed: bool, titles: dict[str, str], empty: list[str], - pinned: list[str]) -> WriterCatalog: + pinned: list[str], analyses: _AnalysisLines | None = None) -> WriterCatalog: """放不下时的目录。以前从头截断:查询按编号排,前面的节点占满预算,后面的节点一次都不露面。 - 1. 口径卡指标、运行输入、结构化字段一律列出(第一优先) + 1. 口径卡指标、运行输入、结构化字段一律列出(第一优先);分析结论的判定、判定标准、具名的数、说明也一律列出 2. 表和字段、知识库检索两节合起来最多占预算的 1/_PROMPT_TAIL_SHARE - 3. 每次查询先只占一行摘要;结构化字段引用到的查询先展开(pinned),再按产出它的节点分组、轮流展开: + 3. 分析结论的结果表(前 table_top_k 段)先补:判定是从原始明细算出来的结论,比原始明细更值得留 + 4. 每次查询先只占一行摘要;结构化字段引用到的查询先展开(pinned),再按产出它的节点分组、轮流展开: 第一轮每次列前 _PROMPT_COMPACT_ROWS 行,第二轮补到 _PROMPT_ROWS 行,放不下为止 - 4. 开头写明目录有省略、各来源省略了几次,以及没展开的查询怎么用(OMITTED_RULES) - 最后整份量一次:超了就把最后展开的退回去;指标和字段本身就超(极少见)才从尾部截断。 + 5. 开头写明目录有省略、各来源省略了几次,以及没展开的查询怎么用(OMITTED_RULES) + 最后整份量一次:超了就把最后展开的退回去(先退查询,最后才退分析结论的结果表);指标和字段本身就超(极少见) + 才从尾部截断。 """ + analyses = analyses or _AnalysisLines([], {}, {}) if tail and len("\n".join(tail)) > budget // _PROMPT_TAIL_SHARE: tail = _cut_lines(tail, budget // _PROMPT_TAIL_SHARE, "…(表和字段、知识库检索太长,后面的省略了)") tables: dict[str, list[str]] = {} @@ -4064,6 +4451,7 @@ def _trimmed_catalog(catalog: dict[str, Any], head: list[str], first: list[str], brief = {e["alias"]: [_query_brief(e, tables.get(e["alias"], []), snaps)] for e in queries} shapes = {"brief": brief, "compact": compact, "full": full} tier = {e["alias"]: "brief" for e in queries} + tier.update({alias: "compact" for alias in analyses.order}) def rows() -> list[dict[str, Any]]: out = [] @@ -4081,7 +4469,10 @@ def _trimmed_catalog(catalog: dict[str, Any], head: list[str], first: list[str], notice = (f"注意:证据太多,这份目录有省略(篇幅上限约 {budget} 字)。共 {len(queries)} 次查询:" f"{n['full']} 次列出前 {_PROMPT_ROWS} 行,{n['compact']} 次只列前 {_PROMPT_COMPACT_ROWS} 行," f"{n['omitted']} 次只列编号和摘要。各来源:{per}。") - body = [*head, notice, OMITTED_RULES, *first, *rules] + if bare := sum(1 for alias in analyses.order if tier[alias] == "compact"): + notice += (f"另有 {len(analyses.order)} 条分析结论,判定和具名的数都列出了,其中 {bare} 条没列出结果表的各段" + "(要列出就单独一行写整表引用,系统照结果表生成)。") + body = [*head, notice, OMITTED_RULES, *first, *analyses.lines(tier), *rules] for r, entries in zip(stats, groups.values()): body.append(f"\n来自「{r['title']}」的查询(共 {r['total']} 次,展开 {r['full'] + r['compact']} 次):") for e in entries: @@ -4098,6 +4489,12 @@ def _trimmed_catalog(catalog: dict[str, Any], head: list[str], first: list[str], # 省略情况那句里的数字会变长几位:留一点余量,最后再整份量一次 remaining = budget - len(assemble()) - 8 * (len(groups) + 3) upgraded: list[tuple[str, str]] = [] + for alias in analyses.order: + # 分析结论的结果表先补,排在查询的行前面;整份量下来超了,最后才退回它们 + if (delta := size(analyses.full[alias]) - size(analyses.compact[alias])) <= remaining: + tier[alias] = "full" + remaining -= delta + upgraded.append((alias, "compact")) for alias in pinned: if tier.get(alias) == "brief" and (delta := size(compact[alias]) - size(brief[alias])) <= remaining: tier[alias] = "compact" @@ -4249,6 +4646,172 @@ def describe_violations(violations: list[dict[str, Any]], *, limit: int = 20) -> return "\n".join(lines) +# -------------------------------------------------------------------------- +# 分析结论的出处:复算、代入式(证据面板、报告导出、可验证报告同一套) +# -------------------------------------------------------------------------- + +#: 复算不一致时点名哪几项不同的叫法(analysis.contract.AnalysisResult 的字段) +_RESULT_FIELDS = { + "op": "算子", "mode": "模式", "method": "方法版本", "params": "参数", "settings": "阈值", "inputs": "输入", + "probes": "现查结果", "status": "算成没算成", "reason_code": "算不了的原因", "reason": "算不了的原因", + "table": "结果表", "values": "具名的数", "verdict": "判定", "criterion": "判定标准", "phrase": "判定语", + "caveats": "附带说明", +} +#: app.analysis.recompute 取不回输入(CallError 的 code input_missing)时那句以「复算」开头(复算取不回 Q2 的查询快照): +#: 接在「复算失败:」后面时去掉,不说两遍 +_RECHECK_HEAD = "复算" + + +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]: + """把一份封存的分析结论按它记下的输入复算一遍,和封存的逐字段比(contract.same,agent 写的假设不比)。 + + 复算就是 app.analysis.recompute:load 按 sha256 交回查询快照工件的原内容,load_result 交回下钻的上一层结论的原内容, + 取不回(不在封存范围内、哈希对不上)的交 None;算子按结论里记下的参数、阈值、现查结果算,不再现查、不读目录。 + + 交回 {status, text, method, method_now?, fields?}:status 是 same(一致)/ different(不一致)/ failed(复算不了); + text 是给人看的一句,证据面板、报告导出、可验证报告照它写。fields 是和封存的不同的那几项(结论的字段名)。 + + 方法升过版(结论里记的 method 和现在的 contract.METHODS[(op, mode)] 不同)时 method_now 是现在的那一版:复算成立的照实写 + 复算结果同不同(方法版本那一项本来就不同,不算进去),现在的方法按封存的输入不成立(recompute 抛 CallError)的写明原因。 + 复算出错(输入取不回、调用的写法现在不认、出了意外)不往外抛,照实写「复算失败:原因」。""" + from app import analysis + 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 CallError as e: + 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", **out, "text": f"复算失败:{describe_exception(e)}"} + if not isinstance(again, dict): + 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: + named = "、".join(dict.fromkeys(_RESULT_FIELDS.get(k, k) for k in fields)) + out["text"] = f"复算不一致:{named}和封存的不同" + if fields: + out["fields"] = fields + return out + + +#: 变化拆解结果表里不是「段」的那几列(细案 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 + 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 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]] + 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}],数照单元格的规则渲染(系统写的,不是模型写的)。只给拿结论里 + 记下的数就写得出来的几条: + + - 变化拆解(可加、乘积):总变化、变化率、点名那一段(因子)的占比——「华东的占比 = 华东的变化 ÷ 总变化 = -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]] = [] + + def add(label: str, build: Callable[[], str]) -> None: + try: + out.append({"label": label, "text": build()}) + 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 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 []] + 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}合计 ÷ 比率的变化 = {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} 倍稳健标准差 = {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 + + # -------------------------------------------------------------------------- # 旧运行:按数值猜「可能的来源」 # diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py index c34e943b..583aef57 100644 --- a/backend/app/engine/judge.py +++ b/backend/app/engine/judge.py @@ -44,6 +44,7 @@ from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from sqlalchemy import select +from app.analysis.contract import CATALOG_PREFIX, LEDGER_KIND, catalog_result, is_result, method_label from app.core.artifact_store import canonical_json, content_hash from app.core.artifact_store import load as load_artifact from app.core.errors import describe_exception, not_configured @@ -160,6 +161,15 @@ JUDGE_RULES = f"""判定(verdict)五选一: 7. used 写你实际用到的证据编号(摘录里【】中的那个,比如 m:gmv、Q1),没用到就写空数组。 8. 每句都要给判定,unit 照抄句子前面方括号里的编号。""" +#: 摘录里有分析结论(【A1】)时接在裁判规则后面的一条。只在这时才加:没有分析结论的报告,裁判看到的规则一字不差, +#: 已判过的句子照旧复用(复用键里只有摘录,没有规则) +ANALYSIS_JUDGE_PROMPT = ( + "9. 【A1】这类分析结论的判定、判定标准和关键数是系统按平台的方法算出来的,不要质疑也不要复算;只判断句子的说法和" + "判定是否一致:「主要来自」「集中在」「超出常规波动」「呈上升趋势」「差异站得住」这类判断要和判定对得上,句子点名的段" + "要在判定里,方向要一致;判定写着算不了的,不能支持任何判断。分析结论只说明变化来自哪里,说明不了原因:句子写了" + "原因、证据里又没有原因的,判 partial 或 insufficient。" +) + #: 报告节点 claims 为 judge 时拼在写作规则后面:写作者要知道还有一道按证据的核对 JUDGE_RULE = ("这份报告的结论句还会由另一个模型逐句核对:它只看你挂的依据,判断这句话站不站得住。" "说法不要超出证据——证据里没有的原因、范围、趋势,不要写成结论。") @@ -299,8 +309,8 @@ def _candidate(unit: dict[str, Any], priority: int, order: int, text: str, secti rows.append((alias, int(locator["row"]))) if kind == "cell" and locator.get("column") not in (None, ""): cols.append((alias, str(locator["column"]))) - elif kind in ("query", "retrieval"): - whole.append(alias) + elif kind in ("query", "retrieval", LEDGER_KIND): + whole.append(alias) # 分析结论:判定语、具名标量、依据 [[see:A1]] 都整份摘 elif kind == "quote" and isinstance(locator.get("hit"), int): hits.append((alias, int(locator["hit"]))) return Candidate(unit=str(unit.get("id")), priority=priority, order=order, text=text, section=section, @@ -565,6 +575,10 @@ def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch, catalog: d if metric.get("expression"): lines.append(f"原式:{_clean(str(metric['expression']))}") lines.extend(_metric_inputs(metric, fetch, catalog, masked, mask)) + comparability = metric.get("comparability") + if isinstance(comparability, dict) and str(comparability.get("phrase") or "").strip(): + # 跨两份同形查询算的环比、同比:口径卡执行时自动做的可比性检查(判定语是系统写的,原样给) + lines.append(f"两期可比性(系统检查):{_clean(str(comparability['phrase']))}") return "\n".join(lines) @@ -664,6 +678,102 @@ def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], who return "\n".join(lines) +def _analysis_alias(alias: Any) -> bool: + """写作目录里分析结论的编号(A1、A2……)。""" + return isinstance(alias, str) and alias.startswith(CATALOG_PREFIX) and alias[len(CATALOG_PREFIX):].isdigit() + + +#: 分析结论的摘录最多列几个具名的数 +ANALYSIS_VALUES = 16 + + +def _analysis_excerpt(alias: str, entry: dict[str, Any], rows: set[int], fetch: _Fetch, + masked: dict[str, Any] | None, catalog: dict[str, Any], mask: Any = None) -> str | None: + """一条分析结论的摘录:方法、判定语、判定标准、关键数(具名标量)、附带说明、结果表(被引用的行先给,再从头补满 + EXCERPT_ROWS 行,最多 EXCERPT_COLS 列,表头写给人看的列名)、输入取自哪几次查询、用了哪几列,以及那几次查询的 SQL + (截到 SQL_CHARS,最多 ENTITY_QUERIES 次)。判定是系统算的,裁判只判句子的说法和判定是否一致(ANALYSIS_JUDGE_PROMPT)。 + + 结论按工件取(经 fetch:按需裁判时只认封存范围内的工件,取不到、哈希不符的不给这段摘录);目录条目带着整份结论的 + 直接用。agent 写的假设不给:它不是结论。mask 同 _query_excerpt(核查 agent 的摘录才给):SQL 先遮再截,结果表里的文字、 + 判定语按值换,数照原样。""" + result = catalog_result(entry) + if result is None: + content = fetch(entry.get("artifact")) + result = content if is_result(content) else None + if result is None: + return None + scrub = mask.text if mask is not None else (lambda t: t) + lines = [f"【{alias}】分析结论({method_label(result.get('op'), result.get('mode'))},方法 {result.get('method') or '—'})"] + if result.get("status") == "refused": + lines.append(f"判定:算不了({_clean(str(result.get('reason') or '没有写原因'))})") + else: + lines.append(f"判定:{scrub(_clean(str(result.get('phrase') or ''))) or '—'}") + if criterion := _clean(str(result.get("criterion") or "")): + lines.append(f"判定标准:{criterion}") + values = result.get("values") if isinstance(result.get("values"), dict) else {} + if values: + shown = [] + for key, raw in list(values.items())[:ANALYSIS_VALUES]: + try: + text = render_cell(raw) + except RenderError: + text = MISSING + shown.append(f"{key}={text if isinstance(raw, (int, float)) else scrub(text)}") + more = f" | …另有 {len(values) - ANALYSIS_VALUES} 个" if len(values) > ANALYSIS_VALUES else "" + lines.append("关键数:" + " | ".join(shown) + more) + caveats = [_clean(str(c.get("text") or "")) for c in result.get("caveats") or [] if isinstance(c, dict)] + if caveats := [c for c in caveats if c]: + lines.append("附带说明:" + ";".join(scrub(c) for c in caveats)) + table = result.get("table") if isinstance(result.get("table"), dict) else None + if table is not None and isinstance(table.get("rows"), list) and table["rows"]: + columns = [str(c) for c in table.get("columns") or []][:EXCERPT_COLS] + labels = table.get("labels") if isinstance(table.get("labels"), dict) else {} + total = len(table["rows"]) + picked = sorted(r for r in rows if 0 <= r < total)[:EXCERPT_ROWS] + picked += [r for r in range(total) if r not in picked][:EXCERPT_ROWS - len(picked)] + lines.append(f"结果表(共 {total} 行)列:" + " | ".join(_clean(str(labels.get(c) or c)) for c in columns)) + for r in sorted(picked): + cells = [] + for c in columns: + try: + raw, _ = locate_cell(table, r, c) + text = render_cell(raw) + cells.append(text if isinstance(raw, (int, float)) else scrub(text)) + except (CellError, RenderError): + cells.append(MISSING) + lines.append(f"r{r}:" + " | ".join(cells)) + sqls: list[str] = [] + seen: set[str] = set() + for item in result.get("inputs") or []: + if not isinstance(item, dict) or not item.get("artifact") or item.get("role") == "parent": + continue + artifact = str(item["artifact"]) + if artifact in seen: + continue + seen.add(artifact) + query = _query_alias(catalog, artifact) + snap = fetch(artifact) + snap = snap if isinstance(snap, dict) else {} + source = (catalog.get(query) or {}).get("source") if query else None + hidden, names = _derived(snap, _source_hidden(source or snap.get("source"), catalog, fetch, masked) + | _mask_names(snap.get("mask_columns")), fetch, mask) + used = [str(c) for c in item.get("columns") or [] if str(c).lower() not in hidden] + label = f"查询 {query}" if query else "一次查询(不在这份报告的证据目录中)" + lines.append(f"输入:取自{label}" + (f",用到 {'、'.join(used)} 列" if used else "")) + sql = _clean(str(snap.get("sql") or "")) + if sql and mask is not None: + sql = mask.sql(sql, snap, names) + if sql and len(sqls) < ENTITY_QUERIES: + sqls.append(_sql_line(f"查询 {query} 的 SQL" if query else "这次查询的 SQL", sql)) + parents = [str(i.get("artifact")) for i in result.get("inputs") or [] + if isinstance(i, dict) and i.get("role") == "parent"] + for artifact in parents: + found = next((a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == LEDGER_KIND + and e.get("artifact") == artifact), None) + lines.append(f"下钻自:{found or '一条分析结论(不在这份报告的证据目录中)'}") + return "\n".join(lines + sqls) + + def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _Fetch, masked: dict[str, Any] | None, catalog: dict[str, Any], *, provenance_on: bool = False, mask: Any = None) -> str | None: @@ -683,6 +793,9 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _ if kind == "retrieval": picked = [h for c in cands for a, h in c.hits if a == alias] return _retrieval_excerpt(alias, entry, picked, any(alias in c.whole for c in cands), fetch, mask) + if kind == LEDGER_KIND: + rows = {r for c in cands for a, r in c.rows if a == alias} + return _analysis_excerpt(alias, entry, rows, fetch, masked, catalog, mask) if kind == "table": head = f"【{alias}】表 {entry.get('name')}" + (f"(数据源 {entry['source']})" if entry.get("source") else "") return "\n".join([head, *_table_lines(entry, catalog, fetch, masked), @@ -754,8 +867,10 @@ class JudgeRequest: aliases = list(dict.fromkeys(a for c in units for a in c.aliases if a in self.excerpts)) evidence = "\n\n".join(self.excerpts[a] for a in aliases) or "(这些句子都没有挂依据)" ordered = sorted(units, key=lambda c: c.order) + # 摘录里有分析结论时另加一条(ANALYSIS_JUDGE_PROMPT);没有的和以前一字不差 + rules = f"{JUDGE_RULES}\n{ANALYSIS_JUDGE_PROMPT}" if any(_analysis_alias(a) for a in aliases) else JUDGE_RULES return [ - SystemMessage(content=f"{JUDGE_ROLE}\n\n{JUDGE_RULES}"), + SystemMessage(content=f"{JUDGE_ROLE}\n\n{rules}"), HumanMessage(content=( f"证据摘录(【】里是编号,判定时 used 写它):\n{evidence}\n\n" "要判断的句子(每句都要给判定):\n" + "\n".join(self.unit_line(c) for c in ordered) + "\n\n" diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index f3efa3cf..e21ff892 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -146,11 +146,12 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[ if isinstance(sources, str) and sources != "ancestors": sources = [sources] if isinstance(sources, list): - # evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。 - # 写了子工作流节点的,它交回来的(子图里的查询,节点 id「子工作流节点 id/…」)一并算 + # evidence_from 只管查询、检索、分析结论这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。 + # 分析结论按做它的节点认(Agent,或者分析卡)。写了子工作流节点的,它交回来的(子图里的查询,节点 id + # 「子工作流节点 id/…」)一并算 keep = {str(s) for s in sources} - ledger = [e for e in ledger - if e.get("kind") not in ("query", "retrieval", "schema") or _outer(e.get("node_id")) in keep] + ledger = [e for e in ledger if e.get("kind") not in ("query", "retrieval", "schema", "analysis") + or _outer(e.get("node_id")) in keep] nodes = state.get("nodes") or {} catalog = build_catalog( nodes=nodes, diff --git a/backend/app/engine/sql_problems.py b/backend/app/engine/sql_problems.py index e67916eb..c1e60aef 100644 --- a/backend/app/engine/sql_problems.py +++ b/backend/app/engine/sql_problems.py @@ -8,7 +8,9 @@ - 写作目录里提醒写作者(engine/evidence.catalog_prompt)。 合并查询在内存 SQLite 上执行,不对照数据目录,它的快照没有自己的 checks,但记着 inputs:顺着往下找,问题记在 -出问题的那条源查询上。几个指标、几处引用出自同一条查询时,问题只算一次(problems_by_query 按快照 id 归并)。 +出问题的那条源查询上。分析结论(app/analysis 的工件)同样记着 inputs(它用到的查询快照):报告引用了一份分析结论, +它的输入查询上的问题照样算——写作目录随 A 条目提醒,出具契约按引用的查询降档。几个指标、几处引用出自同一条查询时, +问题只算一次(problems_by_query 按快照 id 归并)。 """ from __future__ import annotations @@ -17,6 +19,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field from typing import Any +from app.analysis.contract import is_result from app.core import artifact_store #: 合并查询快照的 source(engine/merge_query.MERGE_SOURCE)。这里写死同一个字面量,免得为一个常量引入执行器 @@ -57,9 +60,10 @@ def _load(artifact: str, loader: Callable[[str], Any] | None) -> Any: def query_problems(artifact: Any, *, loader: Callable[[str], Any] | None = None, _seen: set[str] | None = None, _node: str | None = None, _depth: int = 0) -> list[QueryProblem]: - """这份查询快照(或合并快照的各个输入)里有 error 级 SQL 检查的那几条查询,一条一个 QueryProblem。 + """这份查询快照(或合并快照、分析结论的各个输入)里有 error 级 SQL 检查的那几条查询,一条一个 QueryProblem。 - 读不出来的快照、没有问题的快照都不出现。自引用、成环的坏快照不会卡死。 + 读不出来的快照、没有问题的快照都不出现。自引用、成环的坏快照不会卡死。分析结论的输入没记节点(node_id 为 None), + 调用方按证据台账补(node_of);下钻的上一层结论(role parent)也是输入,顺着它再往下找。 """ if not isinstance(artifact, str) or not artifact or _depth > _MAX_DEPTH: return [] @@ -75,7 +79,7 @@ def query_problems(artifact: Any, *, loader: Callable[[str], Any] | None = None, errors = [c for c in checks if isinstance(c, dict) and c.get("level") == "error"] if isinstance(checks, list) else [] if errors: out.append(QueryProblem(artifact, _node, errors)) - inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE else None + inputs = snapshot.get("inputs") if snapshot.get("source") == MERGE_SOURCE or is_result(snapshot) else None for entry in inputs if isinstance(inputs, list) else []: if isinstance(entry, dict) and isinstance(entry.get("artifact"), str): node = entry.get("node_id") if isinstance(entry.get("node_id"), str) else None diff --git a/backend/app/ledger/labels.py b/backend/app/ledger/labels.py index f53861e4..71e1b447 100644 --- a/backend/app/ledger/labels.py +++ b/backend/app/ledger/labels.py @@ -20,6 +20,9 @@ 整题(whole_of):主报告里所有带标签的数都来自同一口径的同一版、绑定一致,就记 equivalent,并写出还缺的入口字段(口径入口 节点声明了、这次的绑定里没有的;required 且没有缺省值的另列)。 +分析结论里的数([[a:A3]]、[[a:A3.键]]、[[v:A3.r0.列]]、整表 [[table:A3]] 展开的格)不标:它们是从查询快照派生出来的, +不是口径(docs/design/analysis-round/00-总方案.md D11),不和口径比对、不进整题判断的计数,也不算成「没比对」。 + 这里只是纯函数:标签在比对时算好、存进比对工件(ledger/match.py);接口读的时候按这次运行查到过的遮罩值遮一遍(view_label): 绑定的值是遮罩值的不给值,差异条目里的 SQL 片段走证据面板同一套遮罩(api/evidence._Shown)。 """ @@ -28,6 +31,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping from typing import Any +from app.analysis.contract import LEDGER_KIND + #: 标签的四种、认法的两种 KINDS = ("caliber", "related", "reasoning", "unknown") HOWS = ("independent", "composed") @@ -259,6 +264,8 @@ def referenced_columns(docs: list[Mapping[str, Any]], if cite.get("status") != "resolved": continue entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {} + if entry.get("kind") == LEDGER_KIND: + continue # 分析结论的结果表不是查询,不比对 locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {} artifact = str(entry.get("artifact") or "") if cite.get("kind") == "cell" and artifact and isinstance(locator.get("column"), str): @@ -375,7 +382,7 @@ def label_doc(doc: Mapping[str, Any], queries: Mapping[str, Mapping[str, Any]], calibers: Mapping[str, Mapping[str, Any]], load_card: Callable[[str], Mapping[str, Any] | None]) -> dict[str, dict[str, Any]]: """一份报告文档里每个数的标签:{片段 id: 标签}。只标引用解析了的数字、取值片段(单元格、指标);运行输入、表名、引文、 - 没有引用的数不标。""" + 没有引用的数不标,分析结论里的数(具名标量、结果表的格)也不标(D11:派生的数,不是口径)。""" from app.engine.evidence import iter_units catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {} @@ -389,6 +396,8 @@ def label_doc(doc: Mapping[str, Any], queries: Mapping[str, Mapping[str, Any]], if cite.get("status") != "resolved": continue entry = catalog.get(cite.get("alias")) if isinstance(catalog.get(cite.get("alias")), dict) else {} + if entry.get("kind") == LEDGER_KIND: + continue locator = cite.get("locator") if isinstance(cite.get("locator"), dict) else {} artifact = str(entry.get("artifact") or "") if cite.get("kind") == "cell" and artifact: diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py index cf353fc6..7cbbb079 100644 --- a/backend/app/report_export/build.py +++ b/backend/app/report_export/build.py @@ -4,7 +4,9 @@ 执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的 出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、 输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查), -以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。 +以及运行输入、表名字段名、引文各自的步骤;分析结论(判定语、具名的数、结果表的格)点开是判定 → 方法 → 计算 → 输入 → +复算 → 封存。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。报告文档带着系统写的「分析范围」 +(analysis_scope)时,正文后面另渲染这一段,编号点得开同一条出处链;没有这个字段的文档一个字都不多。 这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希 (可验证报告,app/bundle)。页面页脚写明这一点。 @@ -77,7 +79,7 @@ _STATE_CLASS = { "deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad", "unjudged": "na", "candidate": "na", } -_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"} +_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文", "analysis": "判定"} def _esc(text: Any) -> str: @@ -293,6 +295,61 @@ def render_body(doc: Mapping[str, Any]) -> str: return f'
{"".join(_render_block(b) for b in blocks)}
' +#: 「分析范围」各组的叫法(00-总方案 §3.6),按这个顺序写 +SCOPE_TITLES = (("phenomena", "现象"), ("decomposed", "拆过"), ("excluded", "排除"), ("refused", "算不了")) +SCOPE_NOTE = "这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定标准、计算、输入和复算。" +#: 分析范围里一个编号的片段键(api/evidence.SCOPE_REF 同一个写法):点开时按它找出处链 +_SCOPE_REF = "scope:{alias}" + + +def _scope_item(item: Mapping[str, Any], *, depth: int = 0) -> str: + """分析范围里的一条:编号(按钮,点开出处链)、维度、下钻自哪一条、判定语或算不了的原因。""" + alias = str(item.get("alias") or "") + title = f"分析结论 {_esc(alias)}:点开查看出处" + button = (f'") if alias else "" + dims = [str(d) for d in item.get("dims") or [] if d] + bits = ([f"按{'、'.join(dims)}"] if dims else []) + ([f"下钻自 {item['parent']}"] if item.get("parent") else []) + lead = f"({','.join(_esc(b) for b in bits)})" if bits else "" + said = item.get("reason") if item.get("reason") and not item.get("phrase") else item.get("phrase") + margin = f' style="margin-left:{depth * 14}px"' if depth else "" + return f"{button}{lead}{':' if said else ''}{_esc(said or '')}" + + +def render_scope(doc: Mapping[str, Any]) -> str: + """报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):现象、拆过(下钻按链缩进)、排除、算不了、没拆、 + 检验了几次。文档没有这个字段(老文档、这次没有分析结论、报告节点关了这一段)时是空串,页面一个字都不多。""" + scope = doc.get("analysis_scope") + if not isinstance(scope, Mapping): + return "" + rows: list[str] = [] + for key, title in SCOPE_TITLES: + items = [i for i in scope.get(key) or [] if isinstance(i, Mapping)] + if not items: + continue + depth: dict[str, int] = {} + lines = [] + for item in items: + level = depth.get(str(item.get("parent") or ""), -1) + 1 if item.get("parent") else 0 + depth[str(item.get("alias") or "")] = level + lines.append(_scope_item(item, depth=level)) + rows.append(f"
{title}
    {''.join(lines)}
") + missing = scope.get("not_decomposed") + if isinstance(missing, list): + names = "、".join(_esc(n) for n in missing if n) + rows.append(f"
没拆
{names or '候选维度都拆过了'}
") + elif "not_decomposed" in scope: + rows.append(f"
没拆
{_esc(scope.get('note') or '候选维度核对不了')}
") + tests = scope.get("tests") + if isinstance(tests, int) and not isinstance(tests, bool) and tests > 0: + rows.append(f"
检验
这次做了 {tests} 次差异检验
") + if not rows: + return "" + return (f'

分析范围

' + f'

{SCOPE_NOTE}

{"".join(rows)}
') + + # -------------------------------------------------------------------------- # 数据块和页面 # -------------------------------------------------------------------------- @@ -390,7 +447,7 @@ def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str: "NAME": _esc(name), "SEAL": seal_text, "RUN": _esc(str(meta.get("run_id") or "")), - "BODY": render_body(doc), + "BODY": render_body(doc) + render_scope(doc), "CSP": f"'sha256-{digest}'", "DATA": _json_text(data), "SCRIPT": script, diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index f88755e7..a98595ad 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -3,9 +3,10 @@ * 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事: * ① 填页面头部(报告节点、文档工件、封存状态、运行信息); * ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板; - * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、 + * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算、可比性)、 * 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、 - * 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。 + * 运行输入、表名字段名、引文,分析结论的六步(判定、方法、计算、输入、复算、封存),和网页上的证据面板 + * (api/evidence 的片段接口)同一个拼法。正文后面系统写的「分析范围」里的编号同样点得开(data-seg 是 scope:A1)。 * 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。 * 来自数据的文字一律经 textContent 写进页面,不拼 HTML。 */ @@ -20,7 +21,7 @@ deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实', unjudged: '未裁判', candidate: '猜测的来源', }, - kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' }, + kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文', analysis: '判定' }, segTitle: (kind, text) => `${kind}「${text}」`, metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点', metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间', @@ -53,6 +54,7 @@ truncated: '查询结果已截断,该字段只含取回的部分行', // 查询步骤 query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`, + dTotalRows: '行数', dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分', truncatedRows: '查询结果被截断,取回的行不完整', merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告', @@ -76,6 +78,28 @@ ledgerRelated: '相关口径', ledgerReasoning: '这次的推理:没有相关的已发布口径', ledgerUnknown: '没有比对', dBinding: '参数', dDiffers: '差在哪', dHow: '怎么认的', dColumnOf: '对应口径的列', unnamed: '已删除的工作流', + // 口径卡指标上的可比性(跨两份同形查询算的环比、同比) + dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' }, + comparabilityOpen: '展开这份可比性结论(判定、方法、计算、输入、复算、封存)', + // 分析结论:判定 → 方法 → 计算 → 输入 → 复算 → 封存 + analysis: '分析结论', dPhrase: '判定语', dCriterion: '判定标准', dSubjects: '点名的段', + dDirection: '方向', directionLabel: { up: '上升', down: '下降' }, dCaveats: '附带说明', dReason: '算不了的原因', + dCitedPart: '这一处引用的是', citedPhrase: '判定语', citedValue: (k) => `具名的数「${k}」`, + citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的', + originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' }, + dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致', + method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查结果', + probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' }, + probeBound: (expr, value) => `按运行当天现算的界 ${expr} = ${value}`, probeEmpty: '这一列一个值都没有', + probeRange: (min, max) => `${min} 至 ${max}`, + calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表', + resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`, + analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行', + allRows: '全部行', dHeadline: '总数那一格', dClosure: '闭合核对', + closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`, + dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`, + inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论', + viaCard: '记在口径卡的指标集里,随卡封存(不进台账)', } const MASKED = '已遮罩' @@ -211,6 +235,23 @@ : T.originUnknown(String(u.path || NONE)))).join(';') : T.originTraced] : null, ]))) + // 可比性:口径卡执行时自动做的可比性检查(跨两份同形查询算的环比、同比),做过才有 + if (isObj(step.comparability)) { + const c = step.comparability + const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.join(';')}` : '' + body.push(h('div', { style: 'margin-top:8px' }, kv([ + [T.dComparability, + `${own(T.comparabilityLabel, c.verdict) || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`], + c.criterion ? [T.dCriterion, String(c.criterion)] : null, + c.artifact ? [T.dArtifact, h('code', {}, short(c.artifact))] : null, + ]))) + // 点得开那份可比性结论:服务端给的出处链(同分析结论的引用),收在一个可展开的块里 + const steps = Array.isArray(c.steps) ? c.steps.filter((x) => isObj(x) && own(PARTS, x.step)) : [] + if (steps.length) { + body.push(h('details', { class: 'comparability', 'data-comparability': '' }, + h('summary', {}, T.comparabilityOpen), ...steps.map((x) => PARTS[x.step](x)))) + } + } // 双盲复算(期 4):复核过这个指标才有 if (isObj(step.dual_check)) { const d = step.dual_check @@ -254,7 +295,7 @@ step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null, step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null, step.alias ? [T.dQuery, String(step.alias)] : null, - step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null, + step.total_rows != null ? [T.dTotalRows, T.dAllRows(step.total_rows)] : null, ] body.push(kv(rows.filter(Boolean))) if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql)) @@ -314,7 +355,7 @@ if (step.hash_ok === false) bits.push(badge('bad', T.hashBad)) if (bits.length) body.push(h('div', { class: 'chips' }, ...bits)) return h('section', { class: 'part', 'data-step': 'query' }, - h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`), + h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}${step.input_of ? ` · ${T.inputOfAnalysis(step.input_of)}` : ''}`), h('div', { class: 'part-body' }, ...body)) } @@ -397,9 +438,153 @@ h('div', { class: 'part-body' }, ...body)) } + /** 完整性徽标:已封存、哈希核对失败(每一步都可带) */ + function integrity(step) { + const bits = [] + if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo)) + if (step.hash_ok === false) bits.push(badge('bad', T.hashBad)) + return bits.length ? h('div', { class: 'chips' }, ...bits) : null + } + + const muted = (text) => h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(text)) + const waiting = (text) => h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(text)) + + /** 一张小表:表头 heads,行 rows(每行一组格),高亮的格(行号:列 id)和列 id 对上 */ + function smallTable(heads, ids, rows, index, hits) { + const hit = new Set((hits || []).map((c) => `${c[0]}:${c[1]}`)) + return h('div', { class: 'table-wrap' }, h('table', {}, + h('thead', {}, h('tr', {}, heads.map((c) => h('th', {}, String(c))))), + h('tbody', {}, rows.map((row, i) => h('tr', {}, (Array.isArray(row) ? row : ids.map((c) => own(row, c))).map((v, j) => h('td', { + class: [hit.has(`${index[i]}:${ids[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : ''].filter(Boolean).join(' ') || null, + }, v == null ? NONE : textOf(v)))))))) + } + + /** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设 */ + function analysisPart(step) { + const body = [] + const verdict = isObj(step.verdict) ? step.verdict : null + const cited = isObj(step.cited) ? step.cited : {} + const citedText = cited.what === 'value' ? T.citedValue(cited.key) : cited.what === 'cell' + ? T.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : T.citedPhrase + body.push(kv([ + [T.dPhrase, String(step.phrase || NONE)], + step.criterion ? [T.dCriterion, String(step.criterion)] : null, + verdict && Array.isArray(verdict.subjects) && verdict.subjects.length + ? [T.dSubjects, verdict.subjects.map((s) => (Array.isArray(s) ? s.join(' · ') : String(s))).join('、')] : null, + verdict && verdict.direction ? [T.dDirection, T.directionLabel[verdict.direction] || String(verdict.direction)] : null, + [T.dCitedPart, citedText], + [T.dWho, `${own(T.originLabel, step.origin) || NONE}` + + `${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`], + ])) + if (step.status === 'refused') body.push(waiting(`${T.refused}${step.reason ? `:${step.reason}` : ''}`)) + if (Array.isArray(step.caveats) && step.caveats.length) { + body.push(h('p', { class: 'sub' }, T.dCaveats), + h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, step.caveats.map((c) => h('li', {}, String(c.text || ''))))) + } + if (step.hypothesis) { + body.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(step.hypothesis))) + if (step.hypothesis_note) body.push(muted(step.hypothesis_note)) + } + if (step.note) body.push(waiting(step.note)) + if (typeof step.render_ok === 'boolean') body.push(muted(step.render_ok ? T.renderOk : T.renderBad)) + return h('section', { class: 'part', 'data-step': 'analysis' }, + h('h3', {}, `${[T.analysis, step.alias].filter(Boolean).join(' ')} · ${step.method_label || NONE}`), + h('div', { class: 'part-body' }, ...body)) + } + + /** 现查结果的一条:有 expr 的是按运行当天现算的界(CURRENT_DATE 这类在库里算出的日期),其余是一列的实际范围;现查到的 + * 范围最早、最晚都空,是这一列一个值都没有 */ + function probeText(p) { + const how = `(${own(T.probeBasis, p.basis) || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})` + if (typeof p.expr === 'string' && p.expr) return `${T.probeBound(p.expr, p.value || NONE)}${how}` + const where = `${p.table || NONE}.${p.column || NONE}` + const empty = p.basis === 'live' && !p.min && !p.max + return `${where}:${empty ? T.probeEmpty : T.probeRange(p.min || NONE, p.max || NONE)}${how}` + } + + /** 分析结论 · 方法:方法与版本、参数、实际用的阈值、现查结果 */ + function analysisMethodPart(step) { + const body = [kv([[T.dMethod, `${step.method_label || NONE} · ${step.method || NONE}`]])] + if (isObj(step.params) && Object.keys(step.params).length) { + body.push(h('p', { class: 'sub' }, T.dParams), h('pre', { class: 'sql' }, JSON.stringify(step.params, null, 2))) + } + if (Array.isArray(step.settings) && step.settings.length) { + body.push(h('p', { class: 'sub' }, T.dSettings), kv(step.settings.map((x) => [String(x.label || x.key), textOf(x.value)]))) + } + if (Array.isArray(step.probes) && step.probes.length) { + body.push(h('p', { class: 'sub' }, T.dProbes), h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, + step.probes.filter(isObj).map((p) => h('li', {}, probeText(p))))) + } + return h('section', { class: 'part', 'data-step': 'analysis_method' }, h('h3', {}, T.method), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 计算:关键数的代入式、具名的数、结果表(引用的格高亮) */ + function analysisCalcPart(step) { + const body = [] + if (Array.isArray(step.formulas) && step.formulas.length) { + body.push(h('p', { class: 'sub' }, T.dFormulas), h('pre', { class: 'sql' }, step.formulas.map((f) => String(f.text || '')).join('\n'))) + } + if (Array.isArray(step.values) && step.values.length) { + body.push(h('p', { class: 'sub' }, T.dValues), kv(step.values.map((v) => [String(v.key), + h('span', { class: v.cited ? 'hit' : null }, String(v.rendered ?? NONE))]))) + } + const table = isObj(step.table) ? step.table : null + if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) { + const labels = isObj(table.labels) ? table.labels : {} + const index = Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i) + body.push(h('p', { class: 'sub' }, `${T.dResultTable}(${T.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})`), + smallTable(table.columns.map((c) => labels[c] || c), table.columns, table.rows.map((r) => (Array.isArray(r) + ? r.map((v, j) => ((table.masked || []).includes(table.columns[j]) ? MASKED : v)) : r)), index, + isObj(table.highlight) ? table.highlight.cells : [])) + if (table.window_truncated) body.push(muted(T.windowCut)) + } + return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数那一格和闭合核对、下钻的上一层 */ + function analysisInputPart(step) { + const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null + const closure = isObj(step.closure) ? step.closure : null + const body = [kv([ + [T.dRole, String(step.role_label || step.role || NONE)], + step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null, + step.query ? [T.dQuery, String(step.query)] : null, + step.alias ? [T.dParent, String(step.alias)] : null, + step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null, + Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null, + rows ? [T.dRowsUsed, rows] : null, + 'value' in step ? [T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null, + closure ? [T.dClosure, closure.closed ? T.closureOk(closure.headline, closure.total) : T.closureBad(closure.headline, closure.total)] : null, + step.phrase ? [T.dPhrase, String(step.phrase)] : null, + ])] + const bits = integrity(step) + if (bits) body.push(bits) + return h('section', { class: 'part', 'data-step': 'analysis_input' }, + h('h3', {}, `${T.analysisInput}${step.query || step.alias ? ` ${step.query || step.alias}` : ''}`), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 复算:按封存的输入重算、和封存的比(服务端算好的那一句) */ + function analysisRecomputePart(step) { + const kind = step.status === 'same' ? 'ok' : step.status === 'different' ? 'bad' : 'na' + return h('section', { class: 'part', 'data-step': 'analysis_recompute' }, h('h3', {}, T.recompute), + h('div', { class: 'part-body' }, h('div', { class: 'chips' }, badge(kind, String(step.text || NONE))))) + } + + /** 分析结论 · 封存:台账记没记过、哈希核对 */ + function analysisSealPart(step) { + const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE], + [T.seal, step.via === 'metric_set' ? T.viaCard : step.ledger ? T.inLedger : T.notInLedger]])] + if (step.note) body.push(waiting(step.note)) + const bits = integrity(step) + if (bits) body.push(bits) + return h('section', { class: 'part', 'data-step': 'analysis_seal' }, h('h3', {}, T.seal), h('div', { class: 'part-body' }, ...body)) + } + const PARTS = { metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart, entity: entityPart, quote: quotePart, + analysis: analysisPart, analysis_method: analysisMethodPart, analysis_calc: analysisCalcPart, + analysis_input: analysisInputPart, analysis_recompute: analysisRecomputePart, analysis_seal: analysisSealPart, } /** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪; diff --git a/backend/app/report_export/template.html b/backend/app/report_export/template.html index d2ef850c..44a2da5f 100644 --- a/backend/app/report_export/template.html +++ b/backend/app/report_export/template.html @@ -97,6 +97,13 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } .chipbtn { font: inherit; font-size: 12px; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; } .hit { background: var(--mark); } +.scope { margin-top: 16px; padding: 14px 18px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); } +.scope h3 { margin: 0 0 4px; font-size: 16px; } +.scope > .muted { margin: 0 0 8px; font-size: 12px; } +.scope-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; font-size: 14px; } +.scope-list dt { color: var(--faint); } +.scope-list dd { margin: 0; min-width: 0; } +.scope-list ul { margin: 0; padding-left: 1.2em; } .foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); } .foot p { margin: 4px 0; } @media print { @@ -115,7 +122,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } body { font-size: 14px; } .page { padding: 20px 16px 56px; } h1 { font-size: 20px; } - .meta { grid-template-columns: minmax(0, 1fr); gap: 0; } + .meta, .scope-list { grid-template-columns: minmax(0, 1fr); gap: 0; } .meta dt { margin-top: 6px; } .doc { padding: 12px; } } diff --git a/backend/tests/test_analysis_bundle.py b/backend/tests/test_analysis_bundle.py new file mode 100644 index 00000000..d41f31aa --- /dev/null +++ b/backend/tests/test_analysis_bundle.py @@ -0,0 +1,268 @@ +"""分析结论进可验证报告(第 1 波 W1-b):bundle/build.py 带上结论工件和它的输入快照,verifier.js 在 node 里核对。 + +- 报告引用的分析结论按原字节嵌入,连同输入快照;服务端导出时按封存的输入复算一遍(真的 app.analysis.recompute),结果记在 + 工件条目上(recheck) +- 口径卡指标上的可比性结论(不进台账、只从卡上引用)随卡附带,④ 按「已核对的口径卡记着的可比性」认它 +- 分析工具的 tool.end、分析卡一项的 analysis.end:结论随文件附带得了才带规范字符串 +- 页面只核哈希、核判定语和数是不是结论里写的那几个字(不在浏览器里重算统计):原样导出全部通过,只改显示文字的伪造 + 点名不通过 +- 输入快照只给 id(证明不了没碰遮罩列)的,结论本身也只给 id,引用它的判定语、数无法核对 +- 报告文档里系统写的「分析范围」点名的结论也随文件附带 +""" +from __future__ import annotations + +import base64 +import json +from typing import Any + +import pytest + +from app.analysis.contract import ARTIFACT_KIND, ledger_entry +from app.bundle.build import build_bundle +from app.core.artifact_store import canonical_json, content_hash, manifest_hash +from app.engine.evidence import build_catalog, compose_doc, iter_segments +from tests.test_analysis_evidence import SNAPSHOT, analyzed, real_result, run_sync +from tests.test_analysis_evidence_api import LAST_WEEK, THIS_WEEK +from tests.test_bundle_shown import NODE, node, reseal + +pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的核对脚本") + +RUN_ID = "7b1e0c3a5d9f4a2e8c6b4d2f0a1e3c5b" +RUN = {"id": RUN_ID, "workflow_id": "wf-analysis-weekly", "workflow_name": "异动周报", "version": 2, + "version_hash": "cd" * 32, "run_class": "formal", "status": "succeeded", + "started_at": "2026-10-12T01:00:00+00:00", "finished_at": "2026-10-12T01:01:00+00:00"} +GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [ + ("fetch", "tool", "按地区取数"), ("an", "agent", "分析"), ("write", "report", "写周报"), ("out", "output", "成果")]]} +TEXT = "## 异动\n\n[[a:A1]]。华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]]。[[see:A1]]" + + +class Files(dict): + def put(self, obj: Any) -> str: + raw = canonical_json(obj).encode() + sha = content_hash(raw) + self[sha] = raw + return sha + + def load(self, artifact: str) -> Any: + raw = self.get(artifact) + return json.loads(raw) if raw is not None else None + + +def bundle_of(text: str = TEXT, *, snapshot: dict[str, Any] | None = None, doc_extra: dict[str, Any] | None = None, + source_masks: dict[str, list[str]] | None = None, graph: dict[str, Any] | None = None, + extra_events: list[tuple[str, str | None, dict[str, Any]]] = (), + drop: tuple[str, ...] = ()) -> tuple[dict[str, Any], Files, str]: + """一次封存好的运行的页面数据:(bundle, 工件, 分析结论的 id)。结论按快照真算(app.analysis.run);extra_events 接在 + 分析节点完成之后({analysis} 换成结论的 id),drop 是导出时服务端已经没有的工件("query":输入快照)。""" + files = Files() + snap = snapshot or SNAPSHOT + q = files.put(snap) + result = real_result(q, snap) + a = files.put(result) + qentry = {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop", + "source": snap.get("source"), "columns": snap["columns"], "rows": len(snap["rows"]), "truncated": False} + aentry = ledger_entry(result, node_id="an", exec_no=1, artifact=a, item="drop") + catalog = build_catalog(nodes={}, ledger=[qentry, aentry], loader=files.load) + doc = compose_doc(text, catalog, node_id="write", run_id=RUN_ID, loader=files.load) + doc.update(doc_extra or {}) + doc_id = files.put(doc) + events: list[tuple[int, str, str | None, Any]] = [] + + def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None: + events.append((len(events) + 1, etype, node_id, data)) + + add("run.started", None, {"nodes": 4, "resumed": False, "memory_scope": "default", "collection": "default", + "approval_default": None, "tool_trust": "formal", "agent_limits": {}, + "data_versions": None, "replay_protocol": 2}) + add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 5, "query_artifact": q, "preview": "…"}) + add("node.finished", "fetch", {"duration_ms": 6, "attempt": 1, "evidence": [qentry]}) + add("node.finished", "an", {"duration_ms": 8, "attempt": 1, "evidence": [aentry]}) + for etype, node_id, data in extra_events: + add(etype, node_id, json.loads(json.dumps(data).replace("{analysis}", a))) + add("report.checked", "write", {"doc_artifact": doc_id, "ok": True}) + add("node.finished", "write", {"duration_ms": 9, "attempt": 1, "preview": {"text": "(报告正文)", "doc_artifact": doc_id}}) + add("issuance", "out", {"tier": "formal", "mode": "citations"}) + add("run.finished", None, {"output": {"周报": "(报告正文)"}}) + if "query" in drop: + files.pop(q) + bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash=manifest_hash(events), read=files.get, + exported_at="2026-10-21T08:00:00+00:00", graph=graph or GRAPH, + source_masks=source_masks or {}) + return bundle, files, a + + +def item(bundle: dict[str, Any], artifact: str) -> dict[str, Any]: + return next(a for a in bundle["artifacts"] if a["id"] == artifact) + + +def verdicts(bundle: dict[str, Any]) -> dict[str, Any]: + return node("verify", data=bundle, seal=bundle["meta"]["manifest_hash"]) + + +def test_the_analysis_and_its_input_travel_with_the_file_and_verify(): + bundle, files, a = bundle_of() + analysis_item = item(bundle, a) + assert analysis_item["kind"] == ARTIFACT_KIND and "b64" in analysis_item and analysis_item["node_id"] == "an" + # 导出时服务端真的复算了一遍:按封存的输入快照(这份文件里哈希对得上的原字节)重算,逐字段相同 + assert analysis_item["recheck"] == {"status": "same", "method": "decompose.additive@1", + "text": "复算一致:按封存的输入重新计算,结论逐项相同"} + [query] = [x for x in bundle["artifacts"] if x["kind"] == "query"] + assert "b64" in query + + got = verdicts(bundle) + assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain", "meta") for r in got[key]), got["why"] + numbers = {n["ref"]: n for n in got["numbers"]} + assert set(numbers) == {"a:A1", "a:A1.top_share|pct", "v:A1.r0.delta"} + assert all(n["result"] == "pass" and n["expected"] == n["text"] for n in numbers.values()), numbers + assert numbers["a:A1"]["text"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)" + assert got["overall"]["result"] == "pass" + + +def test_an_analysis_whose_input_is_gone_travels_only_as_a_hash(): + """导出时服务端已经没有输入快照:证明不了没碰遮罩列,结论只给 id,也就不顺着它复算、不附带别的。""" + bundle, _, a = bundle_of(drop=("query",)) + assert item(bundle, a) == {"id": a, "kind": ARTIFACT_KIND, "node_id": "an", "masked": True} + + +@pytest.mark.parametrize("ref, text", [("a:A1", "销售额下降 194(-8.7%),主要来自华南(-120,占 61.9%)"), + ("a:A1.top_share|pct", "71.9%"), ("v:A1.r0.delta", "-220")]) +def test_forging_the_shown_text_fails_by_name(ref, text): + bundle, _, _ = bundle_of() + + def change(doc: dict[str, Any]) -> None: + seg = next(s for s in iter_segments(doc) if s.get("ref") == ref) + doc["markdown"] = doc["markdown"].replace(seg["text"], text, 1) + seg["cite"]["rendered"] = seg["text"] = text + + art = next(x for x in bundle["artifacts"] if x["kind"] == "report_doc") + doc = json.loads(base64.b64decode(art["b64"])) + change(doc) + got = verdicts(reseal(bundle, art, doc)) + forged = next(n for n in got["numbers"] if n["ref"] == ref) + assert forged["result"] == "fail" and forged["reason"].startswith(f"正文显示「{text}」") + + +def test_an_analysis_on_a_masked_query_travels_only_as_a_hash(): + masked = {**SNAPSHOT, "columns": ["region", "phone", "week", "gmv"], "mask_columns": ["phone"], + "sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3", + "rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]} + bundle, _, a = bundle_of(snapshot=masked) + assert item(bundle, a) == {"id": a, "kind": ARTIFACT_KIND, "node_id": "an", "masked": True} + got = verdicts(bundle) + why = next(w for w in got["why"]["artifacts"] if w["kind"] == ARTIFACT_KIND) + assert why["result"] == "na" and why["reason"].startswith("这份分析结论的输入查询用到或可能用到设了遮罩的列") + assert all(n["result"] == "na" for n in got["numbers"]) + assert got["overall"]["result"] != "pass" + + +def test_analyses_named_only_in_the_scope_travel_too(): + scope = {"analysis_scope": {"phenomena": [{"alias": "A1", "op": "decompose", "phrase": "主要来自华东"}], + "decomposed": [], "excluded": [], "refused": [], "not_decomposed": None, + "note": "拆解用的列对不上目录,候选维度核对不了", "tests": 0}} + bundle, _, a = bundle_of("本周销售额有变化。", doc_extra=scope) + assert "b64" in item(bundle, a) + got = verdicts(bundle) + assert all(r == "pass" for r in got["chain"]), got["why"]["chain"] + + +def test_an_analysis_card_event_shows_its_content_only_when_its_conclusions_travel(): + """分析卡(节点类型 analysis)的节点完成事件:结论都随文件附带才带规范字符串;结论只给 id 的,这条也只给哈希。 + Agent 节点的照旧只给哈希。""" + card = {"nodes": [{**n, "type": "analysis"} if n["id"] == "an" else n for n in GRAPH["nodes"]]} + + def shown(bundle: dict[str, Any]) -> bool: + return "canonical" in next(e for e in bundle["events"] if e["type"] == "node.finished" and e["node_id"] == "an") + + assert shown(bundle_of(graph=card)[0]) + assert not shown(bundle_of()[0]) + masked = {**SNAPSHOT, "mask_columns": ["phone"], "columns": ["region", "phone", "week", "gmv"], + "sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3", + "rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]} + assert not shown(bundle_of(graph=card, snapshot=masked)[0]) + got = verdicts(bundle_of(graph=card)[0]) + assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]) + + +def test_analysis_tool_and_card_events_show_content_only_when_the_conclusion_travels(): + """分析工具的 tool.end(预览里是结论的前一截)、分析卡一项的 analysis.end(带判定语):和结论随文件附带同一个认法。""" + events = [("tool.end", "an", {"tool": "analysis_decompose", "duration_ms": 3, "analysis_artifact": "{analysis}", + "preview": "{\"analysis_artifact\": \"{analysis}\"}"}), + ("analysis.end", "an", {"item": "drop", "op": "decompose", "mode": "additive", "status": "ok", + "verdict": "driver", "direction": "down", "phrase": "主要来自华东", + "reason_code": None, "analysis_artifact": "{analysis}"})] + + def shown(bundle: dict[str, Any]) -> dict[str, bool]: + return {e["type"]: "canonical" in e for e in bundle["events"] if e["type"] in ("tool.end", "analysis.end") + and e["node_id"] == "an"} + + open_ = bundle_of(extra_events=events)[0] + assert shown(open_) == {"tool.end": True, "analysis.end": True} + got = verdicts(open_) + assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got["why"] + masked = {**SNAPSHOT, "mask_columns": ["phone"], "columns": ["region", "phone", "week", "gmv"], + "sql": "SELECT region, phone, week, SUM(gmv) AS gmv FROM orders GROUP BY 1, 2, 3", + "rows": [[r[0], "000-0001", r[1], r[2]] for r in SNAPSHOT["rows"]]} + assert shown(bundle_of(snapshot=masked, extra_events=events)[0]) == {"tool.end": False, "analysis.end": False} + + +def card_bundle() -> tuple[dict[str, Any], Files, str, str]: + """跨两周算环比的口径卡,指标上记着口径卡执行时自动做的可比性检查(结论落成工件,不进台账): + (bundle, 工件, 可比性结论的 id, 口径卡的 id)。""" + files = Files() + last, this = files.put(LAST_WEEK), files.put(THIS_WEEK) + result = run_sync(analyzed({"fetch·base": (LAST_WEEK, last), "fetch·target": (THIS_WEEK, this)}, + {"op": "comparable", "mode": None, "params": {}}, + base_query="fetch·base", target_query="fetch·target")) + found = files.put(result) + card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [ + {"id": "wow", "name": "环比", "value": -0.0874, "unit": "", "format": "percent_of_ratio", "decimals": 3, + "expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'gmv') - 1", + "substituted": "2026 / 2220 - 1", "recompute_ok": True, "status": "ok", + "inputs": [{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2026, "node_id": "fetch", "via": "tool_cell", + "artifact": this, "locator": {"row": 0, "column": "gmv"}}, + {"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2220, "node_id": "fetch", "via": "tool_cell", + "artifact": last, "locator": {"row": 0, "column": "gmv"}}], + "comparability": {"artifact": found, "verdict": result["verdict"]["code"], "phrase": result["phrase"], + "reasons": []}}]} + card_id = files.put(card) + qentries = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": x, "tool": "db_query__shop", + "source": "shop", "columns": ["gmv"], "rows": 1, "truncated": False} for x in (last, this)] + mentry = {"kind": "metric_set", "node_id": "card", "exec": 1, "artifact": card_id} + catalog = build_catalog(nodes={"card": {**card, "artifact": card_id}}, ledger=[*qentries, mentry], + loader=files.load) + doc = compose_doc("## 环比\n\n销售额环比 [[m:wow]]。", catalog, node_id="write", run_id=RUN_ID, loader=files.load) + doc_id = files.put(doc) + events: list[tuple[int, str, str | None, Any]] = [] + + def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None: + events.append((len(events) + 1, etype, node_id, data)) + + add("run.started", None, {"nodes": 4, "resumed": False, "memory_scope": "default", "collection": "default", + "approval_default": None, "tool_trust": "formal", "agent_limits": {}, + "data_versions": None, "replay_protocol": 2}) + for x in (last, this): + add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 5, "query_artifact": x, "preview": "…"}) + add("node.finished", "fetch", {"duration_ms": 6, "attempt": 1, "evidence": qentries}) + add("node.finished", "card", {"duration_ms": 7, "attempt": 1, "evidence": [mentry]}) + add("report.checked", "write", {"doc_artifact": doc_id, "ok": True}) + add("node.finished", "write", {"duration_ms": 9, "attempt": 1, "preview": {"text": "(报告正文)", "doc_artifact": doc_id}}) + add("run.finished", None, {"output": {"周报": "(报告正文)"}}) + graph = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [ + ("fetch", "tool", "按周取数"), ("card", "metrics", "周报口径"), ("write", "report", "写周报")]]} + bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash=manifest_hash(events), read=files.get, + exported_at="2026-10-21T08:00:00+00:00", graph=graph, source_masks={}) + return bundle, files, found, card_id + + +def test_the_comparability_on_a_card_travels_with_the_card_and_is_chained_through_it(): + bundle, _, found, card_id = card_bundle() + assert "b64" in item(bundle, card_id) + comparability = item(bundle, found) + assert comparability["kind"] == ARTIFACT_KIND and "b64" in comparability and comparability["node_id"] == "card" + assert comparability["recheck"]["status"] == "same" + got = verdicts(bundle) + assert all(r == "pass" for key in ("events", "manifest", "artifacts", "chain") for r in got[key]), got["why"] + assert {w["kind"] for w in got["why"]["chain"]} >= {"metric_set", ARTIFACT_KIND, "query"} + [wow] = got["numbers"] + assert wow["result"] == "pass", wow + diff --git a/backend/tests/test_analysis_evidence.py b/backend/tests/test_analysis_evidence.py new file mode 100644 index 00000000..84c922de --- /dev/null +++ b/backend/tests/test_analysis_evidence.py @@ -0,0 +1,658 @@ +"""分析结论进证据链(docs/design/analysis-round/00-总方案.md §3.3,第 1 波 W1-b):引擎这一层的纯函数。 + +- 写作目录按台账编 A1、A2……(只收报告祖先里的),条目带着从工件取回、复验过哈希的整份结论(contract.catalog_result); + 存进报告文档的目录只留摘要 +- [[a:A1]] 渲染判定语(片段种类 analysis,整段涂白、不算裸数字),[[a:A1.键|换算]] 渲染具名标量,[[v:A1.r0.列]]、 + [[table:A1 …]] 取结果表(表头写给人看的列名),[[see:A1]] 当依据 +- 受管级别的正式出具、契约没开「单元格引用」时,Agent 现场做的分析和它查的格同一个规矩,分析卡做的照常引用(D10) +- 写作目录的「分析结论」一段:判定语、判定标准、具名的数、结果表前 table_top_k 段、附带说明、算不了的原因、来自哪几个 Q, + 放不下时结果表的各段排在查询的行前面补 +- 分析结论的输入查询上的 SQL 检查问题随 A 条目出现;口径卡指标上的可比性照实写进写作目录 +- 复算(analysis_recheck)、代入式(analysis_formulas):用 app.analysis 真算的结论(run 交的输入照分析入口的写法,复算交 + 工件原内容),也用 fake_result 测目录和引用的写法 +""" +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from typing import Any + +import pytest + +from app import analysis +from app.analysis import contract +from app.analysis.contract import CallError, effective_settings, ledger_entry +from app.analysis.testing import fake_result +from app.core.artifact_store import canonical_json, content_hash +from app.engine.evidence import ( + ANALYSIS_CELLS_REASON, + StreamRenderer, + analysis_formulas, + analysis_recheck, + build_catalog, + compose_doc, + iter_segments, + render_markers, + resolve_ref, + verify_doc, + writer_catalog, +) +from app.engine.sql_problems import query_problems + +#: 虚构零售库 shop:四个地区两周的销售额 +SNAPSHOT: dict[str, Any] = { + "columns": ["region", "week", "gmv"], "column_types": {"region": "text", "week": "text", "gmv": "number"}, + "rows": [["华东", "W41", 820], ["华东", "W42", 700], ["华北", "W41", 600], ["华北", "W42", 560], + ["华南", "W41", 700], ["华南", "W42", 666], ["西南", "W41", 100], ["西南", "W42", 100]], + "sql": "SELECT region, week, SUM(gmv) AS gmv FROM orders GROUP BY region, week ORDER BY region, week", + "source": "shop", "truncated": False, "row_count": 8, +} + + +class Store(dict): + """内容寻址的假工件库:put 落一份、交回 sha256;loader 是 artifact_store.load 的约定(取不到 None)。""" + + def put(self, obj: Any) -> str: + sha = content_hash(canonical_json(obj)) + self[sha] = obj + return sha + + def load(self, artifact: str) -> Any: + return self.get(artifact) + + +#: 运行当天(服务端时区):真算的结论记进 params.today,复算按它 +TODAY = date(2026, 10, 21) +#: 按 SNAPSHOT 真算一份按地区拆的可加拆解 +REQUEST: dict[str, Any] = { + "op": "decompose", "mode": "additive", "hypothesis": "华东拖累", + "params": {"query": "Q2", "by": ["region"], "period": "week", "base": "W41", "target": "W42", "measure": "gmv", + "label": "销售额"}} +#: 华东按渠道再拆一层(下钻):两个渠道之和 820 → 700,和 A1 里华东那一段闭合 +CHANNELS: dict[str, Any] = { + "columns": ["channel", "week", "gmv"], "column_types": {"channel": "text", "week": "text", "gmv": "number"}, + "rows": [["线上", "W41", 500], ["线上", "W42", 410], ["线下", "W41", 320], ["线下", "W42", 290]], + "sql": "SELECT channel, week, SUM(gmv) AS gmv FROM orders WHERE region = '华东' GROUP BY channel, week", + "source": "shop", "truncated": False, "row_count": 4, +} + + +def input_snapshot(content: dict[str, Any], *, ref: str, artifact: str, node_id: str = "fetch") -> dict[str, Any]: + """照分析入口(tools/analysis.input_snapshot)的写法交给 run:列的类型按列序写成列表,加上出处,遮罩列留空。复算交的是 + 工件原内容(类型是 {列: 类型}):两边算出来要逐字段相同。""" + types = content.get("column_types") or {} + return {**content, "ref": ref, "artifact": artifact, "node_id": node_id, + "column_types": [types.get(c) for c in content["columns"]], "masked": []} + + +async def analyzed(snapshots: dict[str, tuple[dict[str, Any], str]], request: dict[str, Any] | None = None, *, + parents: dict[str, Any] | None = None, hypothesis: str | None = None, + **params: Any) -> dict[str, Any]: + """app.analysis.run 真算一份结论。snapshots:{调用里写的名字: (快照内容, 工件 id)};params 盖在请求的参数上,hypothesis + 换掉请求里 agent 写的假设(不进复算的比较,用来让一份结论的内容独一份)。""" + request = request or REQUEST + by_ref = {ref: input_snapshot(content, ref=ref, artifact=sha) for ref, (content, sha) in snapshots.items()} + asked = {**request, "params": {**request["params"], **params}} + if hypothesis is not None: + asked["hypothesis"] = hypothesis + return dict(await analysis.run(asked, by_ref.get, settings=effective_settings(None), context={"today": TODAY}, + parents=(parents or {}).get)) + + +def run_sync(coro: Any) -> Any: + """在同步的地方等一个协程:没有正在跑的事件循环就直接 asyncio.run,协程测试里调用(已经在循环里)换一个线程跑。""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + with ThreadPoolExecutor(1) as pool: + return pool.submit(asyncio.run, coro).result() + + +def real_result(q: str, snapshot: dict[str, Any] | None = None, **params: Any) -> dict[str, Any]: + """同步的 analyzed:按 SNAPSHOT(工件 q)真算 REQUEST。""" + return run_sync(analyzed({"Q2": (snapshot or SNAPSHOT, q)}, **params)) + + +def setup(*, item: str | None = "drop", call_id: str | None = None, result: dict[str, Any] | None = None, + snapshot: dict[str, Any] | None = None) -> tuple[Store, list[dict[str, Any]], str, str]: + """一次查询(Q1)加一份按地区拆的分析结论(A1):(工件库, 台账, 查询快照, 分析结论)。""" + store = Store() + q = store.put(snapshot or SNAPSHOT) + res = result or fake_result(inputs=[{"ref": "Q2", "artifact": q, "role": "data", + "columns": ["region", "week", "gmv"], "rows": "all"}]) + a = store.put(res) + ledger = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop", + "source": "shop", "columns": ["region", "week", "gmv"], "rows": 8, "truncated": False}, + ledger_entry(res, node_id="an", exec_no=1, artifact=a, item=item, call_id=call_id)] + return store, ledger, q, a + + +def catalog_of(**kw: Any) -> tuple[Store, dict[str, Any]]: + store, ledger, _, _ = setup(**kw) + return store, build_catalog(nodes={}, ledger=ledger, loader=store.load) + + +def cites(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]: + return [(s["kind"], s["text"], s.get("issue")) for s in iter_segments(doc) if s.get("ref")] + + +# -------------------------------------------------------------------------- +# 契约里补的两样:叫法、按形状认分析结论 +# -------------------------------------------------------------------------- + + +def test_method_labels_and_result_shapes(): + from app.analysis.contract import OP_LABELS, OPS, is_result, method_label + + assert set(OP_LABELS) == set(OPS) + assert method_label("decompose", "additive") == "变化拆解(可加)" and method_label("baseline", None) == "波动基线" + assert method_label("differ", "rate") == "差异检验(比率)" and method_label("odd", "x") == "odd(x)" + assert is_result(fake_result()) and is_result(fake_result("trend", verdict="rising")) + assert not is_result(SNAPSHOT) and not is_result({"op": "decompose", "method": 1, "inputs": []}) + assert not is_result(None) and not is_result([fake_result()]) + + +# -------------------------------------------------------------------------- +# 写作目录的 A 条目 +# -------------------------------------------------------------------------- + + +def test_analyses_are_numbered_by_ledger_order_and_carry_the_whole_result(): + store, ledger, q, a = setup() + second = fake_result("baseline", verdict="within_band", inputs=[{"ref": "Q1", "artifact": q, "role": "data"}]) + b = store.put(second) + ledger.append(ledger_entry(second, node_id="trend_agent", exec_no=1, artifact=b, call_id="call-1")) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + + assert [k for k, e in catalog.items() if e["kind"] == "analysis"] == ["A1", "A2"] + first = catalog["A1"] + assert first["result"] is store[a] and first["item"] == "drop" and "call_id" not in first + assert first["op"] == "decompose" and first["verdict"] == "driver" and first["inputs"] == [q] and first["rows"] == 4 + assert first["label"].startswith("变化拆解(可加) · 销售额下降") + assert catalog["A2"]["call_id"] == "call-1" and catalog["A2"]["label"].startswith("波动基线 · ") + + +def test_only_analyses_from_allowed_nodes_get_a_number(): + store, ledger, q, _ = setup() + late = fake_result(inputs=[{"ref": "Q1", "artifact": q, "role": "data"}], phrase="下游的结论") + ledger.insert(0, ledger_entry(late, node_id="late", exec_no=1, artifact=store.put(late), item="x")) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load, allowed={"fetch", "an"}) + assert [k for k, e in catalog.items() if e["kind"] == "analysis"] == ["A1"] + assert catalog["A1"]["node_id"] == "an" + + +def test_unreadable_results_are_listed_with_the_reason_and_cannot_be_cited(): + store, ledger, _, a = setup() + store[a] = {**store[a], "phrase": "被改过的判定"} + + def tampered(artifact: str) -> Any: + if artifact == a: + raise ValueError("哈希不一致") + return store.get(artifact) + + catalog = build_catalog(nodes={}, ledger=ledger, loader=tampered) + assert "result" not in catalog["A1"] and catalog["A1"]["unreadable"] == "A1 的分析结论与哈希不一致,疑似被修改" + cite = resolve_ref("a:A1", catalog, loader=tampered) + assert cite["status"] == "unresolved" and cite["reason"] == "A1 的分析结论与哈希不一致,疑似被修改" + + gone = build_catalog(nodes={}, ledger=ledger, loader=lambda _: None) + assert gone["A1"]["unreadable"] == "A1 的分析结论已不存在,无法读取" + shaped = build_catalog(nodes={}, ledger=ledger, loader=lambda art: {"rows": []} if art == a else store.get(art)) + assert shaped["A1"]["unreadable"] == "A1 的工件不是分析结论" + + +def test_evidence_from_filters_analyses_by_the_node_that_made_them(): + from app.engine.nodes.report import report_catalog + from app.engine.schema import GraphSpec + + store, ledger, _, _ = setup() + + def node(nid: str, ntype: str, **config: Any) -> dict[str, Any]: + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + spec = GraphSpec.model_validate({ + "nodes": [node("fetch", "tool", tool="x"), node("an", "tool", tool="x"), + node("write", "report", evidence_from=["fetch"])], + "edges": [{"source": "fetch", "target": "an"}, {"source": "an", "target": "write"}]}) + write = spec.node_map()["write"] + state = {"nodes": {}, "evidence": ledger, "input": {}} + from unittest.mock import patch + + with patch("app.engine.evidence.load_artifact", store.load): + assert set(report_catalog(state, spec, write)) == {"Q1"} + write.config["evidence_from"] = ["fetch", "an"] + assert set(report_catalog(state, spec, write)) == {"Q1", "A1"} + write.config["evidence_from"] = "ancestors" + assert set(report_catalog(state, spec, write)) == {"Q1", "A1"} + + +# -------------------------------------------------------------------------- +# 引用的解析和渲染 +# -------------------------------------------------------------------------- + + +def test_the_judgment_phrase_is_one_system_rendered_segment_and_not_bare_numbers(): + store, catalog = catalog_of() + doc = compose_doc("[[a:A1]]。[[see:A1]]", catalog, loader=store.load) + [seg] = [s for s in iter_segments(doc) if s.get("ref")] + assert seg["kind"] == "analysis" and seg["state"] == "deterministic" + assert seg["text"] == "销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" + assert seg["cite"]["kind"] == "analysis" and seg["cite"]["role"] == "value" and seg["cite"]["locator"] == {} + # 判定语里的 194、8.7、120、62 都是系统写的:不算裸数字 + assert doc["violations"] == [] and doc["stats"]["uncited_numbers"] == 0 + assert doc["blocks"][0]["units"][0]["cites"] == ["A1"] + assert verify_doc(doc, catalog, loader=store.load)["ok"] + + +def test_named_values_render_like_cells_with_the_metric_conversions(): + store, catalog = catalog_of() + doc = compose_doc("华东占 [[a:A1.top_share|pct]],总变化 [[a:A1.delta_total]],最大的是 [[a:A1.top_key]]。", + catalog, loader=store.load) + assert cites(doc) == [("number", "61.9%", None), ("number", "-194", None), ("value", "华东", None)] + share = next(s for s in iter_segments(doc) if s.get("ref") == "a:A1.top_share|pct") + assert share["cite"]["locator"] == {"key": "top_share"} and share["cite"]["conv"] == "pct" + assert share["cite"]["value"] == pytest.approx(0.6186) + assert verify_doc(doc, catalog, loader=store.load)["ok"] + + +@pytest.mark.parametrize("ref, reason", [ + ("a:A9", "引用的分析结论 A9 不存在"), + ("a:A1.nope", "A1 中没有名为「nope」的数"), + ("a:A1|pct", "判定语是一整句话,不能换算"), + ("a:A1.top_key|pct", "「华东」不是数值,不能换算为「pct」"), + ("a:Q1", "分析结论的引用格式无法识别"), + ("a:A1.top_share.x", "分析结论的引用格式无法识别"), + ("v:A1.r9.share", "A1 请求第 9 行,但结果表只有 4 行(行号从 0 开始)"), + ("v:A1.r0.nope", "A1 没有列「nope」,可用的列:region、base、target、delta、pct、share、flag"), + ("v:A9.r0.share", "引用的分析结论 A9 不存在"), +]) +def test_wrong_analysis_references_say_why(ref, reason): + store, catalog = catalog_of() + cite = resolve_ref(ref, catalog, loader=store.load) + assert cite["status"] == "unresolved" and cite["reason"] == reason + + +def test_a_q_alias_is_not_an_analysis(): + store, catalog = catalog_of() + catalog["A2"] = dict(catalog["Q1"]) + assert resolve_ref("a:A2", catalog, loader=store.load)["reason"] == "A2 不是分析结论" + + +def test_result_table_cells_and_whole_tables_use_the_labels_as_headers(): + store, catalog = catalog_of() + doc = compose_doc("华东变化 [[v:A1.r0.delta]]。\n\n[[table:A1 cols=region,delta,share rows=0-1]]", + catalog, loader=store.load) + assert "| 地区 | 变化 | 占变化的比重 |" in doc["markdown"] + assert ("number", "-120", None) in cites(doc) and ("value", "华北", None) in cites(doc) + cell = next(s for s in iter_segments(doc) if s.get("ref") == "v:A1.r0.delta") + assert cell["cite"]["kind"] == "cell" and cell["cite"]["alias"] == "A1" + assert cell["cite"]["locator"] == {"row": 0, "column": "delta"} + assert doc["violations"] == [] + assert verify_doc(doc, catalog, loader=store.load)["ok"] + + +def test_header_labels_with_digits_are_not_bare_numbers(): + result = fake_result() + result["table"]["labels"] = {**result["table"]["labels"], "base": "基期(2026-W41)", "target": "本期(2026-W42)"} + store, ledger, q, _ = setup(result={**result, "inputs": [{"ref": "Q2", "artifact": "x" * 64, "role": "data"}]}) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + doc = compose_doc("[[table:A1 cols=region,base,target rows=0-1]]", catalog, loader=store.load) + assert "| 地区 | 基期(2026-W41) | 本期(2026-W42) |" in doc["markdown"] + assert doc["violations"] == [] and verify_doc(doc, catalog, loader=store.load)["ok"] + + +def test_dimension_values_with_digits_written_as_text_are_names(): + result = fake_result() + result["table"]["rows"][0][0] = "华东3区" + store, ledger, _, _ = setup(result=result, snapshot={**SNAPSHOT, "rows": [["华北", "W41", 1]]}) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + doc = compose_doc("下降最多的是华东3区。[[see:A1]]", catalog, loader=store.load) + assert doc["violations"] == [] + + +def test_see_accepts_analyses_rows_and_the_a_prefix(): + store, catalog = catalog_of() + doc = compose_doc("下降主要来自华东。[[see:A1]] 第一段同理。[[see:A1.r0,a:A1]] 越界。[[see:A1.r9]]", + catalog, loader=store.load) + sees = [c for _, u in __import__("app.engine.evidence", fromlist=["iter_units"]).iter_units(doc) for c in u["see"]] + assert [(c["alias"], c["status"]) for c in sees] == [("A1", "resolved"), ("A1", "resolved"), ("A1", "resolved"), + ("A1.r9", "unresolved")] + assert sees[-1]["reason"] == "A1 只有 4 行,没有第 9 行(行号从 0 开始)" + assert sees[0]["kind"] == "analysis" + + +def test_the_doc_keeps_a_summary_and_reloads_the_result_from_the_artifact(): + store, catalog = catalog_of() + doc = compose_doc("[[a:A1]],华东占 [[a:A1.top_share|pct]]。", catalog, loader=store.load) + stored = doc["catalog"]["A1"] + assert "result" not in stored and stored["artifact"] and stored["rows"] == 4 and stored["verdict"] == "driver" + # 文档里的摘要照样解析得了:结论按工件取回 + assert resolve_ref("a:A1", doc["catalog"], loader=store.load)["status"] == "resolved" + assert verify_doc(doc, doc["catalog"], loader=store.load)["ok"] + + +def test_a_tampered_phrase_in_the_doc_is_a_render_mismatch(): + store, catalog = catalog_of() + doc = compose_doc("[[a:A1]]。", catalog, loader=store.load) + seg = next(s for s in iter_segments(doc) if s.get("ref")) + seg["text"] = seg["cite"]["rendered"] = seg["text"].replace("华东", "华南") + doc["markdown"] = doc["markdown"].replace("华东", "华南") + codes = [v["code"] for v in verify_doc(doc, catalog, loader=store.load)["violations"]] + assert "render_mismatch" in codes + + +def test_streaming_renders_the_phrase_like_the_whole_text(): + store, catalog = catalog_of() + text = "结论:[[a:A1]]。华东占 [[a:A1.top_share|pct]]%。[[see:A1]]" + whole = render_markers(text, catalog, loader=store.load) + for size in (1, 3, 7): + renderer = StreamRenderer(catalog, loader=store.load) + out = "".join(renderer.feed(text[i:i + size]) for i in range(0, len(text), size)) + renderer.flush() + assert out == whole + # 具名标量后面重复写的单位去掉(同指标) + assert "61.9%。" in whole and "61.9%%" not in whole + + +# -------------------------------------------------------------------------- +# 受管级别的出具:Agent 现场做的分析和它查的格同一个规矩,分析卡做的总能引用(D10) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("ref", ["a:A1", "a:A1.top_share|pct", "v:A1.r0.share", "table:A1 cols=region,share"]) +def test_agent_analyses_follow_the_cells_rule_when_cells_are_off(ref): + store, catalog = catalog_of(item=None, call_id="call-7") + cite = resolve_ref(ref, catalog, cells_allowed=False, loader=store.load) + assert cite["status"] == "unresolved" and cite["reason"] == ANALYSIS_CELLS_REASON + assert resolve_ref(ref, catalog, cells_allowed=True, loader=store.load)["status"] == "resolved" + + +@pytest.mark.parametrize("ref", ["a:A1", "a:A1.top_share|pct", "v:A1.r0.share", "table:A1 cols=region,share"]) +def test_card_analyses_are_always_citable(ref): + store, catalog = catalog_of(item="drop") + assert resolve_ref(ref, catalog, cells_allowed=False, loader=store.load)["status"] == "resolved" + + +def test_query_cells_keep_their_reason_and_see_stays_allowed(): + store, catalog = catalog_of(item=None, call_id="call-7") + from app.engine.evidence import CELLS_REASON, resolve_support + + assert resolve_ref("v:Q1.r0.gmv", catalog, cells_allowed=False, loader=store.load)["reason"] == CELLS_REASON + assert resolve_support("A1", catalog, loader=store.load)["status"] == "resolved" + doc = compose_doc("下降主要来自华东。[[see:A1]]", catalog, cells_allowed=False, loader=store.load) + assert doc["violations"] == [] + + +# -------------------------------------------------------------------------- +# 写作目录 +# -------------------------------------------------------------------------- + + +def test_writer_catalog_lists_the_analysis_with_how_to_cite_it(): + store, catalog = catalog_of() + text = writer_catalog(catalog, loader=store.load).text + section = text[text.index("分析结论("):text.index("查询结果(")] + assert "判定语单独写 [[a:A1]]" in section and "[[see:A1]]" in section + assert "- A1:变化拆解(可加),来自 Q1" in section + assert "判定语 [[a:A1]] = 销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" in section + assert "判定标准:一段占总变化一半以上算主要来源" in section + assert "具名的数(写 [[a:A1.键]]):base_total=2,220,target_total=2,026,delta_total=-194" in section + assert "结果表(共 4 段):列 region(地区)、base(基期)" in section + assert "r0:region=华东,base=820,target=700,delta=-120" in section + assert "hypothesis" not in section and "地区拖累" not in section # agent 写的假设不进写作目录 + # 分析结论排在查询前面 + assert text.index("分析结论(") < text.index("- Q1:") + + +def test_writer_catalog_shows_only_the_top_segments_and_says_how_many_are_left(): + result = fake_result(settings={**fake_result()["settings"], "table_top_k": 3}) + store, catalog = catalog_of(result=result) + text = writer_catalog(catalog, loader=store.load).text + assert "r2:region=华南" in text and "r3:" not in text.split("查询结果(")[0] + assert "…其余 1 段(这里只列出前 3 段;后面的行也能按行号引用)" in text + + +def test_refused_results_say_why_and_warn_off_judgments(): + result = fake_result("baseline", status="refused", reason_code="history_short", + caveats=[{"code": "weekday_effect", "text": "日数据有星期效应"}]) + store, catalog = catalog_of(result=result) + text = writer_catalog(catalog, loader=store.load).text + assert "判定语 [[a:A1]] = 变化拆解没有做成:历史只有 5 期,至少要 8 期" in text + assert "算不了:历史只有 5 期,至少要 8 期。这条结论不能当「异常」「主要来自」「呈上升趋势」这类判断的依据" in text + assert "附带说明:日数据有星期效应" in text + assert "结果表(共" not in text and "具名的数(写" not in text + + +def test_agent_analyses_are_see_only_when_cells_are_off(): + store, catalog = catalog_of(item=None, call_id="call-7") + text = writer_catalog(catalog, cells_allowed=False, loader=store.load).text + assert "只能在句末写 [[see:A1]] 作依据" in text + assert "具名的数(写" not in text and "r0:region=华东" not in text + assert "这次出具不允许直接引用 Agent 现场做的分析:标着只能当依据的几条" in text + card_store, card = catalog_of(item="drop") + shown = writer_catalog(card, cells_allowed=False, loader=card_store.load).text + assert "具名的数(写" in shown and "标着只能当依据的几条" not in shown + + +def test_drill_downs_and_inputs_outside_the_catalog_are_named(): + store, ledger, q, a = setup() + child = fake_result(inputs=[{"ref": "Q9", "artifact": q, "role": "data"}, + {"ref": "A3", "artifact": a, "role": "parent"}, + {"ref": "Q8", "artifact": "f" * 64, "role": "data"}]) + ledger.append(ledger_entry(child, node_id="an", exec_no=2, artifact=store.put(child), item="drill")) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + text = writer_catalog(catalog, loader=store.load).text + assert "- A2:变化拆解(可加),来自 Q1,下钻自 A1,另有 1 份输入不在这份目录里" in text + + +def test_unreadable_results_are_listed_as_not_citable(): + store, ledger, _, a = setup() + catalog = build_catalog(nodes={}, ledger=ledger, loader=lambda art: None if art == a else store.get(art)) + assert "- A1:变化拆解(可加),来自 Q1(A1 的分析结论已不存在,无法读取,不要引用)" in \ + writer_catalog(catalog, loader=store.load).text + + +def test_trimmed_catalogs_keep_the_judgments_and_fill_result_tables_before_query_rows(): + store, ledger, q, _ = setup() + many = [{"kind": "query", "node_id": "fetch", "exec": i + 2, "artifact": store.put({**SNAPSHOT, "sql": f"-- {i}"}), + "tool": "db_query__shop", "source": "shop", "columns": SNAPSHOT["columns"], "rows": 8} for i in range(40)] + catalog = build_catalog(nodes={}, ledger=[*ledger, *many], loader=store.load) + full = writer_catalog(catalog, loader=store.load, budget=10 ** 6) + assert not full.trimmed + trimmed = writer_catalog(catalog, loader=store.load, budget=len(full.text) // 3) + assert trimmed.trimmed and len(trimmed.text) <= trimmed.budget + assert "判定语 [[a:A1]] = " in trimmed.text and "r0:region=华东,base=820" in trimmed.text + assert trimmed.omitted # 查询有省略,分析结论的结果表没被省掉 + + # 更紧:判定、判定标准、具名的数照样列出,结果表退成一句,开头照实说 + tight = writer_catalog(catalog, loader=store.load, budget=2000) + assert len(tight.text) <= 2000 and "判定语 [[a:A1]] = " in tight.text and "具名的数(写" in tight.text + assert "r0:region=华东,base=820" not in tight.text + assert "结果表共 4 段,列 region(地区)" in tight.text and "要列出就单独一行写 [[table:A1]]" in tight.text + assert "另有 1 条分析结论,判定和具名的数都列出了,其中 1 条没列出结果表的各段" in tight.text + + +def test_metric_comparability_is_spoken_in_the_writer_catalog(): + card = {"kind": "metric_set", "caliber": "周报", "caliber_version": "v1", "artifact": "c" * 64, "metrics": [ + {"id": "wow", "name": "环比", "value": -0.087, "unit": "", "comparability": { + "artifact": "d" * 64, "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", + "reasons": ["本期只有 3 天数据"]}}, + {"id": "yoy", "name": "同比", "value": 0.02, "unit": "", "comparability": { + "artifact": "e" * 64, "verdict": "conditional", "phrase": "两期有条件可比:两段的周末天数不同", + "reasons": ["两段的周末天数不同:4 天对 6 天"]}}, + {"id": "gmv", "name": "销售额", "value": 2026, "unit": "", "comparability": { + "artifact": "f" * 64, "verdict": "comparable", "phrase": "两期可比", "reasons": []}}]} + catalog = build_catalog(nodes={"card": card}, ledger=[]) + assert catalog["m:wow"]["comparability"]["verdict"] == "incomparable" + text = writer_catalog(catalog).text + assert "环比 = -0.087(两期不可比:本期只有 3 天数据。这个比较不能当结论,引用时须如实说明两期不可比)" in text + assert "同比 = 0.02(两期有条件可比:两段的周末天数不同:4 天对 6 天。引用时须如实说明这几点)" in text + assert "- [[m:gmv]] 销售额 = 2,026\n" in text + "\n" + + +def test_sql_check_problems_of_the_input_queries_follow_the_analysis(): + bad = {**SNAPSHOT, "checks": [{"code": "fanout_sum", "level": "error", + "message": "一对多关联后求和,金额被重复计算。改成先汇总再关联"}]} + store, ledger, q, a = setup(snapshot=bad) + found = query_problems(a, loader=store.load) + assert [p.artifact for p in found] == [q] and found[0].problems == ["一对多关联后求和,金额被重复计算"] + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + text = writer_catalog(catalog, loader=store.load).text + section = text[text.index("- A1:"):text.index("查询结果(")] + assert "(这次分析的输入查询未通过 SQL 检查:一对多关联后求和,金额被重复计算。其中的数存疑" in section + + +# -------------------------------------------------------------------------- +# 复算与代入式 +# -------------------------------------------------------------------------- + + +def test_recheck_with_the_real_operators_says_a_sealed_result_holds(): + """真算的结论按封存的输入复算(load 交工件原内容),逐字段相同;agent 写的假设不比。""" + store = Store() + q = store.put(SNAPSHOT) + sealed = real_result(q) + assert sealed["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)" + out = analysis_recheck(sealed, store.get) + assert out == {"status": "same", "method": "decompose.additive@1", "text": "复算一致:按封存的输入重新计算,结论逐项相同"} + assert analysis_recheck({**sealed, "hypothesis": "别的假设"}, store.get)["status"] == "same" + + +def test_recheck_follows_the_parent_of_a_drill_down_by_hash(): + store = Store() + q, c = store.put(SNAPSHOT), store.put(CHANNELS) + top = real_result(q) + a = store.put(top) + child = asyncio.run(analyzed({"Q3": (CHANNELS, c)}, parents={"A1": top}, query="Q3", by=["channel"], + parent={"of": "A1", "key": ["华东"]})) + assert [i["role"] for i in child["inputs"]] == ["parent", "data"] and child["inputs"][0]["artifact"] == a + assert analysis_recheck(child, store.get, load_result=store.get)["status"] == "same" + # 上一层取不回:照实说取不回哪一份,不说两遍「复算」 + out = analysis_recheck(child, store.get, load_result=lambda sha: None) + assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回上一层的分析结论 A1"} + + +def test_recheck_says_what_differs_and_what_is_missing(): + store = Store() + q = store.put(SNAPSHOT) + sealed = real_result(q) + # 封存的那份和按输入算出来的不一样(判定语被改过、哈希也就是另一份):点名哪几项不同 + out = analysis_recheck({**sealed, "phrase": "销售额下降,主要来自华南"}, store.get) + assert out["status"] == "different" and out["fields"] == ["phrase"] + assert out["text"] == "复算不一致:判定语和封存的不同" + # 输入快照取不回(不在封存范围、哈希对不上的调用方都交 None) + out = analysis_recheck(sealed, lambda sha: None) + assert out == {"status": "failed", "method": "decompose.additive@1", "text": "复算失败:取不回 Q2 的查询快照"} + # 输入快照换了一份(同一个 sha256 交回了别的内容):复算照样算,结果和封存的不同 + other = {**SNAPSHOT, "rows": [["华东", "W41", 820], ["华东", "W42", 650], *SNAPSHOT["rows"][2:]]} + assert analysis_recheck(sealed, lambda sha: other)["status"] == "different" + + +def test_recheck_names_both_versions_when_the_method_was_upgraded(monkeypatch): + store = Store() + q = store.put(SNAPSHOT) + sealed = real_result(q) + monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2") + out = analysis_recheck(sealed, store.get) + assert out == {"status": "same", "method": "decompose.additive@1", "method_now": "decompose.additive@2", + "text": "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同"} + out = analysis_recheck({**sealed, "phrase": "别的判定"}, store.get) + assert out["status"] == "different" and out["fields"] == ["phrase"] + assert out["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同" + + def refuse(result, load, *, load_result=None): + raise CallError("unknown_mode", "变化拆解要写模式:additive / ratio / factor") + + monkeypatch.setattr(analysis, "recompute", refuse) + out = analysis_recheck(sealed, store.get) + assert out["status"] == "failed" and out["method_now"] == "decompose.additive@2" + assert out["text"] == ("运行时用的是 decompose.additive@1,现在是 decompose.additive@2:现在的方法按封存的输入复算不了" + "(变化拆解要写模式:additive / ratio / factor)") + + +def test_recheck_errors_are_said_plainly(monkeypatch): + store = Store() + q = store.put(SNAPSHOT) + sealed = real_result(q) + seen: list[Any] = [] + + def broken(result, load, *, load_result=None): + seen.append(load(q)) + raise ZeroDivisionError("division by zero") + + monkeypatch.setattr(analysis, "recompute", broken) + out = analysis_recheck(sealed, store.get) + assert out["status"] == "failed" and out["text"].startswith("复算失败:") and seen == [SNAPSHOT] + monkeypatch.setattr(analysis, "recompute", lambda result, load, *, load_result=None: None) + assert analysis_recheck(sealed, store.get)["text"] == "复算失败:复算没有交回结论" + + +def test_formulas_substitute_the_key_numbers(): + store = Store() + q = store.put(SNAPSHOT) + formulas = {f["label"]: f["text"] for f in analysis_formulas(real_result(q))} + assert formulas == { + "总变化": "总变化 = 本期合计 − 基期合计 = 2,026 − 2,220 = -194", + "变化率": "变化率 = 总变化 ÷ 基期合计 = -194 ÷ 2,220 = -8.7%", + "占比": "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%", + } + assert analysis_formulas(fake_result(status="refused", reason_code="history_short")) == [] + assert analysis_formulas(fake_result(values={})) == [] + + +def test_formulas_for_factor_ratio_and_baseline(): + store = Store() + factor_snap = {"columns": ["week", "gmv", "orders", "aov"], + "rows": [["W41", 1000, 10, 100], ["W42", 1320, 12, 110]], + "sql": "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov " + "FROM orders GROUP BY week", "source": "shop", "truncated": False} + factor = asyncio.run(analyzed({"Q1": (factor_snap, store.put(factor_snap))}, + {"op": "decompose", "mode": "factor", "params": {}}, query="Q1", period="week", + base="W41", target="W42", product="gmv", factors=["orders", "aov"], label="销售额")) + texts = {f["label"]: f["text"] for f in analysis_formulas(factor)} + assert texts["总变化"] == "总变化 = 本期合计 − 基期合计 = 1,320 − 1,000 = 320" + assert texts["占比"].startswith("orders的占比 = orders的贡献 ÷ 总变化 = 210.1449167") and texts["占比"].endswith(" = 65.7%") + + ratio_snap = {"columns": ["channel", "week", "refunds", "orders"], + "rows": [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], + ["线下", "W42", 26, 1000]], + "sql": "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders " + "GROUP BY 1, 2", "source": "shop", "truncated": False} + ratio = asyncio.run(analyzed({"Q1": (ratio_snap, store.put(ratio_snap))}, + {"op": "decompose", "mode": "ratio", "params": {}}, query="Q1", by="channel", + period="week", base="W41", target="W42", numerator="refunds", denominator="orders", + label="退款率")) + assert [f["text"] for f in analysis_formulas(ratio)] == [ + "结构效应占比 = 结构效应合计 ÷ 比率的变化 = 0 ÷ 0.018 = 0.0%", + "自身效应占比 = 自身效应合计 ÷ 比率的变化 = 0.018 ÷ 0.018 = 100.0%"] + + weeks = [f"2026-{m:02d}-{d:02d}" for m, d in ((6, 8), (6, 15), (6, 22), (6, 29), (7, 6), (7, 13), (7, 20), (7, 27), + (8, 3), (8, 10))] + series = {"columns": ["week", "orders"], "column_types": {"week": "text", "orders": "number"}, + "rows": [[w, v] for w, v in zip(weeks, [100, 102, 98, 101, 99, 103, 100, 97, 102, 130])], + "sql": "SELECT week, COUNT(*) AS orders FROM orders GROUP BY 1", "source": "shop", "truncated": False} + baseline = asyncio.run(analyzed({"Q1": (series, store.put(series))}, {"op": "baseline", "mode": None, "params": {}}, + query="Q1", period="week", measure="orders", label="订单数")) + assert baseline["params"]["basis"] == "change" and baseline["status"] == "ok" + band, z = [f["text"] for f in analysis_formulas(baseline)] + assert band == "常规波动范围 = 中位数 ± 2 倍稳健标准差 = -9.0% 至 9.0%(中位数 0.01%,过去 8 期)" + assert z == "稳健 z = (本期 − 中位数)÷ 稳健标准差 = (27.5% − 0.01%)÷ 稳健标准差 = 6.1072657902" + assert analysis_recheck(baseline, store.get)["status"] == "same" + + +# -------------------------------------------------------------------------- +# 血缘(D16):分析卡的结果不是口径卡的原料 +# -------------------------------------------------------------------------- + + +def test_metrics_reading_analysis_results_are_not_traced_to_raw_data(): + """口径卡读 nodes.<分析卡>.results 的输入:运行时(traced)、静态(static_stops)都认作追不到原始数据,停在分析卡。 + 分析结论的输入只认台账里的查询快照,复算即血缘;写死 tool_cell、agent_field 的几处不用改。""" + from app.engine.lineage import static_stops, traced, untraced + + item = {"path": "nodes.an.results.drop.values.top_share", "value": "0.6186", "node_id": "an", "via": "analysis"} + assert not traced(item) and untraced([item]) == [item] + stops = static_stops({"an": {"type": "analysis", "config": {}}, "fetch": {"type": "tool", "config": {}}}, + "nodes.an.results.drop.values.top_share * 100") + assert [(s.path, s.node_id) for s in stops] == [("nodes.an.results.drop.values.top_share", "an")] diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py new file mode 100644 index 00000000..3c454dc9 --- /dev/null +++ b/backend/tests/test_analysis_evidence_api.py @@ -0,0 +1,439 @@ +"""分析结论进证据链(第 1 波 W1-b):证据接口、审计、报告导出。 + +运行是直接落库的虚构封存运行(虚构零售库 shop):一次查询(Q1)、一份按地区拆的分析结论(A1,分析卡或 Agent 做的), +报告引用判定语、具名标量、结果表的格。结论用 app.analysis 真算(tests.test_analysis_evidence.analyzed),出处链里的复算是 +真的 recompute:按 sha256 取回封存的输入快照、下钻的上一层结论,逐字段比。只有要它出错、升版的几处用 monkeypatch。 + +- 证据图:A1 条目带算子、判定;「派生自」的边 A1 → Q1 +- 片段接口:判定 → 方法 → 计算 → 输入(接查询步骤)→ 复算 → 封存;Agent 写的假设标出来;复算不了照实写,不让接口出错 +- 审计:分析判定、分析结论的叫法;分析说法核对的违规单列一行 +- 报告导出:数据块里有分析结论那几步;文档带「分析范围」的,正文后面渲染、编号点得开 +- 指标步骤带可比性:口径卡指标上的可比性结论(不进台账)照卡上记的 sha256 取回,点得开(判定 → … → 复算 → 封存) +""" +from __future__ import annotations + +import csv +import io +import json +import re +import uuid +from collections.abc import Sequence +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +from app import analysis +from app.analysis import contract +from app.analysis.contract import ARTIFACT_KIND, ledger_entry +from app.core import artifact_store +from app.core.artifact_store import manifest_hash +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.evidence import build_catalog, compose_doc, iter_segments +from app.main import app +from tests.test_analysis_evidence import CHANNELS, SNAPSHOT, analyzed + +GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [ + ("fetch", "tool", "按地区取数"), ("an", "agent", "分析"), ("card", "metrics", "周报口径"), + ("write", "report", "写周报")]]} + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def _query_entry(content: dict[str, Any], artifact: str) -> dict[str, Any]: + return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "tool": "db_query__shop", + "source": "shop", "columns": content["columns"], "rows": len(content["rows"]), "truncated": False} + + +async def sealed_run(text: str, *, item: str | None = "drop", call_id: str | None = None, + result: dict[str, Any] | None = None, doc_extra: dict[str, Any] | None = None, + card: dict[str, Any] | None = None, queries: Sequence[dict[str, Any]] = (), + parents: Sequence[dict[str, Any]] = ()) -> dict[str, Any]: + """落一次封存好的运行:查询快照(SNAPSHOT,再加 queries)、分析结论(parents 在前,result 在后;result 缺省按 SNAPSHOT + 真算一份按地区拆的可加拆解)、(可选)口径卡、报告文档都按真实的工件写,事件按 runner 的算法封存。""" + run_id = uuid.uuid4().hex + q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot", run_id=run_id, node_id="fetch") + more = [await artifact_store.put_json(c, kind="query_snapshot", run_id=run_id, node_id="fetch") for c in queries] + res = result or await analyzed({"Q2": (SNAPSHOT, q)}) + qentries = [_query_entry(SNAPSHOT, q), *(_query_entry(c, a) for c, a in zip(queries, more))] + aentries = [] + for i, parent in enumerate(parents): + found = await artifact_store.put_json(parent, kind=ARTIFACT_KIND, run_id=run_id, node_id="an") + aentries.append(ledger_entry(parent, node_id="an", exec_no=1, artifact=found, item=f"up{i}")) + a = await artifact_store.put_json(res, kind=ARTIFACT_KIND, run_id=run_id, node_id="an") + aentries.append(ledger_entry(res, node_id="an", exec_no=1, artifact=a, item=item, call_id=call_id)) + nodes: dict[str, Any] = {} + events: list[tuple[int, str, str | None, dict[str, Any]]] = [ + (1, "run.started", None, {"input": {}}), (2, "node.finished", "fetch", {"evidence": qentries}), + (3, "node.finished", "an", {"evidence": aentries})] + if card is not None: + card_id = await artifact_store.put_json(card, kind="metric_set", run_id=run_id, node_id="card") + nodes["card"] = {**card, "artifact": card_id} + events.append((4, "node.finished", "card", {"evidence": [{"kind": "metric_set", "node_id": "card", + "exec": 1, "artifact": card_id}]})) + catalog = build_catalog(nodes=nodes, ledger=[*qentries, *aentries]) + doc = compose_doc(text, catalog, node_id="write", run_id=run_id) + doc.update(doc_extra or {}) + doc_id = await artifact_store.put_json(doc, kind="report_doc", run_id=run_id, node_id="write") + seq = len(events) + events += [(seq + 1, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}), + (seq + 2, "run.finished", None, {"output": {}})] + async with SessionLocal() as session: + session.add(Run(id=run_id, workflow_name="分析周报", status="succeeded", run_class="exploratory", + graph=GRAPH, agent_limits={}, manifest_hash=manifest_hash(events), manifest_seq=events[-1][0])) + await session.flush() + for s, etype, node, data in events: + session.add(RunEvent(run_id=run_id, seq=s, type=etype, node_id=node, ts=float(s), data=data)) + await session.commit() + return {"run": run_id, "doc": doc, "query": q, "analysis": a, "result": res, "queries": more} + + +def seg_id(doc: dict[str, Any], ref: str) -> str: + return next(s["id"] for s in iter_segments(doc) if s.get("ref") == ref) + + +TEXT = "[[a:A1]]。华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]]。[[see:A1]]" + + +# -------------------------------------------------------------------------- +# 证据图 +# -------------------------------------------------------------------------- + + +async def test_the_graph_lists_the_analysis_and_what_it_was_derived_from(client): + made = await sealed_run(TEXT) + body = (await client.get(f"/api/runs/{made['run']}/evidence")).json() + assert body["mode"] == "cited" + item = next(e for e in body["evidence"] if e["alias"] == "A1") + assert item["kind"] == "analysis" and item["op"] == "decompose" and item["verdict"] == "driver" + assert item["item"] == "drop" and item["sealed"] is True and item["artifact"] == made["analysis"] + assert item["label"].startswith("变化拆解(可加) · ") + edges = {(e["from"], e["to"], e["rel"]) for e in body["edges"]} + assert ("A1", "Q1", "derived_from") in edges + assert ("A1.r0.delta", "A1", "cell_of") in edges + + +# -------------------------------------------------------------------------- +# 片段接口:出处链 +# -------------------------------------------------------------------------- + + +async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and_seal(client): + made = await sealed_run(TEXT) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + chain = body["chain"] + assert [s["step"] for s in chain] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query", + "analysis_recompute", "analysis_seal"] + judged, method, calc, given, query, again, seal = chain + assert judged["phrase"] == made["result"]["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)" + assert judged["criterion"].startswith("一段占总变化的一半以上算主要来源") + assert judged["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"} + assert judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True and judged["eid_ok"] is True + assert judged["origin"] == "card" and judged["item"] == "drop" and judged["label"] == "分析" + assert judged["hypothesis"] == "华东拖累" and judged["hypothesis_note"].startswith("Agent 写的假设") + assert {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis"} + assert method["method"] == "decompose.additive@1" and method["method_label"] == "变化拆解(可加)" + assert {s["key"]: s["label"] for s in method["settings"]}["main_share"] == "「主要来源」的占比" + assert {s["key"] for s in method["settings"]} >= {"main_share", "concentration", "table_top_k"} + assert "k_warn" not in {s["key"] for s in method["settings"]} + assert calc["table"]["columns"][0] == "region" and calc["table"]["labels"]["share"] == "占变化的比重" + assert calc["table"]["total_rows"] == 4 and calc["table"]["rows"][0][:2] == ["华东", "820"] + assert {f["label"]: f["text"] for f in calc["formulas"]}["占比"] == \ + "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%" + assert given == {"step": "analysis_input", "of": "A1", "ref": "Q2", "role": "data", "role_label": "数据", + "artifact": made["query"], "columns": ["region", "week", "gmv"], "rows": "all", "query": "Q1", + "node_id": "fetch", "sealed": True} + assert query["alias"] == "Q1" and query["input_of"] == "A1" and query["total_rows"] == 8 + assert query["highlight"]["cols"] == ["region", "week", "gmv"] + # 真的复算:按 sha256 取回封存的输入快照,逐字段和封存的比 + assert again == {"step": "analysis_recompute", "alias": "A1", "status": "same", "method": "decompose.additive@1", + "text": "复算一致:按封存的输入重新计算,结论逐项相同"} + assert seal["sealed"] is True and seal["ledger"] is True and seal["hash_ok"] is True + assert body["seal"]["covered"] is True + assert body["note"].startswith("判定语由系统按平台的方法算出判定") + assert body["segment"]["kind"] == "analysis" + + +async def test_named_values_and_result_cells_point_at_what_they_cite(client): + made = await sealed_run(TEXT) + value = (await client.get( + f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1.top_share|pct')}")).json() + assert value["chain"][0]["cited"] == {"what": "value", "key": "top_share", "raw": "0.618556701031", "conv": "pct"} + assert value["segment"]["text"] == "61.9%" and value["chain"][0]["render_ok"] is True + calc = value["chain"][2] + assert next(v for v in calc["values"] if v["key"] == "top_share") == { + "key": "top_share", "raw": "0.618556701031", "rendered": "0.618556701", "cited": True} + assert value["chain"][-2]["text"] == "复算一致:按封存的输入重新计算,结论逐项相同" + assert value["note"] == "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写" + + cell = (await client.get( + f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'v:A1.r0.delta')}")).json() + assert cell["chain"][0]["cited"] == {"what": "cell", "row": 0, "column": "delta", "raw": "-120"} + assert cell["chain"][2]["table"]["highlight"] == {"cells": [[0, "delta"]]} + assert cell["note"] == "该值由系统从分析结论的结果表中取出、按固定规则显示,未经模型转写" + + +async def test_the_recompute_gets_the_sealed_inputs_by_hash(client, monkeypatch): + """复算的 load 交工件原内容(不加出处、不改列的类型),只认封存范围内交回过的查询;别的一律当取不回。""" + seen: dict[str, Any] = {} + real = analysis.recompute + + def recompute(result, load, *, load_result=None): + seen["input"] = load(result["inputs"][0]["artifact"]) + seen["missing"] = load("0" * 64) + return real(result, load, load_result=load_result) + + monkeypatch.setattr(analysis, "recompute", recompute) + made = await sealed_run(TEXT) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") + assert again["status"] == "same" and seen["input"] == SNAPSHOT and seen["missing"] is None + + +async def test_an_input_outside_the_sealed_scope_is_not_recomputed_from(client): + """结论记着的输入快照不是这次运行交回过的(台账里没有):复算照实说取不回,不去工件库里捞。""" + stray = {**SNAPSHOT, "sql": SNAPSHOT["sql"] + " -- 另一次"} + s_id = await artifact_store.put_json(stray, kind="query_snapshot") + made = await sealed_run(TEXT, result=await analyzed({"Q2": (stray, s_id)})) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") + assert again["status"] == "failed" and again["text"] == "复算失败:取不回 Q2 的查询快照" + given = next(s for s in body["chain"] if s["step"] == "analysis_input") + assert given["sealed"] is False and given["query"] is None + + +async def test_an_upgraded_method_names_both_versions(client, monkeypatch): + made = await sealed_run(TEXT) + monkeypatch.setitem(contract.METHODS, ("decompose", "additive"), "decompose.additive@2") + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") + assert again["method"] == "decompose.additive@1" and again["method_now"] == "decompose.additive@2" + assert again["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果相同" + + +async def test_recompute_errors_do_not_break_the_panel(client, monkeypatch): + def broken(result, load, *, load_result=None): + raise ZeroDivisionError("division by zero") + + monkeypatch.setattr(analysis, "recompute", broken) + made = await sealed_run(TEXT) + response = await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}") + assert response.status_code == 200 + again = next(s for s in response.json()["chain"] if s["step"] == "analysis_recompute") + assert again["status"] == "failed" and again["text"].startswith("复算失败:") + + +async def test_a_tampered_result_is_said_and_not_shown(client): + # 工件按内容寻址:改的是这一次独有的那份结论(别的测试的结论内容相同、是同一个文件) + q = await artifact_store.put_json(SNAPSHOT, kind="query_snapshot") + own = await analyzed({"Q2": (SNAPSHOT, q)}, hypothesis=f"只给这一次:{uuid.uuid4().hex}") + assert own["hypothesis"].startswith("只给这一次:") + made = await sealed_run(TEXT, result=own) + path = artifact_store._path_of(made["analysis"]) + path.write_text(path.read_text("utf-8").replace("华东", "华南"), "utf-8") + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + judged, seal = body["chain"] + assert judged["note"] == "分析结论与哈希不一致,疑似被修改,不展示其中的判定和数" and "phrase" not in judged + assert seal["hash_ok"] is False and seal["sealed"] is False and body["seal"]["covered"] is False + + +async def test_agent_analyses_are_marked_as_such(client): + made = await sealed_run(TEXT, item=None, call_id="call-9") + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + assert body["chain"][0]["origin"] == "agent" and body["chain"][0]["call_id"] == "call-9" + + +async def test_headline_inputs_show_the_closure(client): + """给了总数(headline)的拆解:两格各是一份输入,和结论里的总数闭合;复算照样按 sha256 取回两份快照。""" + totals = {"columns": ["gmv_base", "gmv"], "column_types": {"gmv_base": "number", "gmv": "number"}, + "rows": [[2220, 2026]], "sql": "SELECT 2220 AS gmv_base, 2026 AS gmv", "source": "shop", + "truncated": False} + t = artifact_store.content_hash(artifact_store.canonical_json(totals)) + q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT)) + result = await analyzed({"Q2": (SNAPSHOT, q), "Q5": (totals, t)}, headline=["Q5.r0.gmv_base", "Q5.r0.gmv"]) + made = await sealed_run("[[a:A1]]。", result=result, queries=[totals]) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + inputs = [s for s in body["chain"] if s["step"] == "analysis_input"] + assert [s["role"] for s in inputs] == ["headline_base", "headline_target", "data"] + assert inputs[0]["closure"] == {"total_key": "base_total", "total": "2,220", "headline": "2,220", "closed": True} + assert inputs[1]["closure"] == {"total_key": "target_total", "total": "2,026", "headline": "2,026", "closed": True} + assert inputs[1]["query"] == "Q2" and inputs[1]["sealed"] is True and inputs[2]["query"] == "Q1" + assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same" + + +async def test_a_drill_down_points_at_its_parent_and_recomputes_from_it(client): + """下钻(华东按渠道再拆):输入里有上一层结论(parent),落到报告目录的 A1;复算按 sha256 取回台账记过的上一层。""" + q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT)) + c = artifact_store.content_hash(artifact_store.canonical_json(CHANNELS)) + top = await analyzed({"Q2": (SNAPSHOT, q)}) + child = await analyzed({"Q3": (CHANNELS, c)}, parents={"A1": top}, query="Q3", by=["channel"], + parent={"of": "A1", "key": ["华东"]}) + made = await sealed_run("[[a:A2]]。", result=child, parents=[top], queries=[CHANNELS]) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A2')}")).json() + inputs = [s for s in body["chain"] if s["step"] == "analysis_input"] + assert [s["role"] for s in inputs] == ["parent", "data"] + assert inputs[0]["alias"] == "A1" and inputs[0]["phrase"] == top["phrase"] and inputs[0]["sealed"] is True + assert inputs[0]["role_label"] == "上一层拆解" and inputs[1]["query"] == "Q2" + again = next(s for s in body["chain"] if s["step"] == "analysis_recompute") + assert again["status"] == "same", again + graph = (await client.get(f"/api/runs/{made['run']}/evidence")).json() + edges = {(e["from"], e["to"], e["rel"]) for e in graph["edges"]} + assert {("A2", "A1", "derived_from"), ("A2", "Q2", "derived_from"), ("A1", "Q1", "derived_from")} <= edges + + +#: 本周、上周两份同形查询(只差日期):口径卡跨这两份算环比时自动做可比性检查 +WINDOW = "SELECT SUM(gmv) AS gmv FROM orders WHERE order_date >= '{a}' AND order_date < '{b}'" +LAST_WEEK = {"columns": ["gmv"], "column_types": {"gmv": "number"}, "rows": [[2220]], + "sql": WINDOW.format(a="2026-10-05", b="2026-10-12"), "source": "shop", "truncated": False} +THIS_WEEK = {**LAST_WEEK, "rows": [[2026]], "sql": WINDOW.format(a="2026-10-12", b="2026-10-19")} + + +async def comparability_card() -> tuple[dict[str, Any], dict[str, Any]]: + """一张跨两周算环比的口径卡,指标上记着口径卡执行时自动做的可比性检查(结论落成工件,不进台账):(卡, 可比性结论)。""" + last = artifact_store.content_hash(artifact_store.canonical_json(LAST_WEEK)) + this = artifact_store.content_hash(artifact_store.canonical_json(THIS_WEEK)) + result = await analyzed({"fetch·base": (LAST_WEEK, last), "fetch·target": (THIS_WEEK, this)}, + {"op": "comparable", "mode": None, "params": {}}, + base_query="fetch·base", target_query="fetch·target") + found = await artifact_store.put_json(result, kind=ARTIFACT_KIND, node_id="card") + verdict = result["verdict"]["code"] + card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [ + {"id": "wow", "name": "环比", "value": -0.0874, "unit": "", + "expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'gmv') - 1", + "substituted": "2026 / 2220 - 1", "recompute_ok": True, + "inputs": [{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2026, "node_id": "fetch", "via": "tool_cell", + "artifact": this, "locator": {"row": 0, "column": "gmv"}}, + {"path": "cell(nodes.fetch, 0, 'gmv')", "value": 2220, "node_id": "fetch", "via": "tool_cell", + "artifact": last, "locator": {"row": 0, "column": "gmv"}}], + "comparability": {"artifact": found, "verdict": verdict, "phrase": result["phrase"], "reasons": []}}]} + return card, result + + +async def test_the_metric_step_opens_the_comparability_result(client): + """可比性结论不进台账、只从口径卡的指标集里引用:照卡上记的 sha256 取回,随卡封存;点得开它的整条出处链,复算是真的。""" + card, result = await comparability_card() + made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK]) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'm:wow')}")).json() + view = body["chain"][0]["comparability"] + assert view["verdict"] == result["verdict"]["code"] and view["phrase"] == result["phrase"] + assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@1" + steps = view["steps"] + assert [s["step"] for s in steps] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", + "analysis_input", "analysis_recompute", "analysis_seal"] + judged, method, calc, base, target, again, seal = steps + assert judged["origin"] == "metric" and judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True + assert judged["method_label"] == "可比性检查" and judged["node_id"] == "card" + assert [r[0] for r in calc["table"]["rows"]] == ["同口径", "窗口长度", "整期", "星期构成", "数据问题"] + assert (base["role"], target["role"]) == ("base", "target") + assert base["query"] == "Q2" and target["query"] == "Q3" and base["sealed"] is True + assert again["status"] == "same" and again["text"] == "复算一致:按封存的输入重新计算,结论逐项相同" + assert seal["via"] == "metric_set" and seal["ledger"] is False and seal["sealed"] is True + assert seal["note"].startswith("这份可比性结论不进台账") + # 两份输入快照的查询步骤在指标链里(指标的输入就是这两份),可比性的链里不再给 + assert [s["step"] for s in body["chain"]].count("query") == 2 and "query" not in [s["step"] for s in steps] + + +async def test_a_card_without_the_comparability_artifact_says_only_what_it_recorded(client): + card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "metrics": [ + {"id": "wow", "name": "环比", "value": -0.087, "unit": "", "expression": "1", "inputs": [], + "comparability": {"artifact": "", "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", + "reasons": ["本期只有 3 天数据"]}}]} + made = await sealed_run("环比 [[m:wow]]。", card=card) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'm:wow')}")).json() + assert body["chain"][0]["comparability"] == { + "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", "reasons": ["本期只有 3 天数据"], + "artifact": None, "hash_ok": None, "sealed": False} + + +async def test_the_app_metric_endpoint_opens_the_comparability_too(client): + card, _ = await comparability_card() + made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK]) + body = (await client.get(f"/api/runs/{made['run']}/evidence/metrics/card/wow")).json() + view = body["chain"][0]["comparability"] + assert view["sealed"] is True and view["steps"][-2]["status"] == "same" + assert [s["query"] for s in view["steps"] if s["step"] == "analysis_input"] == [None, None] + + +# -------------------------------------------------------------------------- +# 审计 +# -------------------------------------------------------------------------- + + +async def test_the_audit_names_analysis_evidence_and_lists_claim_violations(client): + violation = {"code": "analysis_claim_mismatch", "message": "A1 判的是变化主要来自华东,不能写「主要来自华南」", + "segment": "s0", "unit": "u0", "span": [0, 4], "text": "主要来自"} + made = await sealed_run(TEXT, doc_extra={"violations": [violation]}) + body = (await client.get(f"/api/runs/{made['run']}/evidence/audit")).json() + rows = [r for g in body["groups"] for r in g["rows"]] + phrase = next(r for r in rows if r.get("ref") == "a:A1") + assert phrase["kind"] == "analysis" and phrase["evidence_kind"] == "analysis" and phrase["sealed"] is True + assert phrase["evidence"].startswith("变化拆解(可加) · ") + value = next(r for r in rows if r.get("ref") == "a:A1.top_share|pct") + assert value["evidence"] == "A1.top_share" and value["kind"] == "number" + cell = next(r for r in rows if r.get("ref") == "v:A1.r0.delta") + assert cell["evidence"] == "A1.r0.delta" and cell["evidence_kind"] == "analysis" and cell["sealed"] is True + flagged = next(r for r in rows if r.get("issue") == "analysis_claim_mismatch") + assert flagged["group"] == "none" and flagged["kind"] == "violation" + + text = (await client.get(f"/api/runs/{made['run']}/evidence/audit?format=csv")).text + table = list(csv.reader(io.StringIO(text.lstrip("")))) + head = table[0] + lines = [dict(zip(head, line)) for line in table[1:]] + assert any(r["种类"] == "分析判定" and r["证据种类"] == "分析结论" for r in lines) + assert any(r["问题"] == "判断与分析结论不符" for r in lines) + + +def test_audit_labels_cover_the_new_violation_codes(): + from app.api.evidence import _CSV_LABELS + + issues = _CSV_LABELS["issue"] + assert issues["analysis_claim_uncited"] == "判断词未挂分析结论" + assert issues["analysis_claim_mismatch"] == "判断与分析结论不符" + assert issues["causal_unsupported"] == "因果说法没有依据" + assert issues["inline_see"] == "依据写在句子中间" and issues["absence_claim"] == "缺失断言" + + +# -------------------------------------------------------------------------- +# 报告导出 +# -------------------------------------------------------------------------- + +SCOPE = {"analysis_scope": { + "phenomena": [{"alias": "A1", "op": "decompose", "phrase": "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"}], + "decomposed": [{"alias": "A1", "op": "decompose", "mode": "additive", "dims": ["地区"], "verdict": "driver", + "phrase": "主要来自华东"}], + "excluded": [], "refused": [{"alias": "A1", "op": "baseline", "reason": "历史只有 5 期,至少要 8 期"}], + "not_decomposed": ["渠道"], "tests": 2}} + + +def data_block(html: str) -> dict[str, Any]: + raw = re.search(r'', html, re.S).group(1) + return json.loads(raw) + + +async def test_the_html_export_carries_the_analysis_chain_and_the_scope(client): + made = await sealed_run(TEXT, doc_extra=SCOPE) + response = await client.get(f"/api/runs/{made['run']}/report.html") + assert response.status_code == 200 + html = response.text + data = data_block(html) + phrase = seg_id(made["doc"], "a:A1") + chain = data["chains"][data["refs"][phrase]] + assert [s["step"] for s in chain][:3] == ["analysis", "analysis_method", "analysis_calc"] + assert data["notes"][phrase].startswith("判定语由系统按平台的方法算出判定") + # 分析范围:正文后面渲染,编号点得开、和点开判定语是同一条链 + assert '
现象" in html and "
拆过
" in html + assert "
算不了
" in html and "
渠道
" in html and "这次做了 2 次差异检验" in html + assert 'data-seg="scope:A1"' in html and "(按地区)" in html + assert data["refs"]["scope:A1"] == data["refs"][phrase] + assert f'data-seg="{phrase}"' in html and 'data-kind="analysis"' in html + + +async def test_docs_without_a_scope_render_no_scope(client): + made = await sealed_run(TEXT) + html = (await client.get(f"/api/runs/{made['run']}/report.html")).text + assert '
tuple[Any, dict[str, Any], dict[str, Any]]: + store, ledger, _, _ = setup(**kw) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + return store, catalog, compose_doc(text, catalog, loader=store.load) + + +# -------------------------------------------------------------------------- +# 裁判 +# -------------------------------------------------------------------------- + + +def test_the_judge_gets_the_method_the_judgment_and_the_input_sql(): + store, catalog, doc = composed("[[a:A1]],华东变化 [[v:A1.r0.delta]]。[[see:A1]]") + request = judging.prepare(doc, catalog, loader=store.load) + [cand] = request.cands + assert cand.aliases == ("A1",) and "A1" in cand.whole and ("A1", 0) in cand.rows and ("A1", "delta") in cand.cols + excerpt = request.excerpts["A1"] + assert excerpt.startswith("【A1】分析结论(变化拆解(可加),方法 decompose.additive@1)") + assert "判定:销售额下降 194 万(-8.7%),主要来自华东(-120 万,占 62%)" in excerpt + assert "判定标准:一段占总变化一半以上算主要来源" in excerpt + assert "关键数:base_total=2,220 | target_total=2,026 | delta_total=-194" in excerpt + assert "结果表(共 4 行)列:地区 | 基期 | 本期 | 变化" in excerpt and "r0:华东 | 820 | 700 | -120" in excerpt + assert "输入:取自查询 Q1,用到 region、week、gmv 列" in excerpt + assert "查询 Q1 的 SQL:SELECT region, week, SUM(gmv) AS gmv FROM orders" in excerpt + assert "地区拖累" not in excerpt # agent 写的假设不进摘录 + + system = request.messages(request.cands)[0].content + assert judging.JUDGE_RULES in system and judging.ANALYSIS_JUDGE_PROMPT in system + + +def test_reports_without_analyses_see_exactly_the_old_rules(): + store, catalog, doc = composed("华东本周 [[v:Q1.r1.gmv]]。[[see:Q1]]") + request = judging.prepare(doc, catalog, loader=store.load) + system = request.messages(request.cands)[0].content + assert system == f"{judging.JUDGE_ROLE}\n\n{judging.JUDGE_RULES}" + + +def test_excerpts_reload_the_result_from_the_stored_summary_and_say_refusals(): + store, catalog, doc = composed("下降来自哪里还说不清。[[see:A1]]", + result=fake_result(status="refused", reason_code="too_few_segments")) + request = judging.prepare(doc, doc["catalog"], loader=store.load) # 文档里的目录只留摘要:按工件取回 + assert "判定:算不了(历史只有 5 期,至少要 8 期)" in request.excerpts["A1"] + gone = judging.prepare(doc, doc["catalog"], loader=lambda _: None) + assert "A1" not in gone.excerpts + + +def test_drill_downs_name_the_parent_in_the_excerpt(): + store, ledger, q, a = setup() + from app.analysis.contract import ledger_entry + + child = fake_result(inputs=[{"ref": "Q1", "artifact": q, "role": "data", "columns": ["region"]}, + {"ref": "A3", "artifact": a, "role": "parent"}]) + ledger.append(ledger_entry(child, node_id="an", exec_no=2, artifact=store.put(child), item="drill")) + catalog = build_catalog(nodes={}, ledger=ledger, loader=store.load) + doc = compose_doc("华东里主要来自上海。[[see:A2]]", catalog, loader=store.load) + excerpt = judging.prepare(doc, catalog, loader=store.load).excerpts["A2"] + assert "下钻自:A1" in excerpt + + +# -------------------------------------------------------------------------- +# 口径账本:分析结论的数不标口径 +# -------------------------------------------------------------------------- + + +def test_analysis_numbers_get_no_caliber_label_and_are_not_counted(): + store, catalog, doc = composed("[[a:A1]],华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]];" + "华东上周 [[v:Q1.r0.gmv]]。") + q = catalog["Q1"]["artifact"] + queries = {q: {"artifact": q, "status": "none", "candidates": []}} + labels = ledger_labels.label_doc(doc, queries, {}, lambda _: None) + labelled = {s["id"]: s.get("ref") for s in iter_segments(doc) if s["id"] in labels} + assert list(labelled.values()) == ["v:Q1.r0.gmv"] + whole = ledger_labels.whole_of(labels, {}) + assert whole["counts"] == {"caliber": 0, "related": 0, "reasoning": 1, "unknown": 0} + assert ledger_labels.referenced_columns([doc], lambda _: None) == {q: {"gmv"}} + + +def test_a_report_citing_only_analyses_has_nothing_to_label(): + store, catalog, doc = composed("[[a:A1]]。[[see:A1]]") + assert ledger_labels.label_doc(doc, {}, {}, lambda _: None) == {} + assert ledger_labels.referenced_columns([doc], lambda _: None) == {} + assert ledger_labels.whole_of({}, {})["reason"] == "报告里没有可标口径的数" + + +def test_the_judge_and_the_writer_see_the_comparability_of_a_metric(): + """口径卡指标上的可比性(口径卡执行时自动做的检查):裁判的摘录里原样给判定语;写作目录没记原因的照判定语写,不重复判定。""" + from app.core.artifact_store import canonical_json, content_hash + from app.engine.evidence import writer_catalog + + card = {"kind": "metric_set", "caliber": "周报", "caliber_version": "v1", "metrics": [ + {"id": "wow", "name": "环比", "value": -0.087, "unit": "", "expression": "1", "inputs": [], + "comparability": {"artifact": "d" * 64, "verdict": "incomparable", "phrase": "两期不可比:本期只有 3 天数据", + "reasons": []}}]} + sha = content_hash(canonical_json(card)) + catalog = build_catalog(nodes={"card": {**card, "artifact": sha}}, ledger=[]) + assert "环比 = -0.087(两期不可比:本期只有 3 天数据。这个比较不能当结论" in writer_catalog(catalog).text + doc = compose_doc("环比 [[m:wow]],需求走弱。", catalog) + request = judging.prepare(doc, catalog, loader={sha: card}.get) + [excerpt] = request.excerpts.values() + assert "两期可比性(系统检查):两期不可比:本期只有 3 天数据" in excerpt diff --git a/backend/tests/test_analysis_pages.py b/backend/tests/test_analysis_pages.py new file mode 100644 index 00000000..94e720de --- /dev/null +++ b/backend/tests/test_analysis_pages.py @@ -0,0 +1,202 @@ +"""分析结论在两份自包含页面里的渲染(第 1 波 W1-b):报告导出(report_export/runtime.js)点开判定语、分析范围里的编号; +可验证报告(bundle/verifier.js)点开判定语看分析结论一节、正文后面的分析范围。 + +浏览器里的完整检查(check-report-export、check-bundle)第 2 波由编排者跑;这里在 node 里用一个最小的 DOM 替身把两份脚本 +真正跑一遍,挡住渲染分析结论那几步时的脚本错误(引用了不存在的文案、函数),并核对面板里写出来的字。 +""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +import app.bundle +import app.report_export +from app.main import app as asgi +from tests.test_analysis_bundle import bundle_of, card_bundle +from tests.test_analysis_evidence_api import LAST_WEEK, SCOPE, TEXT, THIS_WEEK, comparability_card, sealed_run, seg_id + +NODE = shutil.which("node") +pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的脚本") + +RUNTIME = Path(app.report_export.__file__).parent / "runtime.js" +VERIFIER = Path(app.bundle.__file__).parent / "verifier.js" + +#: 最小的 DOM 替身:元素、文字、片段,getElementById 按 id 建元素;textContent 递归拼接 +DOM = r""" +const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +class Node_ { + constructor() { this.children = []; this.parent = null } + append(...kids) { + for (const k of kids) { + const n = typeof k === 'string' ? new Text_(k) : k + if (n instanceof Frag_) { for (const c of n.children) { c.parent = this; this.children.push(c) } n.children = [] } + else { n.parent = this; this.children.push(n) } + } + } + replaceChildren(...kids) { this.children = []; this.append(...kids) } + get textContent() { return this.children.map((c) => c.textContent).join('') } + set textContent(v) { this.children = [new Text_(String(v))] } + walk(f) { f(this); for (const c of this.children) c.walk(f) } +} +class Text_ extends Node_ { + constructor(t) { super(); this.text = t } + get textContent() { return this.text } + set textContent(v) { this.text = String(v) } +} +class Frag_ extends Node_ {} +class El_ extends Node_ { + constructor(tag) { + super(); this.tag = tag; this.attrs = {}; this.dataset = {}; this.style = {}; this.hidden = false; this.listeners = {} + this.className = ''; const cls = new Set(); this.classList = { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) } + } + setAttribute(k, v) { this.attrs[k] = String(v); if (k.startsWith('data-')) this.dataset[camel(k.slice(5))] = String(v) } + getAttribute(k) { return this.attrs[k] ?? null } + removeAttribute(k) { delete this.attrs[k] } + addEventListener(t, f) { (this.listeners[t] ||= []).push(f) } + focus() {} scrollIntoView() {} + getBoundingClientRect() { return { top: 0, bottom: 0 } } + querySelector() { return new El_('span') } + closest() { return this } +} +globalThis.Node = Node_ +const ids = {} +globalThis.window = { scrollBy() {} } +globalThis.document = { + readyState: 'complete', activeElement: null, documentElement: new El_('html'), + getElementById(id) { return ids[id] ||= new El_('div') }, + createElement(tag) { return new El_(tag) }, createTextNode(t) { return new Text_(t) }, + createDocumentFragment() { return new Frag_() }, addEventListener() {}, querySelectorAll() { return [] }, +} +const fs = require('fs') +const input = JSON.parse(fs.readFileSync(0, 'utf8')) +const find = (root, pred) => { const out = []; root.walk((n) => { if (n instanceof El_ && pred(n)) out.push(n) }); return out } +""" + +RUN_RUNTIME = DOM + r""" +document.getElementById('report-data').textContent = input.data +new Function(fs.readFileSync(process.argv[1], 'utf8'))() +const out = {} +for (const seg of input.segs) { + const el = new El_('button') + for (const [k, v] of Object.entries(seg)) el.setAttribute(`data-${k}`, v) + el.textContent = seg.text + for (const f of document.getElementById('report').listeners.click || []) f({ target: el }) + out[seg.seg] = { title: document.getElementById('detail-title').textContent, + body: document.getElementById('detail-body').textContent, + steps: find(document.getElementById('detail-body'), (n) => n.attrs['data-step']).map((n) => n.attrs['data-step']) } +} +process.stdout.write(JSON.stringify(out)) +""" + +RUN_VERIFIER = DOM + r""" +document.getElementById('bundle-data').textContent = input.data +document.getElementById('seal-hash').textContent = input.seal +new Function(fs.readFileSync(process.argv[1], 'utf8'))() +setTimeout(() => { + const reports = document.getElementById('reports') + const out = { scope: find(reports, (n) => 'data-scope' in n.attrs).map((n) => n.textContent), opened: {} } + for (const btn of find(reports, (n) => n.attrs['data-num'])) { + for (const f of btn.listeners.click || []) f() + out.opened[btn.attrs['data-num']] = document.getElementById('detail-body').textContent + } + out.verified = document.documentElement.attrs['data-verified'] ?? null + process.stdout.write(JSON.stringify(out)) +}, 200) +""" + + +def run_node(script: str, path: Path, payload: dict[str, Any]) -> Any: + done = subprocess.run([NODE, "-e", script, str(path)], input=json.dumps(payload), capture_output=True, text=True, + timeout=120) + assert done.returncode == 0, done.stderr[-3000:] + return json.loads(done.stdout) + + +async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers(): + made = await sealed_run(TEXT, doc_extra=SCOPE) + async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client: + html = (await client.get(f"/api/runs/{made['run']}/report.html")).text + data = re.search(r'', html, re.S).group(1) + phrase = seg_id(made["doc"], "a:A1") + cell = seg_id(made["doc"], "v:A1.r0.delta") + segs = [{"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]}, + {"seg": cell, "kind": "number", "state": "deterministic", "text": "-120"}, + {"seg": "scope:A1", "kind": "analysis", "state": "deterministic", "text": "A1"}] + # 现查结果的三种写法:按运行当天现算的界、一列一个值都没有、表的范围(结论里的 probes 原样交给页面) + parsed = json.loads(data) + method = next(s for s in parsed["chains"][parsed["refs"][phrase]] if s["step"] == "analysis_method") + method["probes"] = [ + {"source": "shop", "expr": "date('now', '-7 days')", "value": "2026-10-14", "basis": "live", + "at": "2026-10-21T09:00:00"}, + {"source": "shop", "table": "orders", "column": "refund_date", "min": None, "max": None, "basis": "live", + "at": "2026-10-21T09:00:00"}, + {"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01", "max": "2026-10-20", + "basis": "profile", "at": "2026-10-21T09:00:00"}] + out = run_node(RUN_RUNTIME, RUNTIME, {"data": json.dumps(parsed, ensure_ascii=False), "segs": segs}) + opened = out[phrase] + assert opened["steps"] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query", + "analysis_recompute", "analysis_seal"] + assert opened["title"].startswith("判定「销售额下降") + body = opened["body"] + for text in ("判定标准一段占总变化的一半以上算主要来源", "Agent 写的假设华东拖累", "实际用的阈值", "「主要来源」的占比", + "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%", "结果表(列出 4 行,共 4 行)", + "复算一致:按封存的输入重新计算,结论逐项相同", "封存范围内的台账记过这份分析结论", + "按运行当天现算的界 date('now', '-7 days') = 2026-10-14(现查 · 2026-10-21T09:00:00)", + "orders.refund_date:这一列一个值都没有(现查", "orders.order_date:2025-01-01 至 2026-10-20(剖析"): + assert text in body, text + assert "结果表第 1 行「delta」" in out[cell]["body"] + assert out["scope:A1"]["steps"] == opened["steps"] + + +async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope(): + bundle, _, _ = bundle_of(doc_extra=SCOPE) + out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]}) + assert out["verified"] == "" + [scope] = out["scope"] + assert scope.startswith("分析范围") and "现象A1:销售额下降" in scope and "拆过A1(按地区):主要来自华东" in scope + assert "没拆渠道" in scope and "这次做了 2 次差异检验" in scope + phrase = next(text for text in out["opened"].values() if "出处分析结论 A1 的判定语" in text) + assert "核对结果✓ 通过" in phrase and "分析结论里的值「销售额下降 194(-8.7%)" in phrase + assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本decompose.additive@1" in phrase + assert "输入数据 · Q1" in phrase # 输入的角色写叫法,不写 data + assert "复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论;本页只核哈希" in phrase + value = next(text for text in out["opened"].values() if "具名的数「top_share」" in text) + assert "引用的值0.618556701031" in value and "正文显示61.9%(与按显示规则渲染的结果一致)" in value + scope_item = next(text for key, text in out["opened"].items() if key.endswith("|scope:A1")) + assert "核对结果✓ 通过" in scope_item and "正文显示" not in scope_item + + +async def test_the_export_page_opens_the_comparability_on_a_metric(): + """口径卡指标上的可比性:指标那一步里写判定,点得开那份可比性结论(同分析结论的几步,收在一个可展开的块里)。""" + card, result = await comparability_card() + made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK]) + async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client: + html = (await client.get(f"/api/runs/{made['run']}/report.html")).text + data = re.search(r'', 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 +