Files
AgentLab/backend/tests/test_analysis_e2e.py
T
ThearchyHelios 71dc16bc1a 分析能力入口(七):按 W1-a 算子库的实际接口对齐。工具参数名以算子为准:差异检验的均值是 mean / sd / n 或 sum / sumsq / n,逐行是 value,可选 pairs / reference;可比性检查另收可选的 measure、grain(不收 label);分析卡的参数校验用同一份定义,运行当天、目录事实、下钻层数由算子自己记,分析项里写了不收。交给算子的输入是工件库里那份查询快照原样(含 SQL 检查结果 checks、「查询区间里这张表没有数据」no_data、查询当时记下的遮罩),加上出处和遮罩列(数据源设的、按 SQL 认出改了名字的、遮罩运行里查询时就遮掉的),合并查询写明 sqlite 方言;agent 的容器在分析前把每份换成完整快照(每份只取回一次)。探查器改用 analysis.make_prober(数据源名 → 按运行固定版本解析出的源,coverage.Cache 一个节点里共用),剖析事实一律用 analysis.probe.profile_facts(查询工具的覆盖检查也改用它,原来那段同样的取法删掉)。分析次数用完交回 analysis.exhausted 的那句;口径卡的可比性写成 {工件, 判定码, 判定语, analysis.comparable_reasons 拆出的原因},删掉自己拆原因的那段。加真算子的端到端测试:agent 变化拆解并下钻(父结论按内容哈希认)、审批恢复重放不再算、分析卡的波动基线和变化拆解(本期完整按现查的表范围判)、口径卡跑真可比性检查(可比、有条件可比带原因),每份结论都按封存的工件复算、逐字段相同
2026-10-21 13:27:12 +08:00

315 lines
16 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"]