Files
AgentLab/backend/tests/test_analysis_claims_operators.py
T

192 lines
11 KiB
Python

"""说法核对挂真算子算出来的结论(app/analysis,第 1 波 W1-a):判定、点名的段、方向、新高新低、连续、抵消、结构、差异的两组、
算不了,都按算子真给的形状核(tests/analysis_samples 的样例:虚构零售库 shop)。再经组装和复核走一遍:判定语 [[a:A1]] 和
具名标量 [[a:A1.键]] 整段不扫,[[see:A1]] 当依据。"""
from __future__ import annotations
import pytest
from app.engine import analysis_claims as ac
from app.engine.evidence import build_catalog, compose_doc, make_eid, verify_doc
from tests.analysis_samples import analyze, fixed_prober, snap
SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week"
TYPES = {"region": "text", "week": "text", "gmv": "number"}
WEEKLY = [["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600], ["华北", "2026-W42", 560],
["华南", "2026-W41", 700], ["华南", "2026-W42", 666], ["西南", "2026-W41", 100], ["西南", "2026-W42", 100]]
async def additive(rows=WEEKLY, **params):
s = snap("Q2", SQL, ["region", "week", "gmv"], rows, types=TYPES)
return await analyze("decompose", "additive", snaps=[s], query="Q2", by=["region"], period="week",
base="2026-W41", target="2026-W42", measure="gmv", label="销售额", **params)
def weekly_rows(base: dict, target: dict) -> list[list]:
return [[k, "2026-W41", v] for k, v in base.items()] + [[k, "2026-W42", v] for k, v in target.items()]
def said(text: str, **results) -> list[str]:
"""一句话挂了 results 这几份结论:违规的代码。"""
return [v["code"] for v in ac.check_sentence(ac.Sentence(text=text, analyses=list(results)), results)]
async def test_an_additive_driver_backs_its_segment_and_nothing_else():
a1 = await additive()
assert a1["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
assert said("销售额下降主要来自华东。", A1=a1) == []
assert said("华东拖累了整体。", A1=a1) == []
assert said("销售额下降主要来自华南。", A1=a1) == [ac.MISMATCH]
assert said("华北带动了增长。", A1=a1) == [ac.MISMATCH], "华北下降、占比不到一半,不能说带动"
assert said("各地区普遍下降。", A1=a1) == [ac.MISMATCH]
async def test_a_dispersed_change_backs_only_dispersion():
a1 = await additive(weekly_rows({"华东": 100, "华北": 100, "华南": 100, "西南": 100},
{"华东": 75, "华北": 75, "华南": 75, "西南": 75}))
assert a1["verdict"]["code"] == "dispersed"
assert said("销售额下降分散在各地区。", A1=a1) == []
assert said("销售额下降主要来自华东。", A1=a1) == [ac.MISMATCH]
async def test_a_flat_total_with_opposite_segments_backs_an_offset():
a1 = await additive(weekly_rows({"华东": 500, "华南": 500, "华北": 300}, {"华东": 550, "华南": 450, "华北": 300}))
assert a1["verdict"]["code"] == "flat"
assert said("华东和华南此消彼长。", A1=a1) == []
assert said("销售额基本持平,主要来自华东。", A1=a1) == [ac.MISMATCH]
async def test_a_ratio_decomposition_backs_structure_and_its_main_source():
s = snap("Q1", "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2",
["channel", "week", "refunds", "orders"],
[["线上", "W41", 50, 500], ["线上", "W42", 90, 1000], ["线下", "W41", 10, 1000], ["线下", "W42", 4, 500]])
a1 = await analyze("decompose", "ratio", snaps=[s], query="Q1", by="channel", period="week", base="W41",
target="W42", numerator="refunds", denominator="orders", label="退款率")
assert a1["verdict"] == {"code": "mix_driven", "subjects": [["线上"]], "direction": "up"}
assert said("退款率上升来自结构变化。", A1=a1) == []
assert said("退款率上升主要来自结构变化,线上占比变大。", A1=a1) == [], "判定语就是这么说的"
assert said("退款率上升主要来自线下。", A1=a1) == [ac.MISMATCH]
async def test_a_factor_decomposition_names_its_factor():
s = snap("Q1", "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov FROM orders "
"GROUP BY week", ["week", "gmv", "orders", "aov"],
[["W41", 10000, 100, 100], ["W42", 15000, 150, 100]])
a1 = await analyze("decompose", "factor", snaps=[s], query="Q1", period="week", base="W41", target="W42",
product="gmv", factors=["orders", "aov"], label="销售额")
assert a1["verdict"]["code"] == "driver"
[[factor]] = a1["verdict"]["subjects"]
assert said(f"销售额增长主要来自{factor}的增长。", A1=a1) == []
other = next(r[0] for r in a1["table"]["rows"] if r[0] != factor)
assert said(f"销售额增长主要来自{other}。", A1=a1) == [ac.MISMATCH]
NOISE = [100, 102, 98, 101, 99, 103, 100, 97, 102, 101, 99, 100, 98, 103, 101, 100, 99, 102, 100]
SERIES_SQL = "SELECT DATE(order_date, 'weekday 0', '-6 days') AS week, COUNT(*) AS orders FROM orders GROUP BY 1"
def weeks(values):
from datetime import date, timedelta
start = date(2026, 10, 12) - timedelta(days=7 * (len(values) - 1))
return [[(start + timedelta(days=7 * i)).isoformat(), v] for i, v in enumerate(values)]
async def series(op: str, values, **params):
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
s = snap("Q1", SERIES_SQL, ["week", "orders"], weeks(values), types={"week": "text", "orders": "number"})
return await analyze(op, snaps=[s], probe=probe, query="Q1", period="week", measure="orders", label="订单数",
**params)
async def test_a_baseline_backs_anomalies_records_and_normal_swings_in_the_right_direction():
far = await series("baseline", NOISE + [70])
assert far["verdict"] == {"code": "far_outside_band", "subjects": [], "direction": "down"}
assert said("订单数本周骤降。", A1=far) == []
assert said("订单数本周大幅下降,创历史新低。", A1=far) == []
assert said("订单数本周异常增长。", A1=far) == [ac.MISMATCH]
assert said("订单数创历史新高。", A1=far) == [ac.MISMATCH]
assert said("订单数属于正常波动。", A1=far) == [ac.MISMATCH]
within = await series("baseline", NOISE + [101])
assert within["verdict"]["code"] == "within_band"
assert said("订单数属于正常波动,并未出现明显异常。", A1=within) == []
assert said("订单数异常。", A1=within) == [ac.MISMATCH]
async def test_a_trend_backs_its_direction_and_runs_only_when_long_enough():
rising = await series("trend", [100 + 3 * i + (i % 3) for i in range(12)])
assert rising["verdict"]["code"] == "rising" and rising["values"]["run_direction"] == "up"
assert said("订单数持续上升。", A1=rising) == []
assert said("订单数已连续多周上升。", A1=rising) == []
assert said("订单数持续下降。", A1=rising) == [ac.MISMATCH]
assert said("订单数没有明显趋势。", A1=rising) == [ac.MISMATCH]
short = dict(rising, values={**rising["values"], "run_length": "2"})
assert said("订单数连续上升。", A1=short) == [ac.MISMATCH]
async def test_a_difference_backs_the_higher_group_in_either_order():
rates = snap("Q1", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region",
["region", "refunds", "orders"], [["西南", 80, 1000], ["华东", 40, 1000]])
differs = await analyze("differ", "rate", snaps=[rates], query="Q1", by="region", numerator="refunds",
denominator="orders", label="退款率")
assert differs["verdict"] == {"code": "differs", "subjects": [["西南"], ["华东"]], "direction": "up"}
assert said("西南的退款率显著高于华东。", A1=differs) == []
assert said("华东的退款率明显低于西南。", A1=differs) == []
assert said("西南的退款率显著低于华东。", A1=differs) == [ac.MISMATCH]
small = snap("Q1", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region",
["region", "refunds", "orders"], [["西南", 2, 40], ["华东", 5, 230]])
nothing = await analyze("differ", "rate", snaps=[small], query="Q1", by="region", numerator="refunds",
denominator="orders", label="退款率")
assert nothing["verdict"]["code"] == "no_evidence"
assert said("两地的退款率差异不大。", A1=nothing) == []
assert said("西南的退款率显著高于华东。", A1=nothing) == [ac.MISMATCH]
async def test_a_refused_baseline_backs_no_judgement():
short = await series("baseline", [100, 102, 98, 70])
assert short["status"] == "refused" and short["verdict"] is None and short["reason_code"] == "history_short"
[miss] = ac.check_sentence(ac.Sentence(text="订单数本周异常下降。", analyses=["A1"]), {"A1": short})
assert miss["code"] == ac.MISMATCH and "没有做成" in miss["message"]
# --------------------------------------------------------------------------
# 经组装和复核:判定语、具名标量不扫,[[see:A1]] 当依据
# --------------------------------------------------------------------------
@pytest.fixture
async def driver_catalog():
a1 = await additive()
catalog = build_catalog(nodes={"caliber": {
"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": "f" * 64,
"metrics": [{"id": "gmv", "name": "销售额", "value": 2026, "unit": "元"}]}})
artifact = "e" * 64
catalog["A1"] = {"alias": "A1", "kind": "analysis", "artifact": artifact, "eid": make_eid("analysis", artifact, {}),
"locator": {}, "op": "decompose", "mode": "additive", "status": "ok", "item": "drop",
"inputs": [], "result": a1}
return catalog
@pytest.mark.parametrize("raw", [
"[[a:A1]]。",
"[[a:A1]]:下降主要来自华东。",
"华东占变化的 [[a:A1.top_share|pct]],下降主要来自华东。[[see:A1]]",
"下降主要来自华东。[[see:A1.r0]]",
"下降主要来自华东。[[see:a:A1]]",
])
def test_phrases_and_scalars_are_not_scanned_and_see_markers_bind(driver_catalog, raw):
doc = compose_doc(raw, driver_catalog, analysis_claims="check")
assert [v for v in doc["violations"] if v["code"] in ac.CODES] == [], doc["violations"]
assert [v for v in verify_doc(doc, driver_catalog)["violations"] if v["code"] in ac.CODES] == []
def test_a_contradicting_sentence_next_to_the_phrase_is_caught(driver_catalog):
doc = compose_doc("[[a:A1]]。下降主要来自华南。[[see:A1]]", driver_catalog, analysis_claims="check")
[miss] = [v for v in doc["violations"] if v["code"] in ac.CODES]
assert miss["code"] == ac.MISMATCH and miss["analyses"] == ["A1"] and miss["unit"] and miss["segment"]
assert doc["markdown"][slice(*miss["span"])] == "主要来自"
# 存进文档的目录只留摘要:按工件取不回结论时只能当作没挂(交给复核的是重建的整份目录,这里看清楚退路)
stored = {k: v for k, v in doc["catalog"]["A1"].items() if k != "result"}
assert ac.results_of({"A1": stored}) == {}
assert ac.results_of({"A1": stored}, lambda alias, entry: driver_catalog["A1"]["result"])["A1"]["verdict"]["code"] \
== "driver"