mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
分析卡里随数据变化的调用错误记成算不了的一项,不让卡失败(复核 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:
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 是空列表)。"""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 - 算子本身出错:说清是哪一项,原因照实
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user