Files
AgentLab/backend/tests/test_analysis_e2e.py
T

352 lines
18 KiB
Python
Raw 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.
"""分析入口接真算子跑一遍(不用替身):agent 分析工具(含下钻)、分析卡(波动基线 + 变化拆解)、口径卡的可比性挂钩、
审批恢复时的重放。断言工件、台账、交回模型的那一段、节点产出,并用 analysis.recompute 按封存的工件复算、逐字段相同。
虚构零售库 shop:orders(day, region, channel, amount),2026-03-02 起 12 个整周,每周每个地区、渠道两笔(周三、周日),
最后一周华东掉三成。模型换成脚本(MockChatModel._decide),不调用任何模型。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
from datetime import date, timedelta
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import select
import app.analysis as analysis_ops
from app.analysis import contract
from app.core import artifact_store
from app.core.artifact_store import canonical_json, content_hash
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.runner import run_manager
TOOL = "db_query__shop"
DECOMPOSE = contract.TOOL_NAMES["decompose"]
WEEK = "date(day, '-6 days', 'weekday 1')"
BY_REGION = (f"SELECT region, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' "
"GROUP BY region, week ORDER BY region, week")
TOTALS = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE day >= '2026-05-11' GROUP BY week ORDER BY week"
EAST = (f"SELECT channel, {WEEK} AS week, SUM(amount) AS gmv FROM orders WHERE region = '华东' AND day >= '2026-05-11' "
"GROUP BY channel, week ORDER BY channel, week")
WEEKLY = f"SELECT {WEEK} AS week, SUM(amount) AS gmv FROM orders GROUP BY week ORDER BY week"
TWO_WEEKS = {"period": "week", "base": "2026-05-11", "target": "2026-05-18", "measure": "gmv"}
A1_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], **TWO_WEEKS, "label": "销售额",
"headline": {"base": "Q2.r0.gmv", "target": "Q2.r1.gmv"}, "hypothesis": "下降是不是集中在某个地区"}
A2_ARGS = {"mode": "additive", "query": "Q3", "by": ["channel"], **TWO_WEEKS, "parent": {"of": "A1", "key": ["华东"]},
"hypothesis": "华东是哪个渠道掉的"}
def _orders():
out, i = [], 0
base = {"华东": 400, "华南": 300, "华北": 250, "西南": 50}
for w in range(12):
monday = date(2026, 3, 2) + timedelta(weeks=w)
for region, amount in base.items():
for channel, share in (("线上", 3), ("线下", 2)):
value = amount * share // 5 + (w % 3) * 2
if w == 11 and region == "华东":
value = value * 7 // 10
for offset in (2, 6):
i += 1
out.append((i, (monday + timedelta(days=offset)).isoformat(), region, channel, value))
return out
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
async def shop(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, day TEXT, region TEXT, channel TEXT, amount INTEGER);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", _orders())
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
class Script:
"""Agent 依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。"""
def __init__(self, monkeypatch, calls):
from app.providers import mock_model
self.seen: list[list] = []
def decide(model, messages):
if not model.tools:
return AIMessage(content="收尾")
self.seen.append(list(messages))
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(calls):
name, args = calls[done]
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}])
return AIMessage(content="销售额下降主要来自华东。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
def replies(self):
return [m.content for m in self.seen[-1] if isinstance(m, ToolMessage)]
class Counting:
"""真算子外面包一层只数次数(重放时不该再算)。"""
def __init__(self, monkeypatch):
self.calls = 0
real = analysis_ops.run
async def run(*args, **kwargs):
self.calls += 1
return await real(*args, **kwargs)
monkeypatch.setattr(analysis_ops, "run", run)
async def wait(run_id, statuses=("succeeded", "failed")):
for _ in range(600):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run_id} 没有结束:{row.status}")
async def events(run_id, etype=None, node_id=None):
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()]
def agent_graph(approval="never"):
return chain(node("start", "input"),
node("bot", "agent", prompt="销售额为什么下降", tools=[TOOL, DECOMPOSE], approval=approval),
node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}]))
def recomputes(stored):
"""按封存的工件复算(查询快照、上一层结论都按 sha256 取回),和封存的逐字段相同。"""
again = analysis_ops.recompute(stored, artifact_store.load, load_result=artifact_store.load)
assert contract.same(stored, again), {k: (stored.get(k), again.get(k)) for k in stored if stored.get(k) != again.get(k)}
AGENT_CALLS = [(TOOL, {"sql": BY_REGION}), (TOOL, {"sql": TOTALS}), (TOOL, {"sql": EAST}),
(DECOMPOSE, A1_ARGS), (DECOMPOSE, A2_ARGS)]
# --------------------------------------------------------------------------
# agent 分析工具:拆解、下钻
# --------------------------------------------------------------------------
async def test_an_agent_decomposes_and_drills_down_with_the_real_operator(monkeypatch):
script = Script(monkeypatch, AGENT_CALLS)
run = await run_manager.start(graph=agent_graph(), input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
ends = await events(row.id, "tool.end", "bot")
q1, q2, q3 = (ends[i]["query_artifact"] for i in range(3))
a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"]
a1, a2 = artifact_store.load(a1_artifact), artifact_store.load(a2_artifact)
# 结论:华东是主要来源,下钻到渠道是线上;总数闭合(headline 是 Q2 的两格)
assert a1["status"] == "ok" and a1["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
assert "主要来自华东" in a1["phrase"] and a1["hypothesis"] == "下降是不是集中在某个地区"
assert {i["ref"] for i in a1["inputs"]} >= {"Q1", "Q2.r0.gmv", "Q2.r1.gmv"}
assert a2["verdict"]["code"] == "driver" and a2["verdict"]["subjects"] == [["线上"]]
# 下钻:算子按上一层结论的内容哈希认 parent,就是存下的那份工件
[parent] = [i for i in a2["inputs"] if i.get("role") == "parent"]
assert parent == {"ref": "A1", "artifact": a1_artifact, "role": "parent"}
assert content_hash(canonical_json(a1)) == a1_artifact
# 台账:两条 kind analysis,带 call_id,输入是那几份查询快照(下钻的另有上一层结论)
[finished] = await events(row.id, "node.finished", "bot")
analyses = [e for e in finished["evidence"] if e["kind"] == "analysis"]
assert [(e["call_id"], e["artifact"]) for e in analyses] == [("call_3", a1_artifact), ("call_4", a2_artifact)]
assert set(analyses[0]["inputs"]) == {q1, q2} and set(analyses[1]["inputs"]) == {q3, a1_artifact}
assert analyses[0] == contract.ledger_entry(a1, node_id="bot", exec_no=1, artifact=a1_artifact, call_id="call_3")
# 交回模型的:编号、判定语、关键数
replies = script.replies()
assert [r.split("\n", 1)[0] for r in replies] == ["【证据 Q1】", "【证据 Q2】", "【证据 Q3】", "【分析 A1】", "【分析 A2】"]
body = json.loads(replies[3].split("\n", 1)[1])
assert body["phrase"] == a1["phrase"] and body["verdict"] == "driver" and body["values"] == a1["values"]
assert body["table"]["rows"][0][0] == "华东"
# 按封存的工件复算,逐字段相同
recomputes(a1)
recomputes(a2)
async def test_replaying_after_approvals_does_not_recompute(monkeypatch):
counted = Counting(monkeypatch)
script = Script(monkeypatch, AGENT_CALLS)
run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={})
for _ in AGENT_CALLS:
row = await wait(run.id, ("interrupted", "failed", "succeeded"))
assert row.status == "interrupted", row.error
await run_manager.resume(run.id, {"approved": True})
row = await wait(run.id, ("succeeded", "failed", "interrupted"))
assert row.status == "succeeded", row.error
assert counted.calls == 2, "重放时分析又算了一次"
ends = await events(row.id, "tool.end", "bot")
assert len(ends) == len(AGENT_CALLS)
a1_artifact, a2_artifact = ends[3]["analysis_artifact"], ends[4]["analysis_artifact"]
assert [r.split("\n", 1)[0] for r in script.replies()][-2:] == ["【分析 A1】", "【分析 A2】"]
[parent] = [i for i in artifact_store.load(a2_artifact)["inputs"] if i.get("role") == "parent"]
assert parent["artifact"] == a1_artifact
[finished] = await events(row.id, "node.finished", "bot")
assert [e["artifact"] for e in finished["evidence"] if e["kind"] == "analysis"] == [a1_artifact, a2_artifact]
# --------------------------------------------------------------------------
# 分析卡:波动基线 + 变化拆解
# --------------------------------------------------------------------------
async def test_an_analysis_card_runs_the_real_operators():
items = [
{"id": "wow", "op": "baseline", "from": "weekly", "params": {"period": "week", "measure": "gmv", "label": "销售额"}},
{"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["region"], **TWO_WEEKS, "label": "销售额",
"headline": {"base": "weekly.r10.gmv", "target": "weekly.r11.gmv"}}},
]
graph = chain(node("start", "input"), node("weekly", "tool", tool=TOOL, args={"sql": WEEKLY}),
node("by_region", "tool", tool=TOOL, args={"sql": BY_REGION}),
node("card", "analysis", items=items),
node("out", "output", fields=[{"name": "判定", "value": "{{ nodes.card.results.wow.phrase }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
results = artifact_store.load(finished["artifact"])["results"]
wow, where = results["wow"], results["where"]
assert wow["status"] == "ok" and wow["verdict"] in ("outside_band", "far_outside_band") and wow["direction"] == "down"
assert where["status"] == "ok" and where["verdict"] == "driver" and "主要来自华东" in where["phrase"]
assert row.output["判定"] == wow["phrase"]
baseline = artifact_store.load(wow["artifact"])
# 本期完不完整按现查的表范围判(analysis.make_prober 按这次运行的数据源探 orders.day):探到了,不再说核对不了
[probe] = baseline["probes"]
assert (probe["source"], probe["table"], probe["column"], probe["basis"]) == ("shop", "orders", "day", "live")
assert probe["max"] == "2026-05-24"
assert not [c for c in baseline["caveats"] if c["code"] == "comparability"]
decomposed = artifact_store.load(where["artifact"])
assert decomposed["verdict"]["subjects"] == [["华东"]]
entries = finished["evidence"]
assert [(e["kind"], e["item"], e["artifact"]) for e in entries] == [
("analysis", "wow", wow["artifact"]), ("analysis", "where", where["artifact"])]
recomputes(baseline)
recomputes(decomposed)
# --------------------------------------------------------------------------
# 口径卡的可比性挂钩:真 comparable
# --------------------------------------------------------------------------
def _week_sql(start: str, end: str) -> str:
return f"SELECT SUM(amount) AS gmv FROM orders WHERE day BETWEEN '{start}' AND '{end}'"
@pytest.mark.parametrize("this_week, verdict, has_reasons", [
(("2026-05-18", "2026-05-24"), "comparable", False),
(("2026-05-18", "2026-05-23"), "conditional", True), # 本期 6 天对上期 7 天:窗口长度、周末天数都不一样
])
async def test_a_caliber_card_checks_two_periods_with_the_real_operator(this_week, verdict, has_reasons):
metrics = [{"id": "wow", "name": "销售额环比", "decimals": 4,
"expression": "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv'))"
" / cell(nodes.last_week, 0, 'gmv')"}]
graph = chain(node("start", "input"), node("this_week", "tool", tool=TOOL, args={"sql": _week_sql(*this_week)}),
node("last_week", "tool", tool=TOOL, args={"sql": _week_sql("2026-05-11", "2026-05-17")}),
node("card", "metrics", caliber="周报口径", metrics=metrics),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
[metric] = artifact_store.load(finished["artifact"])["metrics"]
found = metric["comparability"]
stored = artifact_store.load(found["artifact"])
assert stored["op"] == "comparable" and stored["params"]["base_query"] == "last_week"
assert stored["params"]["target_query"] == "this_week"
assert found == {"artifact": found["artifact"], "verdict": verdict, "phrase": stored["phrase"],
"reasons": analysis_ops.comparable_reasons(stored)}
assert bool(found["reasons"]) is has_reasons
# 整期按现查的表范围判
assert any(p.get("table") == "orders" and p.get("basis") == "live" for p in stored["probes"])
recomputes(stored)
assert not [e for e in await events(row.id, "log", "card") if e.get("code") == "metric_comparability_skipped"]
def _region_sql(region: str, start: str, end: str) -> str:
return f"SELECT SUM(amount) AS gmv FROM orders WHERE region = '{region}' AND day BETWEEN '{start}' AND '{end}'"
@pytest.mark.parametrize("south_week, verdict", [
(("2026-05-18", "2026-05-24"), None), # 同一周、只差地区:是两段不是两期,不查
(("2026-05-11", "2026-05-17"), "incomparable"), # 日期和地区都不同:两边除了日期,筛选的还不一样
])
async def test_a_ratio_between_two_regions_is_not_a_period_comparison(south_week, verdict):
"""华东对华南的倍数:两份同形 SQL 只差地区字面量,不做可比性检查,带契约的出口照常正式出具(以前判成两期不可比、降档)。
日期和地区都不同的照查,真算子判不可比,出口记缺口降档。"""
metrics = [{"id": "times", "name": "华东对华南的倍数", "decimals": 4,
"expression": "cell(nodes.east, 0, 'gmv') / cell(nodes.south, 0, 'gmv')"}]
graph = chain(node("start", "input"),
node("east", "tool", tool=TOOL, args={"sql": _region_sql("华东", "2026-05-18", "2026-05-24")}),
node("south", "tool", tool=TOOL, args={"sql": _region_sql("华南", *south_week)}),
node("card", "metrics", caliber="周报口径", metrics=metrics),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}],
contract={"metrics_from": ["card"], "required": ["times"]}))
run = await run_manager.start(graph=graph, input_payload={})
row = await wait(run.id)
assert row.status == "succeeded", row.error
[finished] = await events(row.id, "node.finished", "card")
[metric] = artifact_store.load(finished["artifact"])["metrics"]
issuance = row.output["_issuance"]
assert not [e for e in await events(row.id, "log", "card") if e.get("code") == "metric_comparability_skipped"]
if verdict is None:
assert "comparability" not in metric
assert issuance["tier"] == "formal" and not issuance.get("gaps") and not issuance.get("comparability")
return
found = metric["comparability"]
assert found["verdict"] == verdict and found["reasons"] == ["两边除了日期,筛选的还不一样"]
assert issuance["tier"] == "degraded"
assert issuance["gaps"] == ["指标「华东对华南的倍数」两期不可比:两边除了日期,筛选的还不一样"]
recomputes(artifact_store.load(found["artifact"]))