分析卡里随数据变化的调用错误记成算不了的一项,不让卡失败(复核 R6):analysis.run 加参数 refuse_data_errors(缺省假),为真时 DATA_ERRORS 里的调用错误——上一层没做成 parent_refused、下钻的段这期没有 parent_key_missing、期次这期没有 period_not_found(变化拆解、波动基线的本期、差异检验按期比、可比性检查)、总数那一格越界或为空 row_out_of_range / headline_empty、要比的组这期没有 unknown_group / too_few_groups、要用的那一格这期为空 empty_cell(乘积拆解的乘积和因子;差异检验的计数为空原来报的是 not_count,改报 empty_cell)——记成 status refused 的结论:契约 REASONS 加 parent_refused、input_unavailable,CallError 加 reason(照实说是哪一项、哪个值,不带改法),判定语「{叫法}没有做成:原因」;params 照调用原样(label 照样消毒),取过还没记用法的快照都记进输入(Inputs.settle,角色照第一次取时的),上一层在核对它做没做成之前就记进输入,复算按它们走到同一处;阈值和判定标准同各算子自己算不了时那一份(各模块的 USED / refusal_basis,算子本身也改用它)。recompute 一律按真复现;结构性的调用错误照旧抛。分析卡运行器传真,agent 工具不传。顺手:段数不够的原因里维度叫法和汉字之间照 say 的规矩补空格

