Files
AgentLab/backend/tests/analysis_samples.py
ThearchyHelios 6e38df4fbf 分析卡里随数据变化的调用错误记成算不了的一项,不让卡失败(复核 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 的规矩补空格
2026-10-21 15:47:30 +08:00

130 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析算子测试用的样例:虚构零售库 shop(地区华东、华南、华北、西南,渠道线上、线下,销售额、订单数、退款),表名、列名、
数字都是编的。analyze 跑一次 analysis.run,顺手用 recompute 复算一遍、核对逐字段相同(contract.same),所以每个用例都同时
钉住「复算和运行逐字段相同」。"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any
from app import analysis
from app.analysis.contract import CallError, effective_settings, same
from app.core.artifact_store import canonical_json, content_hash
#: 测试里的「运行当天」
TODAY = date(2026, 10, 21)
def snap(ref: str, sql: str, columns: Sequence[str], rows: Sequence[Sequence[Any]], *,
types: Mapping[str, str] | None = None, source: str = "shop", truncated: bool = False,
masked: Sequence[str] = (), **extra: Any) -> dict[str, Any]:
"""一份输入快照(InputSnapshot 的形状)。artifact 按内容算,复算按它取回。"""
body = {"source": source, "sql": sql, "columns": list(columns), "rows": [list(r) for r in rows],
"truncated": truncated, **({"column_types": dict(types)} if types else {}), **extra}
return {"ref": ref, "artifact": content_hash(canonical_json(body)), "node_id": "q1", "masked": list(masked), **body}
def item(value: Any, status: str = "confirmed", source: str = "human") -> dict[str, Any]:
return {"value": value, "source": source, "status": status}
def notes(*, columns: Mapping[str, Mapping[str, Any]] | None = None, business_date: str | None = None) -> dict[str, Any]:
"""一张表的目录 notes:columns 是 {列: {label / measure: 值或 item(...)}}。"""
out: dict[str, Any] = {}
if columns:
out["columns"] = {c: {k: v if isinstance(v, Mapping) else item(v) for k, v in spec.items()}
for c, spec in columns.items()}
if business_date:
out["business_date"] = item({"column": business_date, "rule": "下单日", "timezone": "Asia/Shanghai"})
return out
#: orders 表的目录:地区、渠道、实收金额(流量)、退款单数
ORDERS = notes(columns={"region": {"label": "地区"}, "channel": {"label": "渠道"},
"amount": {"label": "销售额", "measure": "flow"},
"refund_flag": {"label": "退款单数", "measure": "flow"},
"order_date": {"label": "下单日"}},
business_date="order_date")
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, 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:
request["hypothesis"] = hypothesis
context: dict[str, Any] = {"today": today, "catalogs": CATALOGS if catalogs is None else catalogs}
if probe is not None:
context["probe"] = probe
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, refuse_data_errors=refuse_data_errors)
if check:
again = recomputed(result, snaps, parents)
assert same(result, again), _diff(result, again)
assert again["hypothesis"] == result["hypothesis"]
return dict(result)
def recomputed(result: Mapping[str, Any], snaps: Sequence[Mapping[str, Any]],
parents: Mapping[str, Mapping[str, Any]] | None = None) -> dict[str, Any]:
"""按结论里记下的复算:快照按 artifact 取回(只交快照内容本身,不交入口加的出处),上一层结论按内容哈希取回。"""
by_artifact = {s["artifact"]: {k: v for k, v in s.items() if k not in ("ref", "artifact", "node_id", "masked")}
for s in snaps}
by_hash = {content_hash(canonical_json(r)): r for r in (parents or {}).values()}
return dict(analysis.recompute(result, by_artifact.get, load_result=by_hash.get)) # type: ignore[arg-type]
def _diff(a: Mapping[str, Any], b: Mapping[str, Any]) -> str:
keys = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
return ";".join(f"{k}: {json.dumps(a.get(k), ensure_ascii=False)} ≠ {json.dumps(b.get(k), ensure_ascii=False)}"
for k in keys)
async def call_error(op: str, mode: str | None = None, **kwargs: Any) -> CallError:
"""跑一次,期望是调用错误。"""
try:
await analyze(op, mode, check=False, **kwargs)
except CallError as e:
return e
raise AssertionError("应当是调用错误")
def fixed_prober(ranges: Mapping[tuple[str, str], tuple[str | None, str | None]] | None = None, *,
bounds: Mapping[str, str] | None = None, at: str = "2026-10-21T09:00:00") -> Any:
"""测试用的探查器:ranges 是 {(表, 列): (最早, 最晚)},bounds 是 {界的 SQL: 日期};没写的查不到。"""
class Prober:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
async def __call__(self, source: str, table: str, column: str) -> dict[str, Any] | None:
self.calls.append((source, table, column))
found = (ranges or {}).get((table, column))
if found is None:
return None
return {"source": source, "table": table, "column": column, "min": found[0], "max": found[1],
"basis": "live", "at": at}
async def bounds(self, source: str, expr: str) -> dict[str, Any] | None:
self.calls.append((source, expr))
value = (bounds or {}).get(expr)
return None if value is None else {"source": source, "expr": expr, "value": value, "basis": "live", "at": at}
def dialect(self, source: str) -> str | None:
return "sqlite"
return Prober()
__all__ = ["CATALOGS", "ORDERS", "TODAY", "analyze", "call_error", "fixed_prober", "item", "notes", "recomputed",
"snap"]