分析结论进证据链(引擎):台账 kind analysis 的条目在写作目录里按台账先后编 A1、A2……(只收报告祖先里的,报告节点的「证据来自」按做它的节点过滤),条目照 catalog_result 的约定带着从工件取回、复验过哈希的整份结论,取不回、哈希不符的记明原因;存进报告文档的目录只留摘要,整份结论按工件取回。引用:[[a:A3]] 渲染判定语(一个片段,种类 analysis,整段涂白、不算裸数字),[[a:A3.键|换算]] 渲染具名标量(换算同指标),[[v:A3.r0.列]]、[[table:A3 …]] 取结果表(表头写结果表的列名,表头里的数不算裸数字),[[see:A3]]、[[see:A3.r0]] 当依据;写错的编号、键、越界的行、判定语加换算都照实说为什么。受管级别的正式出具、契约没开「单元格引用」时,Agent 现场做的分析和它查的格同一个规矩,分析卡做的照常引用。写作目录加「分析结论」一段(判定语、判定标准、具名的数、结果表前 table_top_k 段与其余几段、附带说明、算不了的原因、来自哪几个 Q、下钻自哪个 A、输入查询的 SQL 检查问题,外加引用写法一句),放不下时判定和具名的数一律列出、结果表排在查询的行前面补、最后才退;口径卡指标上的可比性(有条件可比、不可比)照实写出原因。SQL 问题顺着分析结论的输入往下找。另有复算(analysis_recheck:算子没落地、调用写错、出意外都照实写「复算失败:原因」,方法升版写明前后两版)、复算用的输入快照、关键数的代入式,证据面板、导出、可验证报告共用