This commit is contained in:
ThearchyHelios
2026-10-21 15:47:30 +08:00
parent 0acf9fb3a8
commit 6e38df4fbf
13 changed files with 353 additions and 54 deletions
+55 -7
View File
@@ -5,8 +5,11 @@
- run:按请求算一份分析结论。resolve 按调用里写的名字(Q3、来源节点 id)取回输入快照;单元格写成「名字.r行.列」
(Q1.r0.gmv),由这里拆开再经 resolve 取快照。parents 按名字(A3、分析卡里的项 id)取回上一层拆解,下钻用。
调用写错抛 contract.CallError;算不了返回 status refused 的结论。唯一的异步来自 context["probe"](现查表的日期范围)。
refuse_data_errors 为真时(分析卡:调用是模板定死的,这期的数据里没有要用的那一项不是写错了),随数据变化的调用错误
(DATA_ERRORS)也记成算不了的结论:上一层没做成 parent_refused,其余 input_unavailable,原因照实写是哪一项、哪个值;
结构性的(列不存在、遮罩列、比率当可加、重复行、参数写错)照旧抛。agent 工具不传,调用错误交回模型改调用。
- recompute:按结论里记下的 params、settings、probes 和输入快照(按 sha256 取回)重算一遍,不现查;和封存的用
contract.same 比。
contract.same 比。一律按 refuse_data_errors 为真复现:分析卡记下的这类结论复算也成立。
契约(名字、形状、缺省阈值)在 contract.py。各算子在 decompose.py(变化拆解)、series.py(波动基线、趋势)、differ.py
(差异检验)、comparable.py(可比性检查);统计在 stats.py,判定语和数的写法在 phrases.py,现查在 probe.py。
@@ -32,6 +35,14 @@ Resolver = Callable[[str], InputSnapshot | None]
#: 按名字取回上一层的分析结论(下钻);没有返回 None
ParentResolver = Callable[[str], AnalysisResult | None]
#: 随数据变化的调用错误(CallError.code):调用本身没写错,是这期的数据里没有要用的那一项。分析卡里(refuse_data_errors)
#: 记成算不了的结论,不让卡失败:上一层没做成(parent_refused);下钻的段这期没有(parent_key_missing);期次这期没有
#: (period_not_found:变化拆解、波动基线的本期、差异检验按期比、可比性检查);按位置取的总数那一格越界或为空
#: (row_out_of_range、headline_empty);要比的组这期没有(unknown_group、too_few_groups);要用的那一格这期为空
#: (empty_cell:乘积拆解的乘积、因子,差异检验的计数)
DATA_ERRORS = frozenset({"parent_refused", "parent_key_missing", "period_not_found", "row_out_of_range",
"headline_empty", "unknown_group", "too_few_groups", "empty_cell"})
def _op_mode(request: Mapping[str, Any]) -> tuple[str, str | None]:
op = request.get("op")
@@ -69,9 +80,44 @@ def _env(op: str, mode: str | None, settings: Mapping[str, Any], inputs: Any, fa
parents=parents, hypothesis=hypothesis if isinstance(hypothesis, str) else None)
def _data_refusal(op: str, mode: str | None, raw: Mapping[str, Any], env: Any, error: CallError) -> AnalysisResult:
"""随数据变化的调用错误记成的算不了的结论。params 记调用原样(label 照样消毒;调用还没走完、规范化不完整,复算拿它重走一遍
走到同一处),取过的快照都记进输入(Inputs.settle),阈值和判定标准同这个算子自己算不了时那一份。"""
from app.analysis import decompose, differ, series
from app.analysis import phrases as ph
from app.analysis.common import build
env.inputs.settle()
params = dict(raw)
if params.get("label") is not None:
params["label"] = ph.clean_label(params["label"]) or None
if op == "decompose":
used, criterion = decompose.refusal_basis(mode, raw, env.settings) # type: ignore[arg-type]
elif op in ("baseline", "trend"):
used, criterion = series.refusal_basis(op, env.settings)
elif op == "differ":
used, criterion = differ.refusal_basis(mode, env.settings)
else:
used, criterion = [], ph.CRITERION_COMPARABLE
code = "parent_refused" if error.code == "parent_refused" else "input_unavailable"
reason = error.reason or error.message
return build(env, params, used, status="refused", reason_code=code, reason=reason, criterion=criterion,
phrase=ph.refused(op, reason))
def _compute_or_refuse(op: str, mode: str | None, raw: Mapping[str, Any], env: Any, refuse_data_errors: bool
) -> AnalysisResult:
try:
return _compute(op, mode, raw, env)
except CallError as e:
if refuse_data_errors and e.code in DATA_ERRORS:
return _data_refusal(op, mode, raw, env, e)
raise
async def run(request: AnalysisRequest, resolve: Resolver, *, settings: Mapping[str, Any], context: Context,
parents: ParentResolver | None = None) -> AnalysisResult:
"""算一份分析结论。settings 是 contract.effective_settings 算好的阈值。"""
parents: ParentResolver | None = None, refuse_data_errors: bool = False) -> AnalysisResult:
"""算一份分析结论。settings 是 contract.effective_settings 算好的阈值。refuse_data_errors 见文件头(分析卡传真)。"""
from app.analysis.common import DERIVED_PARAMS
from app.analysis.facts import Facts
from app.analysis.inputs import Inputs
@@ -97,7 +143,7 @@ async def run(request: AnalysisRequest, resolve: Resolver, *, settings: Mapping[
book = Book(known)
env = _env(op, mode, full, Inputs(resolve, dialect), Facts(catalogs=context.get("catalogs")), book, today,
parents, request.get("hypothesis"))
result = _compute(op, mode, raw, env)
result = _compute_or_refuse(op, mode, raw, env, refuse_data_errors)
missing = book.missing
if not missing or rounds == MAX_ROUNDS:
return result
@@ -109,7 +155,8 @@ def recompute(result: AnalysisResult, load: Callable[[str], InputSnapshot | None
load_result: Callable[[str], AnalysisResult | None] | None = None) -> AnalysisResult:
"""复算:load 按 sha256 取回输入快照,load_result 按 sha256 取回 parent 那份结论。用结论里记下的 params(含运行当天、
目录事实)、settings、probes,不现查、不读目录。取不回输入时抛 CallError(code input_missing);方法升版后调用不再成立
时同样抛 CallError(照 run 的说法)。和封存的那份用 contract.same 比。"""
时同样抛 CallError(照 run 的说法)。随数据变化的调用错误一律记成算不了(同 run 的 refuse_data_errors 为真),分析卡记下
的这类结论复算也成立。和封存的那份用 contract.same 比。"""
from app.analysis.common import DERIVED_PARAMS
from app.analysis.facts import Facts
from app.analysis.inputs import _CELL, Inputs
@@ -150,7 +197,7 @@ def recompute(result: AnalysisResult, load: Callable[[str], InputSnapshot | None
env = _env(op, mode, settings, Inputs(resolve, dialect), Facts(recorded=params.get("catalog") or {}),
Book(result.get("probes") or []), today, parents, result.get("hypothesis"))
raw = {k: v for k, v in params.items() if k not in DERIVED_PARAMS}
return _compute(op, mode, raw, env)
return _compute_or_refuse(op, mode, raw, env, True)
def exhausted(settings: Mapping[str, Any]) -> CallError:
@@ -174,4 +221,5 @@ def comparable_reasons(result: Mapping[str, Any]) -> list[str]:
return reasons(result)
__all__ = ["ParentResolver", "Resolver", "comparable_reasons", "exhausted", "make_prober", "recompute", "run"]
__all__ = ["DATA_ERRORS", "ParentResolver", "Resolver", "comparable_reasons", "exhausted", "make_prober", "recompute",
"run"]
+5 -3
View File
@@ -497,8 +497,9 @@ def comparable(raw: Mapping[str, Any], env: Any) -> Any:
params: dict[str, Any] = {}
two = raw.get("base_query") is not None or raw.get("target_query") is not None
if two:
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号")
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号", role="base")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号",
role="target")
if base is target:
from app.analysis.decompose import SAME_QUERY
@@ -529,7 +530,8 @@ def comparable(raw: Mapping[str, Any], env: Any) -> Any:
for side, what in (("base", "基期"), ("target", "本期")):
if not rows[side]:
raise CallError("period_not_found", f"期次列「{period}」里没有「{params[side]}」:{what}要写 {target.ref} "
"里有的期次值")
"里有的期次值",
reason=f"期次列「{period}」里没有{what}「{ph.key_text([params[side]])}」")
env.inputs.use(target, "data", [period, *([measure] if measure else [])], rows["base"] + rows["target"])
parsed = pd.parse(values, grain, weeks=pd.week_scheme(target.sql, period, target.dialect))
if parsed is None:
+10 -4
View File
@@ -83,10 +83,12 @@ VERDICTS: dict[str, tuple[str, ...]] = {
"differ": ("differs", "no_evidence"),
"comparable": ("comparable", "conditional", "incomparable"),
}
#: 算不了的原因(细案 01 §8.2)。算不了也记成结论:写作者可以照实写「历史只有 5 周,判断不了是否异常」
#: 算不了的原因(细案 01 §8.2)。算不了也记成结论:写作者可以照实写「历史只有 5 周,判断不了是否异常」。最后两种是分析卡
#: 里随数据变化的调用错误记成的(analysis.run 的 refuse_data_errors,见 app/analysis 的 DATA_ERRORS):parent_refused 上一层
#: 没做成;input_unavailable 这次的数据里没有要用的那一项(下钻的段、期次、总数那一格、要比的组……,reason 写是哪一项、哪个值)
REASONS: tuple[str, ...] = (
"truncated", "too_few_segments", "history_short", "partial_target", "target_gap", "series_gap",
"period_unreadable", "zero_base", "non_positive", "incomparable_sides",
"period_unreadable", "zero_base", "non_positive", "incomparable_sides", "parent_refused", "input_unavailable",
)
#: 附带说明的种类(caveats[].code)
CAVEATS: tuple[str, ...] = (
@@ -305,12 +307,16 @@ class AnalysisResult(TypedDict, total=False):
class CallError(Exception):
"""调用错误(细案 01 §8.1):列不存在、列被遮罩、角色和目录度量类型冲突、同一键同一期多行、期次不在列里、乘积
不闭合、parent 不对、下钻超深、这个 Agent 这次执行的分析次数用完(max_calls 按每个 Agent 每次执行计)。不记结论,message
交回 agent(一种说法,告诉它怎么改)。"""
交回 agent(一种说法,告诉它怎么改)。
def __init__(self, code: str, message: str) -> None:
随数据变化的那几种(app/analysis 的 DATA_ERRORS:这期没有要用的期次、段、组,上一层没做成……)另带 reason:照实说是哪一项、
哪个值,不带改法。分析卡(模板定死了调用)按它记成算不了的结论,见 analysis.run 的 refuse_data_errors。"""
def __init__(self, code: str, message: str, *, reason: str | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.reason = reason
#: 现查一列的实际范围:(数据源名, 表, 列) → Probe;查不了返回 None(算子写「核对不了」,不判不可比)
+41 -15
View File
@@ -41,6 +41,22 @@ SAME_QUERY = "基期和本期是同一份查询:两期各给一份查询,或
#: 乘积闭合的最小相对误差
_FACTOR_REL = Fraction(1, 10 ** 6)
_NOT_ADDITIVE = {"ratio": "比率", "identifier": "标识", "status": "状态", "attribute": "属性"}
#: 各模式的结论里记的阈值(下钻时另加 max_drill_depth)
USED: dict[str, tuple[str, ...]] = {
"additive": ("main_share", "concentration", "offset_ratio", "flat_pct", "table_top_k"),
"ratio": ("main_share", "flat_pct", "min_denominator", "table_top_k"),
"factor": ("main_share", "flat_pct"),
}
def refusal_basis(mode: str, raw: Mapping[str, Any], settings: Mapping[str, Any]) -> tuple[list[str], str]:
"""还没算就算不了时(分析卡把随数据变化的调用错误记成算不了的结论)结论里记的阈值和判定标准:同这个模式自己算不了时
的那一份。"""
used = list(USED[mode]) + (["max_drill_depth"] if mode != "factor" and raw.get("parent") is not None else [])
if mode == "additive":
summed = raw.get("headline") is None and raw.get("parent") is None
return used, ph.criterion_additive(settings, top_k_max=None, summed=summed)
return used, ph.criterion_ratio(settings) if mode == "ratio" else ph.criterion_factor(settings)
@dataclass
@@ -71,8 +87,9 @@ def _sides(raw: Mapping[str, Any], env: Env, roles: Sequence[tuple[str, str]], *
params: dict[str, Any] = {}
two = raw.get("base_query") is not None or raw.get("target_query") is not None
if two:
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号")
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号", role="base")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号",
role="target")
if base is target:
raise CallError("same_query", SAME_QUERY)
params.update(base_query=base.ref, target_query=target.ref)
@@ -116,7 +133,8 @@ def _period_rows(snap: Snap, period: str, wanted: Any, what: str) -> list[int]:
if not rows:
values = list(dict.fromkeys(ph.key_text([row[i]]) for row in snap.rows if i < len(row)))
raise CallError("period_not_found", f"期次列「{period}」里没有「{wanted}」:{what}要写 {snap.ref} 里有的期次值,"
f"比如 {'、'.join(values[:6])}")
f"比如 {'、'.join(values[:6])}",
reason=f"期次列「{period}」里没有{what}「{ph.key_text([wanted])}」")
return rows
@@ -181,8 +199,12 @@ def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dic
if found.get("op") != "decompose" or found.get("mode") != mode:
raise CallError("parent_not_decompose", f"{name} 不是{'按维度' if mode == 'additive' else '比率'}的变化拆解,"
"下钻要接在同一种拆解后面")
# 上一层先记进输入:它没做成、这期没有那一段时(分析卡记成算不了的结论),复算也要按内容哈希取回它、走到同一处
env.inputs.parent(name, content_hash(canonical_json(found)))
if found.get("status") != "ok" or not isinstance(found.get("table"), Mapping):
raise CallError("parent_refused", f"{name} 没有做成,不能在它下面下钻")
why = f"({found['reason']})" if isinstance(found.get("reason"), str) and found["reason"] else ""
raise CallError("parent_refused", f"{name} 没有做成,不能在它下面下钻",
reason=f"上一层「{name}」没有做成{why},不能在它下面下钻")
depth = int((found.get("params") or {}).get("depth") or 0) + 1
limit = int(env.settings["max_drill_depth"])
if depth > limit:
@@ -196,8 +218,8 @@ def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dic
hit = next((row for row in table.get("rows") or []
if all(same_value(k, v) for k, v in zip(key, row[:dim_count]))), None)
if hit is None:
raise CallError("parent_key_missing", f"{name} 里没有「{ph.key_text(key)}」这一段:写 {name} 结果表里的段")
env.inputs.parent(name, content_hash(canonical_json(found)))
raise CallError("parent_key_missing", f"{name} 里没有「{ph.key_text(key)}」这一段:写 {name} 结果表里的段",
reason=f"上一层「{name}」里没有「{ph.key_text(key)}」这一段")
return {"of": name, "key": key}, dict(zip(columns, hit)), depth
@@ -220,7 +242,8 @@ def _headline(raw: Mapping[str, Any], env: Env) -> tuple[dict[str, str] | None,
for side, what in (("base", "基期总数"), ("target", "本期总数")):
snap, row, col, value = env.inputs.cell(spec[side], f"headline_{side}", what)
if value is None:
raise CallError("headline_empty", f"{what}那一格({snap.ref}.r{row}.{col})是空的:换一格有值的")
raise CallError("headline_empty", f"{what}那一格({snap.ref}.r{row}.{col})是空的:换一格有值的",
reason=f"{what}那一格({snap.ref}.r{row}.{col})是空的")
out[side] = (value, snap.cell(row, col))
refs[side] = f"{snap.ref}.r{row}.{col}"
return refs, out
@@ -260,7 +283,7 @@ def _share_rank(segments: Sequence[Any], share: Any) -> list[Any]:
def additive(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "concentration", "offset_ratio", "flat_pct", "table_top_k"]
used = list(USED["additive"])
params, bs, ts, cols, by = _sides(raw, env, [("measure", "度量列")])
measure = cols["measure"]
label = label_for(env, raw.get("label"), ts, measure)
@@ -299,7 +322,8 @@ def additive(raw: Mapping[str, Any], env: Env) -> Any:
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats)
segs = _segments(side_b, side_t, Fraction(0))
if len(segs) < 2:
return refuse(env, params, used, "too_few_segments", f"按{ph.dims_text(dims)}只有 {len(segs)} 段,至少要 2 段才拆得开",
return refuse(env, params, used, "too_few_segments",
ph.say("按", ph.dims_text(dims), f"只有 {len(segs)} 段,至少要 2 段才拆得开"),
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats)
return _additive_result(env, params, used, segs, by, dims, label, heads, parent_row, outcome, caveats, summed,
side_b, side_t)
@@ -419,7 +443,7 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li
def ratio(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "flat_pct", "min_denominator", "table_top_k"]
used = list(USED["ratio"])
params, bs, ts, cols, by = _sides(raw, env, [("numerator", "分子列"), ("denominator", "分母列")])
num, den = cols["numerator"], cols["denominator"]
num_label, den_label = column_label(env, ts, num), column_label(env, ts, den)
@@ -462,7 +486,8 @@ def ratio(raw: Mapping[str, Any], env: Env) -> Any:
segs = _segments(side_b, side_t, (Fraction(0), Fraction(0)))
live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0]
if len(live) < 2:
return refuse(env, params, used, "too_few_segments", f"按{ph.dims_text(dims)}只有 {len(live)} 段,至少要 2 段才拆得开",
return refuse(env, params, used, "too_few_segments",
ph.say("按", ph.dims_text(dims), f"只有 {len(live)} 段,至少要 2 段才拆得开"),
criterion=criterion)
for when, i in (("基期", 0), ("本期", 1)):
if sum(((x.base, x.target)[i][1] for x in live), Fraction(0)) == 0:
@@ -635,7 +660,7 @@ def lmdi(base: Sequence[float], target: Sequence[float], m_base: float, m_target
def factor(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "flat_pct"]
used = list(USED["factor"])
params, bs, ts, cols, _ = _sides(raw, env, [("product", "乘积列")], by_needed=False)
product = cols["product"]
wanted = as_list(raw.get("factors"))
@@ -672,7 +697,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any:
for col, what in ((product, "乘积列"), *((c, "因子列") for c in factors)):
value = snap.number(r, col, what)
if value is None:
raise CallError("empty_cell", f"{snap.ref} 第 {r} 行的「{col}」是空的:乘积、因子都要有值")
raise CallError("empty_cell", f"{snap.ref} 第 {r} 行的「{col}」是空的:乘积、因子都要有值",
reason=f"{'基期' if when == 'base' else '本期'}的「{col}」那一格是空的({snap.ref} 第 {r} 行)")
row.append((value, snap.cell(r, col)))
cells[when] = row
if bs is ts:
@@ -736,5 +762,5 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any:
phrase=ph.with_caveats(main, caveats), caveats=caveats)
__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "additive", "factor", "lmdi", "ratio",
"ratio_effects"]
__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi",
"ratio", "ratio_effects", "refusal_basis"]
+22 -6
View File
@@ -36,6 +36,16 @@ from app.analysis.numbers import canon, to_fraction
TESTS = {"z": "z 检验", "fisher": "Fisher 精确检验", "welch": "Welch t 检验"}
def used_settings(mode: str | None) -> list[str]:
"""结论里记的阈值:比率看最小期望频数,均值、逐行看最小样本数。"""
return ["differ_alpha", "min_expected"] if mode == "rate" else ["differ_alpha", "min_n"]
def refusal_basis(mode: str | None, settings: Mapping[str, Any]) -> tuple[list[str], str]:
"""还没算就算不了时结论里记的阈值和判定标准(同 decompose.refusal_basis)。"""
return used_settings(mode), ph.criterion_differ(settings, mode) # type: ignore[arg-type]
@dataclass(eq=False)
class Group:
key: Any
@@ -62,7 +72,10 @@ class Pair:
def _int(value: Fraction | None, snap: Snap, r: int, column: str) -> int:
if value is None or value.denominator != 1 or value < 0:
if value is None:
raise CallError("empty_cell", f"{snap.ref} 第 {r} 行的「{column}」是空的:计数要有值(在 SQL 里把空值写成 0)",
reason=f"「{column}」那一格是空的({snap.ref} 第 {r} 行)")
if value.denominator != 1 or value < 0:
raise CallError("not_count", f"{snap.ref} 第 {r} 行的「{column}」不是不小于 0 的整数:比率比较的分子、分母要是计数")
return int(value)
@@ -135,7 +148,7 @@ def _verdict(pairs: Sequence[Pair], alpha: Fraction) -> list[Pair]:
def differ(raw: Mapping[str, Any], env: Env) -> Any:
mode = env.mode
used = ["differ_alpha", "min_expected"] if mode == "rate" else ["differ_alpha", "min_n"]
used = used_settings(mode)
snap = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号")
params: dict[str, Any] = {"query": snap.ref}
by_period = raw.get("period") is not None
@@ -230,7 +243,8 @@ def _groups(snap: Snap, group_col: str, cols: Mapping[str, str], mode: str, by_p
if by_period:
for w, what in ((params["target"], "本期"), (params["base"], "基期")):
if not any(same_value(w, g.key) for g in groups.values()):
raise CallError("period_not_found", f"期次列「{group_col}」里没有「{w}」:{what}要写 {snap.ref} 里有的期次值")
raise CallError("period_not_found", f"期次列「{group_col}」里没有「{w}」:{what}要写 {snap.ref} 里有的期次值",
reason=f"期次列「{group_col}」里没有{what}「{ph.key_text([w])}」")
return groups
@@ -238,7 +252,8 @@ def _find(groups: Mapping[Any, Group], value: Any, column: str) -> Group:
found = next((g for g in groups.values() if same_value(value, g.key)), None)
if found is None:
shown = "、".join(g.name for g in list(groups.values())[:8])
raise CallError("unknown_group", f"「{value}」不是「{column}」里的值:要比的组写 {shown} 里的")
raise CallError("unknown_group", f"「{value}」不是「{column}」里的值:要比的组写 {shown} 里的",
reason=f"分组列「{column}」里没有「{ph.key_text([value])}」这一组")
return found
@@ -261,7 +276,8 @@ def _pairs(raw: Mapping[str, Any], params: dict[str, Any], groups: dict[Any, Gro
params["pairs"] = [[p.a.key, p.b.key] for p in out]
return out
if len(groups) < 2:
raise CallError("too_few_groups", f"「{group_col}」里只有 {len(groups)} 组,至少要两组才比得了")
raise CallError("too_few_groups", f"「{group_col}」里只有 {len(groups)} 组,至少要两组才比得了",
reason=f"分组列「{group_col}」里只有 {len(groups)} 组,至少要两组才比得了")
if raw.get("reference") is not None:
ref = _find(groups, raw.get("reference"), group_col)
params["reference"] = ref.key
@@ -372,4 +388,4 @@ def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fr
return (value > mean, now) if now != differs else None
__all__ = ["TESTS", "differ"]
__all__ = ["TESTS", "differ", "refusal_basis", "used_settings"]
+24 -6
View File
@@ -60,6 +60,7 @@ class Snap:
dialect: str | None = None
columns: list[str] = field(default_factory=list)
rows: list[list[Any]] = field(default_factory=list)
named: list[str] = field(default_factory=list) # 调用里认过的列(按先后,Inputs.settle 记用法用)
def __post_init__(self) -> None:
self.columns = [str(c) for c in self.data.get("columns") or []]
@@ -117,6 +118,8 @@ class Snap:
column = hits[0]
if column.lower() in self.masked:
raise CallError("masked_column", f"「{column}」是遮罩列,不能当{role}:换一列,或者在 SQL 里不选它")
if column not in self.named:
self.named.append(column)
return column
def index(self, column: str) -> int:
@@ -157,18 +160,20 @@ class Inputs:
self._resolve = resolve
self._dialect = dialect
self._snaps: dict[str, Snap] = {}
self._roles: dict[str, str] = {}
self.records: list[AnalysisInput] = []
def snap(self, name: Any, role: str) -> Snap:
"""按名字取一份快照。"""
def snap(self, name: Any, what: str, *, role: str = "data") -> Snap:
"""按名字取一份快照。what 是调用错误里的叫法;role 是这份快照在结论里的角色(第一次取时的为准,settle 用)。"""
if not isinstance(name, str) or not name.strip():
raise CallError("missing_query", f"没写{role}:写本节点查过的查询编号(如 Q3)")
raise CallError("missing_query", f"没写{what}:写本节点查过的查询编号(如 Q3)")
key = name.strip()
if key not in self._snaps:
found = self._resolve(key)
if not isinstance(found, Mapping):
raise CallError("unknown_query", f"没有叫「{key}」的查询结果:{role}写本节点查过的查询编号(如 Q3)")
raise CallError("unknown_query", f"没有叫「{key}」的查询结果:{what}写本节点查过的查询编号(如 Q3)")
self._snaps[key] = Snap(key, found, self._dialect(key, found) if self._dialect else found.get("dialect"))
self._roles[key] = role
return self._snaps[key]
def cell(self, ref: Any, role: str, label: str) -> tuple[Snap, int, str, Fraction | None]:
@@ -176,10 +181,11 @@ class Inputs:
found = _CELL.match(ref.strip()) if isinstance(ref, str) else None
if found is None:
raise CallError("bad_cell", f"{label}要写成单元格「编号.r行.列」(如 Q1.r0.gmv),现在是「{ref}」")
snap = self.snap(found.group("name"), label)
snap = self.snap(found.group("name"), label, role=role)
row = int(found.group("row"))
if row >= len(snap.rows):
raise CallError("row_out_of_range", f"{snap.ref} 没有第 {row} 行(共 {len(snap.rows)} 行,从 r0 数起):改{label}")
raise CallError("row_out_of_range", f"{snap.ref} 没有第 {row} 行(共 {len(snap.rows)} 行,从 r0 数起):改{label}",
reason=f"{label}那一格({ref.strip()})不在:{snap.ref} 这次只有 {len(snap.rows)} 行")
column = snap.column(found.group("column"), label)
value = snap.number(row, column, label)
self.record({"ref": f"{snap.ref}.r{row}.{column}", "role": role, "row": row, "column": column,
@@ -209,6 +215,18 @@ class Inputs:
if entry not in self.records:
self.records.append(entry) # type: ignore[arg-type]
def settle(self) -> None:
"""调用在算出结果之前就算不了时(随数据变化的调用错误记成算不了的结论):取过、还没记下用法的快照照样记一条(角色照
第一次取时的,列是调用里认过的那几列,行记 all),复算才按名字取得回它们、走到同一处。"""
recorded = set()
for item in self.records:
cell = _CELL.match(str(item.get("ref") or "")) if "row" in item else None
recorded.add(cell.group("name") if cell else item.get("ref"))
for key, snap in self._snaps.items():
if key not in recorded:
self.record({"ref": key, "role": self._roles.get(key, "data"), "columns": list(snap.named),
"rows": "all"}, snap)
def as_list(value: Any) -> list[Any]:
"""参数里一个值或一组值:都规范成列表(None 是空列表)。"""
+2 -1
View File
@@ -257,7 +257,8 @@ def week_scheme(sql: str, column: str, dialect: str | None) -> str | None:
return None
refs = {(name_key(c.table), name_key(c.name)) for c in value.find_all(exp.Column)
if not isinstance(c.this, exp.Star)}
texts = [_native_format(node, dialect) for node in value.find_all(exp.TimeToStr, exp.ToChar)] # type: ignore[arg-type]
texts = [_native_format(node, dialect) # type: ignore[arg-type]
for node in value.find_all(exp.TimeToStr, exp.ToChar)]
if len(refs) != 1 or not texts or any(t is None for t in texts):
return None
marks = [m for t in texts for m in _format_marks(t, dialect)] # type: ignore[arg-type]
+16 -4
View File
@@ -40,6 +40,17 @@ from app.analysis.numbers import canon, to_fraction
MAD_SCALE = Fraction(14826, 10000)
#: lag 的上限(周数据同比是 52)
_LAG_MAX = 260
#: 结论里记的阈值
USED: dict[str, tuple[str, ...]] = {
"baseline": ("k_warn", "k_alert", "min_history", "max_history"),
"trend": ("trend_min_points", "trend_alpha", "max_history"),
}
def refusal_basis(op: str, settings: Mapping[str, Any]) -> tuple[list[str], str]:
"""还没算就算不了时结论里记的阈值和判定标准(同 decompose.refusal_basis)。"""
criterion = ph.criterion_baseline if op == "baseline" else ph.criterion_trend
return list(USED[op]), criterion(settings, None, "期")
@dataclass
@@ -147,7 +158,7 @@ def _completeness(env: Env, series: Series, item: pd.Period) -> tuple[str, str |
def baseline(raw: Mapping[str, Any], env: Env) -> Any:
used = ["k_warn", "k_alert", "min_history", "max_history"]
used = list(USED["baseline"])
loaded = _load(raw, env, used)
if not isinstance(loaded, Series):
return loaded
@@ -168,7 +179,8 @@ def baseline(raw: Mapping[str, Any], env: Env) -> Any:
found = next((p for p in series.points if same_value(raw["target"], p[0].raw)), None)
if found is None:
raise CallError("period_not_found", f"期次列「{series.period}」里没有「{raw['target']}」(或那一期为空):"
"本期要写序列里有值的一期,不写就是最后一期")
"本期要写序列里有值的一期,不写就是最后一期",
reason=f"期次列「{series.period}」里没有本期「{ph.key_text([raw['target']])}」(或那一期为空)")
else:
found = series.points[-1]
target, x_level = found
@@ -277,7 +289,7 @@ def run_length(values: list[Fraction]) -> tuple[int, str | None]:
def trend(raw: Mapping[str, Any], env: Env) -> Any:
used = ["trend_min_points", "trend_alpha", "max_history"]
used = list(USED["trend"])
loaded = _load(raw, env, used)
if not isinstance(loaded, Series):
return loaded
@@ -357,4 +369,4 @@ def _tail_gap(series: Series, ordinal: int) -> str:
return "再往前缺期"
__all__ = ["MAD_SCALE", "baseline", "run_length", "trend", "unreadable_text"]
__all__ = ["MAD_SCALE", "USED", "baseline", "refusal_basis", "run_length", "trend", "unreadable_text"]
+7 -3
View File
@@ -13,7 +13,10 @@ driver……),下游条件分支能按它走(周报「超出常规波动
(contract.ledger_entry,带 item),报告的写作目录编成 A{n};每项另发一条 analysis.end(运行详情、运行对比按项对齐)。
**一项算不了不让卡失败。** 算不了(历史不够、本期不完整、截断……)就是这一项的结论(status refused),照实记、发一条
analysis_refused 提醒,接着做下一项。调用写错(列不存在、比率当可加……)是模板的毛病:整张卡失败,说清是哪一项、怎么改。
analysis_refused 提醒,接着做下一项。模板定死了调用,这期的数据里没有要用的那一项(上一层没做成、下钻的段这期没有、期次
这期没有、总数那一格越界或为空、要比的组这期没有,analysis.DATA_ERRORS)不是写错了,同样记成算不了的一项
(run 的 refuse_data_errors)。结构性的调用错误(列不存在、遮罩列、比率当可加、重复行、参数写错……)是模板的毛病:整张卡
失败,说清是哪一项、怎么改。
分析的结果不作口径卡的输入(D16):口径卡读 nodes.<卡>.results 的,按追不到原始数据处理(受管级发布时另有提示)。
"""
@@ -74,8 +77,9 @@ async def run_analysis(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
who = f"分析项「{item_id}」({ANALYSIS_OP_LABEL[op]})"
request = _request(item, state, ctx, who)
try:
# 下钻(parent)只认这张卡里排在前面、已经做完的项:done 里此刻只有它们
result = await analysis_ops.run(request, inputs.get, settings=settings, context=context, parents=done.get)
# 下钻(parent)只认这张卡里排在前面、已经做完的项:done 里此刻只有它们(算不了的也在,下钻那一项照实记成算不了)
result = await analysis_ops.run(request, inputs.get, settings=settings, context=context, parents=done.get,
refuse_data_errors=True)
except CallError as e:
raise NodeError(ctx.node.id, f"{who}:{e.message}。请在分析卡中改正这一项") from None
except Exception as e: # noqa: BLE001 - 算子本身出错:说清是哪一项,原因照实
+5 -3
View File
@@ -52,8 +52,10 @@ CATALOGS = {"shop": {"orders": ORDERS}}
async def analyze(op: str, mode: str | None = None, *, snaps: Sequence[Mapping[str, Any]], today: date = TODAY,
catalogs: Mapping[str, Any] | None = None, probe: Any = None, facts: Any = None,
parents: Mapping[str, Mapping[str, Any]] | None = None, settings: Mapping[str, Any] | None = None,
hypothesis: str | None = None, check: bool = True, **params: Any) -> dict[str, Any]:
"""跑一次 analysis.run;check 时再复算一遍,核对和封存的逐字段相同(hypothesis 除外)。"""
hypothesis: str | None = None, check: bool = True, refuse_data_errors: bool = False,
**params: Any) -> dict[str, Any]:
"""跑一次 analysis.run;check 时再复算一遍,核对和封存的逐字段相同(hypothesis 除外)。refuse_data_errors 同 run(分析卡
传真);复算一律按真。"""
by_ref = {s["ref"]: s for s in snaps}
request: dict[str, Any] = {"op": op, "mode": mode, "params": params}
if hypothesis is not None:
@@ -64,7 +66,7 @@ async def analyze(op: str, mode: str | None = None, *, snaps: Sequence[Mapping[s
if facts is not None:
context["facts"] = facts
result = await analysis.run(request, by_ref.get, settings=effective_settings(settings), context=context,
parents=(parents or {}).get)
parents=(parents or {}).get, refuse_data_errors=refuse_data_errors)
if check:
again = recomputed(result, snaps, parents)
assert same(result, again), _diff(result, again)
+2 -2
View File
@@ -30,7 +30,7 @@ class FakeOps:
monkeypatch.setattr(analysis_ops, "run", self.run)
async def run(self, request: dict[str, Any], resolve: Any, *, settings: Any, context: Any,
parents: Any = None) -> dict[str, Any]:
parents: Any = None, refuse_data_errors: bool = False) -> dict[str, Any]:
params = request.get("params") or {}
names = [params[k] for k in ("query", "base_query", "target_query") if params.get(k)]
snapshots = {name: resolve(name) for name in names}
@@ -38,7 +38,7 @@ class FakeOps:
if isinstance(params.get("parent"), dict) and parents is not None:
parent = parents(params["parent"]["of"])
self.calls.append({"request": request, "snapshots": snapshots, "settings": dict(settings), "context": context,
"parent": parent, "parents": parents,
"parent": parent, "parents": parents, "refuse_data_errors": refuse_data_errors,
"visible": {n: parents is not None and parents(n) is not None for n in self.peek}})
error = self.errors.pop(0) if self.errors else None
if error is not None:
+70
View File
@@ -0,0 +1,70 @@
"""分析卡里随数据变化的调用错误(复核 R6):模板定死了调用,这期的数据里没有要用的那一项(上一层没做成、下钻的段这期没有……)
记成算不了的一项,不让卡失败;结构性的调用错误照旧让卡失败并说清哪一项。接真算子跑(tests/test_analysis_e2e 的虚构零售库
shop),每份结论按封存的工件复算、逐字段相同。"""
from __future__ import annotations
from app.core import artifact_store
from app.engine.runner import run_manager
from tests.test_analysis_e2e import ( # noqa: F401 - engine_up、shop 是自动用的夹具
BY_REGION, EAST, TOOL, TWO_WEEKS, WEEK, chain, engine_up, events, node, recomputes, shop, wait,
)
ONLY_EAST = (f"SELECT region, {WEEK} AS week, SUM(amount) AS gmv FROM orders "
"WHERE region = '华东' AND day >= '2026-05-11' GROUP BY region, week ORDER BY region, week")
WHERE = {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["region"], **TWO_WEEKS}}
DRILL = {"id": "east", "op": "decompose", "mode": "additive", "from": "east_q",
"params": {"by": ["channel"], **TWO_WEEKS, "parent": {"of": "where", "key": ["华东"]}}}
def card_graph(region_sql, items):
return chain(node("start", "input"), node("by_region", "tool", tool=TOOL, args={"sql": region_sql}),
node("east_q", "tool", tool=TOOL, args={"sql": EAST}),
node("card", "analysis", items=items),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.results.east.phrase }}"}]))
async def _card(region_sql, items):
run = await run_manager.start(graph=card_graph(region_sql, items), input_payload={})
row = await wait(run.id)
return row
async def test_a_drill_under_a_refused_parent_is_refused_and_the_card_goes_on():
row = await _card(ONLY_EAST, [WHERE, DRILL])
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
results = artifact_store.load(finished["artifact"])["results"]
assert results["where"]["status"] == "refused" and results["east"]["status"] == "refused"
east = artifact_store.load(results["east"]["artifact"])
assert east["reason_code"] == "parent_refused"
assert east["reason"] == "上一层「where」没有做成(按 region 只有 1 段,至少要 2 段才拆得开),不能在它下面下钻"
assert row.output["r"] == "变化拆解没有做成:" + east["reason"]
ends = {e["item"]: e for e in await events(row.id, "analysis.end", "card")}
assert ends["east"]["reason_code"] == "parent_refused" and ends["east"]["status"] == "refused"
warned = [e for e in await events(row.id, "log", "card") if e.get("code") == "analysis_refused"]
assert [e["item"] for e in warned] == ["where", "east"]
assert [(e["item"], e["status"]) for e in finished["evidence"]] == [("where", "refused"), ("east", "refused")]
recomputes(artifact_store.load(results["where"]["artifact"]))
recomputes(east)
async def test_a_segment_missing_this_time_is_refused_and_the_card_goes_on():
drill = {**DRILL, "params": {**DRILL["params"], "parent": {"of": "where", "key": ["东北"]}}}
row = await _card(BY_REGION, [WHERE, drill])
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
results = artifact_store.load(finished["artifact"])["results"]
assert results["where"]["status"] == "ok" and results["east"]["status"] == "refused"
east = artifact_store.load(results["east"]["artifact"])
assert east["reason_code"] == "input_unavailable" and east["reason"] == "上一层「where」里没有「东北」这一段"
assert [i["role"] for i in east["inputs"]] == ["parent", "data"]
recomputes(east)
async def test_a_structural_call_error_still_fails_the_card_and_names_the_item():
wrong = {**WHERE, "params": {**WHERE["params"], "by": ["channel"]}}
row = await _card(BY_REGION, [wrong])
assert row.status == "failed"
assert "分析项「where」(变化拆解)" in row.error and "没有「channel」这一列" in row.error
assert "请在分析卡中改正这一项" in row.error
+94
View File
@@ -153,3 +153,97 @@ async def test_ledger_entries_name_the_real_input_snapshots():
def test_the_call_limit_error_is_spoken_with_the_limit():
error = analysis.exhausted({"max_calls": 5})
assert error.code == "max_calls" and error.message == "这个 Agent 这次执行最多做 5 次分析,已经用完:用已有的分析结论写,不再新做分析"
# --------------------------------------------------------------------------
# 随数据变化的调用错误(分析卡传 refuse_data_errors):记成算不了的结论,复算也成立
# --------------------------------------------------------------------------
ONE_REGION = snap("Q6", WEEKLY_SQL, ["region", "week", "gmv"], [["华东", "2026-W41", 820], ["华东", "2026-W42", 700]])
TOTALS = snap("Q7", "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week", ["week", "gmv"],
[["2026-W42", 1926], ["2026-W41", None]])
FACTORS = snap("Q8", "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, AVG(amount) AS aov FROM orders "
"GROUP BY week",
["week", "gmv", "orders", "aov"], [["2026-W41", 2120, 20, 106], ["2026-W42", None, 0, None]])
ONE_GROUP = snap("Q9", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region",
["region", "refunds", "orders"], [["西南", 80, 1000]])
WEEK_RATES = snap("Q10", "SELECT week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY week",
["week", "refunds", "orders"], [["2026-W41", 30, 1000], ["2026-W42", None, 1000]])
REFUSALS = [WEEKLY, SERIES, RATES, ONE_REGION, TOTALS, FACTORS, ONE_GROUP, WEEK_RATES]
BY_REGION = {"query": "Q2", "by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42",
"measure": "gmv"}
async def _refused(op, mode=None, *, parents=None, **params):
"""refuse_data_errors 为真跑两次:逐字节相同、形状照契约、复算逐字段相同(analyze 里按真复算);不传时照旧是调用错误。"""
one = await analyze(op, mode, snaps=REFUSALS, parents=parents, refuse_data_errors=True, **params)
two = await analyze(op, mode, snaps=REFUSALS, parents=parents, refuse_data_errors=True, **params)
assert json.dumps(one, sort_keys=True, ensure_ascii=False) == json.dumps(two, sort_keys=True, ensure_ascii=False)
_assert_shape(one)
assert one["status"] == "refused" and one["phrase"] == f"{c.OP_LABELS[op]}没有做成:{one['reason']}"
error = await call_error(op, mode, snaps=REFUSALS, parents=parents, **params)
assert error.code in analysis.DATA_ERRORS and error.reason == one["reason"]
return one
def test_the_data_errors_are_exported():
assert analysis.DATA_ERRORS == {"parent_refused", "parent_key_missing", "period_not_found", "row_out_of_range",
"headline_empty", "unknown_group", "too_few_groups", "empty_cell"}
assert {"parent_refused", "input_unavailable"} <= set(c.REASONS)
async def test_a_drill_under_a_refused_parent_is_itself_refused():
parent = await analyze("decompose", "additive", snaps=REFUSALS, **{**BY_REGION, "query": "Q6"})
assert parent["reason_code"] == "too_few_segments"
result = await _refused("decompose", "additive", parents={"where": parent},
**{**BY_REGION, "by": ["region"], "parent": {"of": "where", "key": ["华东"]}})
assert result["reason_code"] == "parent_refused"
assert result["reason"] == "上一层「where」没有做成(按地区只有 1 段,至少要 2 段才拆得开),不能在它下面下钻"
# 上一层、取过的快照都记进输入(复算按它们走到同一处);参数照调用原样,阈值同这个模式自己算不了时
assert [i["role"] for i in result["inputs"]] == ["parent", "data"]
assert result["inputs"][1] == {"ref": "Q2", "artifact": WEEKLY["artifact"], "role": "data",
"columns": ["region", "gmv", "week"], "rows": "all"}
assert result["params"]["parent"] == {"of": "where", "key": ["华东"]}
assert result["settings"] == {**{k: c.DEFAULTS[k] for k in ("main_share", "concentration", "offset_ratio",
"flat_pct", "table_top_k")}, "max_drill_depth": 3}
assert result["table"] is None and result["verdict"] is None
async def test_segments_periods_cells_and_groups_missing_this_time():
parent = await analyze("decompose", "additive", snaps=REFUSALS, **BY_REGION)
key = await _refused("decompose", "additive", parents={"A1": parent},
**{**BY_REGION, "parent": {"of": "A1", "key": ["东北"]}})
assert key["reason_code"] == "input_unavailable" and key["reason"] == "上一层「A1」里没有「东北」这一段"
period = await _refused("decompose", "additive", **{**BY_REGION, "target": "2026-W43", "label": "销售额,主要来自华东"})
assert period["reason"] == "期次列「week」里没有本期「2026-W43」" and period["params"]["label"] == "销售额"
row = await _refused("decompose", "additive", **BY_REGION, headline={"base": "Q7.r5.gmv", "target": "Q7.r0.gmv"})
assert row["reason"] == "基期总数那一格(Q7.r5.gmv)不在:Q7 这次只有 2 行"
assert [i["role"] for i in row["inputs"]] == ["data", "headline_base"]
empty = await _refused("decompose", "additive", **BY_REGION, headline={"base": "Q7.r1.gmv", "target": "Q7.r0.gmv"})
assert empty["reason"] == "基期总数那一格(Q7.r1.gmv)是空的"
factor = await _refused("decompose", "factor", query="Q8", period="week", base="2026-W41", target="2026-W42",
product="gmv", factors=["orders", "aov"])
assert factor["reason"] == "本期的「gmv」那一格是空的(Q8 第 1 行)"
target = await _refused("baseline", query="Q1", period="week", measure="gmv", target="2020-01-06")
assert target["reason"] == "期次列「week」里没有本期「2020-01-06」(或那一期为空)"
assert target["settings"] == {k: c.DEFAULTS[k] for k in ("k_warn", "k_alert", "min_history", "max_history")}
side = await _refused("comparable", query="Q2", period="week", base="2026-W40", target="2026-W42")
assert side["reason"] == "期次列「week」里没有基期「2026-W40」" and side["settings"] == {}
rates = {"by": ["region"], "numerator": "refunds", "denominator": "orders"}
group = await _refused("differ", "rate", query="Q3", **rates, pairs=[["华东", "东北"]])
assert group["reason"] == "分组列「region」里没有「东北」这一组"
assert group["settings"] == {"differ_alpha": 0.05, "min_expected": 5}
few = await _refused("differ", "rate", query="Q9", **rates)
assert few["reason"] == "分组列「region」里只有 1 组,至少要两组才比得了"
weeks = {"query": "Q10", "period": "week", "numerator": "refunds", "denominator": "orders"}
gone = await _refused("differ", "rate", **weeks, base="2026-W40", target="2026-W41")
assert gone["reason"] == "期次列「week」里没有基期「2026-W40」"
null = await _refused("differ", "rate", **weeks, base="2026-W41", target="2026-W42")
assert null["reason"] == "「refunds」那一格是空的(Q10 第 1 行)"
async def test_structural_call_errors_still_fail_even_when_data_errors_are_refused():
for params in ({**BY_REGION, "by": ["channel"]}, {**BY_REGION, "by": ["region", "week"]}):
with pytest.raises(CallError) as e:
await analyze("decompose", "additive", snaps=REFUSALS, refuse_data_errors=True, check=False, **params)
assert e.value.code not in analysis.DATA_ERRORS and e.value.reason is None