This commit is contained in:
ThearchyHelios
2026-10-21 13:07:36 +08:00
parent 8137f0cf37
commit 855a559300
4 changed files with 1125 additions and 58 deletions
+579 -50
View File
@@ -17,8 +17,16 @@
[[i:<输入字段>]] 运行输入
[[t:<表>]] / [[c:<表>.<列>]] 实体:表 / 字段,显示名字本身(反引号里写的已知名字也会自动链接)
[[q:K<n>|<逐字引文>]] 引文:必须在那次检索命中的片段里逐字出现,显示引文本身
[[a:A<n>]] 分析结论的判定语:系统按模板渲染好的一整句(判定由平台的算子算出,写作者只引用)
[[a:A<n>.<键>|万]] 分析结论的具名标量(values 里的一项),换算同 m:
[[v:A<n>.r<行>.<列>]] 分析结论结果表里的一格;[[table:A<n> …]] 整表,表头写结果表的列名
[[see:<ref>,<ref>…]] 依据:挂在句末,不渲染
分析结论(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<kind>[A-Za-z]+)[ \t]*:(?P<body>[^\[\]\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:<id>,只登记 m:<卡的节点 id>.<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<alias>[QK]\d+)\.r(?P<row>\d+)\.(?P<column>.+)$")
_CELL_REF = re.compile(rf"^(?P<alias>[QK{CATALOG_PREFIX}]\d+)\.r(?P<row>\d+)\.(?P<column>.+)$")
_NODE_REF = re.compile(r"^N:(?P<node>[^.\s]+)(?:\.(?P<field>.+))?$")
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<alias>{CATALOG_PREFIX}\d+)(?:\.(?P<key>[^\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<alias>[QK]\d+)(?:\.r(?P<row>\d+))?$")
_ROW_REF = re.compile(rf"^(?P<alias>[QK{CATALOG_PREFIX}]\d+)(?:\.r(?P<row>\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,21 @@ 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) \
or str(comparability.get("phrase") or "")
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 +4388,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 +4409,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 +4448,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 +4466,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 +4486,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 +4643,141 @@ 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": "附带说明",
}
#: 复算抛 NotImplementedError:这一版系统里还没有这种分析的算子
RECHECK_MISSING = "系统里还没有这种分析的复算实现"
def analysis_input_snapshot(content: Any, artifact: str, *, ref: str | None = None,
node_id: str | None = None) -> dict[str, Any] | None:
"""一份查询快照的内容 → 复算要的输入快照(analysis.contract.InputSnapshot:数据源查询工具的 to_payload 加上出处)。
不能当维度、度量的列(masked)按快照里记下的、查询当时的遮罩:复算按运行当时的条件重算;现在设的遮罩只管展示。
不是查询快照(没有 rows)的返回 None。"""
if not isinstance(content, dict) or not isinstance(content.get("rows"), list):
return None
return {"ref": str(ref or ""), "artifact": str(artifact), "node_id": str(node_id or ""),
"source": content.get("source"), "sql": str(content.get("sql") or ""),
"columns": [str(c) for c in content.get("columns") or []],
"column_types": content.get("column_types") if isinstance(content.get("column_types"), dict) else {},
"rows": content["rows"], "truncated": bool(content.get("truncated")),
"masked": sorted(_mask_names(content.get("mask_columns")))}
def analysis_recheck(result: dict[str, Any], load: Callable[[str], Any], *,
load_result: Callable[[str], Any] | None = None) -> dict[str, Any]:
"""把一份封存的分析结论按它记下的输入复算一遍(app.analysis.recompute:按 sha256 取回输入快照,按结论里记下的参数、
阈值和现查结果算,不再现查),和封存的逐字段比(contract.same,agent 写的假设不比)。
交回 {status, text, method, method_now?, fields?}:status 是 same(一致)/ different(不一致)/ failed(复算不了);
text 是给人看的一句,证据面板、报告导出、可验证报告照它写。方法升过版的照实写运行时用的是哪一版、现在是哪一版,
复算结果同不同。复算抛异常(算子还没有、输入取不回、调用的写法现在不认)不往外抛,照实写「复算失败:原因」。"""
from app import analysis
from app.analysis.contract import NOT_RECOMPUTED, CallError, same
from app.core.errors import describe_exception
method = str(result.get("method") or "")
try:
again = analysis.recompute(result, load, load_result=load_result) # type: ignore[arg-type]
except NotImplementedError:
return {"status": "failed", "method": method, "text": f"复算失败:{RECHECK_MISSING}"}
except CallError as e:
return {"status": "failed", "method": method, "text": f"复算失败:{e.message}"}
except Exception as e: # noqa: BLE001 - 复算出了意外:照实说复算不了,不能让证据面板、导出跟着出错
return {"status": "failed", "method": method, "text": f"复算失败:{describe_exception(e)}"}
if not isinstance(again, dict):
return {"status": "failed", "method": method, "text": "复算失败:复算没有交回结论"}
ok = same(result, again)
fields = sorted(k for k in set(result) | set(again) if k not in NOT_RECOMPUTED and result.get(k) != again.get(k))
out: dict[str, Any] = {"status": "same" if ok else "different", "method": method}
now = str(again.get("method") or "")
if now != method:
out["method_now"] = now
out["text"] = f"运行时用的是 {method},现在是 {now},复算结果{'相同' if ok else '不同'}"
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
def _decompose_row(result: dict[str, Any]) -> list[Any] | None:
"""变化拆解的结果表里判定点名的那一段(verdict.subjects 的第一段,按维度列 params.by 对),对不上就取第一行(结果表
按变化的绝对值从大到小排)。"""
table = result.get("table") if isinstance(result.get("table"), dict) else {}
rows = [r for r in table.get("rows") or [] if isinstance(r, list)]
columns = [str(c) for c in table.get("columns") or []]
if not rows:
return None
subjects = ((result.get("verdict") or {}).get("subjects") or []) if isinstance(result.get("verdict"), dict) else []
by = (result.get("params") or {}).get("by") if isinstance(result.get("params"), dict) else None
by = [str(b) for b in (by if isinstance(by, list) else [by] if by else []) if str(b) in columns]
if subjects and by and isinstance(subjects[0], list):
wanted = [str(v) for v in subjects[0]]
for row in rows:
if [str(row[columns.index(b)]) for b in by if columns.index(b) < len(row)] == wanted:
return row
return rows[0]
def analysis_formulas(result: dict[str, Any]) -> list[dict[str, str]]:
"""出处链「计算」一步里关键数的代入式:[{label, text}],数照单元格的规则渲染(系统写的,不是模型写的)。只给拿结论里
记下的数就写得出来的几条——变化拆解的总变化、变化率、主要来源的占比,比率拆解的结构效应、自身效应占比,波动基线的
常规波动范围和稳健 z;缺了哪个数、渲染不出来的那条不给。比如「华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ -194 = 61.9%」。"""
values = result.get("values") if isinstance(result.get("values"), dict) else {}
settings = result.get("settings") if isinstance(result.get("settings"), 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 # 缺一个数、渲染不出来:这条不给,不编
if result.get("status") != "ok":
return out
if op == "decompose" and mode == "additive":
add("总变化", lambda: f"总变化 = 本期合计 − 基期合计 = {render_cell(values['target_total'])} − "
f"{render_cell(values['base_total'])} = {render_cell(values['delta_total'])}")
add("变化率", lambda: f"变化率 = 总变化 ÷ 基期合计 = {render_cell(values['delta_total'])} ÷ "
f"{render_cell(values['base_total'])} = {render_cell(values['pct_total'], 'pct')}")
table = result.get("table") or {}
columns = [str(c) for c in table.get("columns") or []]
row = _decompose_row(result)
if row is not None and {"delta", "share"} <= set(columns):
by = [c for c in columns if c not in ("base", "target", "delta", "pct", "share", "flag")]
name = " · ".join(str(row[columns.index(c)]) for c in by if columns.index(c) < len(row)) or "这一段"
add("占比", lambda: f"{name}的占比 = {name}的变化 ÷ 总变化 = "
f"{render_cell(row[columns.index('delta')])} ÷ {render_cell(values['delta_total'])} = "
f"{render_cell(row[columns.index('share')], 'pct')}")
elif op == "decompose" and mode == "ratio":
for part, label in (("mix", "结构效应"), ("rate", "自身效应")):
add(f"{label}占比", lambda part=part, label=label: (
f"{label}占比 = {label}合计 ÷ 整体的变化 = {render_cell(values[f'{part}_total'])} ÷ "
f"{render_cell(values['delta'])} = {render_cell(values[f'{part}_share'], 'pct')}"))
elif op == "baseline":
add("常规波动范围", lambda: (
f"常规波动范围 = 中位数 ± {settings['k_warn']:g} 倍稳健标准差 = {render_cell(values['band_lo'])} 至 "
f"{render_cell(values['band_hi'])}(中位数 {render_cell(values['median'])},"
f"过去 {render_cell(values['n_history'])} 期)"))
add("稳健 z", lambda: f"稳健 z = (本期 − 中位数)÷ 稳健标准差 = ({render_cell(values['x'])} − "
f"{render_cell(values['median'])})÷ 稳健标准差 = {render_cell(values['z'])}")
return out
# --------------------------------------------------------------------------
# 旧运行:按数值猜「可能的来源」
#
+5 -4
View File
@@ -146,11 +146,12 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[
if isinstance(sources, str) and sources != "ancestors":
sources = [sources]
if isinstance(sources, list):
# evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。
# 写了子工作流节点的,它交回来的(子图里的查询,节点 id「子工作流节点 id/…」)一并算
# evidence_from 只管查询、检索、分析结论这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。
# 分析结论按做它的节点认(Agent,或者分析卡)。写了子工作流节点的,它交回来的(子图里的查询,节点 id
# 「子工作流节点 id/…」)一并算
keep = {str(s) for s in sources}
ledger = [e for e in ledger
if e.get("kind") not in ("query", "retrieval", "schema") or _outer(e.get("node_id")) in keep]
ledger = [e for e in ledger if e.get("kind") not in ("query", "retrieval", "schema", "analysis")
or _outer(e.get("node_id")) in keep]
nodes = state.get("nodes") or {}
catalog = build_catalog(
nodes=nodes,
+8 -4
View File
@@ -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
+533
View File
@@ -0,0 +1,533 @@
"""分析结论进证据链(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)
"""
from __future__ import annotations
from typing import Any
import pytest
from app import analysis
from app.analysis.contract import CallError, ledger_entry
from app.analysis.testing import fake_result
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import (
ANALYSIS_CELLS_REASON,
StreamRenderer,
analysis_formulas,
analysis_input_snapshot,
analysis_recheck,
build_catalog,
compose_doc,
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)
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_says_plainly_when_the_operators_are_not_there_yet():
store, ledger, q, a = setup()
out = analysis_recheck(store[a], lambda sha: analysis_input_snapshot(store.get(sha), sha))
assert out == {"status": "failed", "method": "decompose.additive@1",
"text": "复算失败:系统里还没有这种分析的复算实现"}
def test_recheck_compares_field_by_field_and_names_the_method_versions(monkeypatch):
store, _, q, a = setup()
sealed = store[a]
seen: list[Any] = []
def same(result, load, *, load_result=None):
seen.append(load(q))
return {**result, "hypothesis": "复算时没有假设"}
monkeypatch.setattr(analysis, "recompute", same)
out = analysis_recheck(sealed, lambda sha: analysis_input_snapshot(store.get(sha), sha, ref="Q2", node_id="fetch"))
assert out == {"status": "same", "method": "decompose.additive@1", "text": "复算一致:按封存的输入重新计算,结论逐项相同"}
assert seen[0]["rows"] == SNAPSHOT["rows"] and seen[0]["masked"] == [] and seen[0]["node_id"] == "fetch"
monkeypatch.setattr(analysis, "recompute", lambda r, load, *, load_result=None: {**r, "phrase": "别的判定"})
assert analysis_recheck(sealed, store.get)["text"] == "复算不一致:判定语和封存的不同"
monkeypatch.setattr(analysis, "recompute",
lambda r, load, *, load_result=None: {**r, "method": "decompose.additive@2"})
out = analysis_recheck(sealed, store.get)
assert out["status"] == "different" and out["method_now"] == "decompose.additive@2"
assert out["text"] == "运行时用的是 decompose.additive@1,现在是 decompose.additive@2,复算结果不同"
def refuse(r, load, *, load_result=None):
raise CallError("missing_column", "Q2 里没有列「gmv」")
monkeypatch.setattr(analysis, "recompute", refuse)
assert analysis_recheck(sealed, store.get)["text"] == "复算失败:Q2 里没有列「gmv」"
def broken(r, load, *, load_result=None):
raise RuntimeError("输入快照取不回来")
monkeypatch.setattr(analysis, "recompute", broken)
assert analysis_recheck(sealed, store.get)["text"] == "复算失败:输入快照取不回来"
def test_input_snapshots_for_recompute_keep_the_masks_of_the_query_time():
snap = analysis_input_snapshot({**SNAPSHOT, "mask_columns": ["Phone", "email"]}, "a" * 64, ref="Q1")
assert snap["masked"] == ["email", "phone"] and snap["artifact"] == "a" * 64 and snap["ref"] == "Q1"
assert analysis_input_snapshot({"columns": []}, "a" * 64) is None
def test_formulas_substitute_the_key_numbers():
formulas = {f["label"]: f["text"] for f in analysis_formulas(fake_result())}
assert formulas == {
"总变化": "总变化 = 本期合计 − 基期合计 = 2,026 − 2,220 = -194",
"变化率": "变化率 = 总变化 ÷ 基期合计 = -194 ÷ 2,220 = -8.7%",
"占比": "华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ -194 = 61.9%",
}
baseline = fake_result("baseline", verdict="outside_band", values={
"x": "-0.087", "median": "-0.004", "band_lo": "-0.061", "band_hi": "0.053", "z": "-2.9", "n_history": "12"})
texts = [f["text"] for f in analysis_formulas(baseline)]
assert texts == ["常规波动范围 = 中位数 ± 2 倍稳健标准差 = -0.061 至 0.053(中位数 -0.004,过去 12 期)",
"稳健 z = (本期 − 中位数)÷ 稳健标准差 = (-0.087 − -0.004)÷ 稳健标准差 = -2.9"]
assert analysis_formulas(fake_result(status="refused", reason_code="history_short")) == []
assert analysis_formulas(fake_result(values={})) == []
# --------------------------------------------------------------------------
# 血缘(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")]