Files
AgentLab/backend/tests/test_eval_e2e.py
T
ThearchyHelios e29adf80d0 评测执行的测试:执行、评分、统计与对比、清理、定时与通知
为什么:照细案 01 的测试清单把这一波的行为钉住,顺带修了跑测试时撞出来的两处。

- test_eval_runner:后台排队、并发不超过 max_parallel 也不超过全局并发的四分之一、同一题集同时只能一次、停止后只重做
  没做完的(评完的不动)、重启标中断不续跑、单次金额上限触顶不再开新题、评测每日账单独记(不占裁判、核查的额度)、
  连续 3 题助手调用失败止损、每题超时取消正在跑的运行、停在审批上的作答取消掉(评测不代批)、接口的估算与进度、设置里
  eval 一组和 /settings/eval/spend
- test_eval_score:五种结论、写出精度和百分数、维度配对与弱匹配、多行 / 名单 / 没有数据 / 反问、路径和期望先于数字、
  dual 两版、允许的相对误差和双盲复算是同一个函数;读封存的答案、逐题 E1 用账本的比对函数;重评只读封存、不调模型
- test_eval_stats:Wilson、精确 McNemar、P50/P90 和手算对照;总表的各项;转移与四种归因;指纹差异;配对表;选基准
- test_eval_cleanup:保留规则;forget 只删一条引用都不剩的文件;清理后总表还在、结果和底下的运行删掉、共用的工件留着
- test_eval_schedule:定时接口收题集(拒工作流、输入模板、Webhook)、到点建一次评测并写 eval_run_id、重叠跳过、错过的按
  宽限补;有退步推「评测有退步」、没评完推「评测失败」,消息里没有期望值和答案里的数
- test_eval_e2e 补:开评拍目录快照、记表结构前后;打开裁判时结论裁判走按需裁判那条路、花费记在评测账上,文字要点单列
- 修:期望反问的题在建图、运行失败(含超时)时把原因写全;改非工作流目标的定时任务时提交后不再读过期对象
2026-10-16 16:49:47 +08:00

167 lines
8.4 KiB
Python

"""评测从头到尾(细案 01 测试 test_eval_e2e):dual_check_shop 的虚构订单库 + 剧本模型。
题目是「9/1–9/7 按支付日、不含退款的销售额」,期望 SQL 算出 330。助手剧本只在查看结果里看到数据目录写着「按支付日期归属」
时才按支付日写(330),没看到就按下单日写(310)。所以:
- 臂「当前目录」按支付日答 330:通过、可追溯、答案的 SQL 和期望 SQL 等价已证明(逐题 E1);
- 臂「关目录」按下单日答 310:不通过,差异方面是 time;
- 「目录贡献」对照(当前 vs 关)的配对表是 1:0,两臂共用期望(同一份期望运行);
- 再评一次(把目录里的业务日期去掉)和上一次比:这题从通过变成不通过,归因「做法变」,指纹里目录快照变了。
评测运行的来源都是 eval,不进使用统计;运行名「评测:题集名·第 n 题」。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run
from app.db.models_eval import EvalResult, EvalRun
from app.engine.origin import counted_runs
from app.engine.runner import run_manager
from app.main import app
from tests import eval_samples as es
from tests.fixtures import dual_check_shop as shop
from tests.fixtures.sources import drop_source
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await es.drop_suites()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def source(tmp_path):
name, sid = await es.make_source(tmp_path)
yield {"name": name, "id": sid}
await drop_source(sid)
async def _results(run_id: str) -> list[EvalResult]:
async with SessionLocal() as session:
return list((await session.execute(select(EvalResult).where(EvalResult.eval_run_id == run_id)
.order_by(EvalResult.seq))).scalars())
async def test_catalog_contribution_pair_end_to_end(client, monkeypatch, source):
es.use_copilot(monkeypatch, es.Copilot(source["name"]))
es.use_report(monkeypatch)
suite_id = await es.make_suite(source["id"])
started = await client.post(f"/api/evals/suites/{suite_id}/evaluations",
json={"preset": "catalog", "variant": {"repeats": 1}})
assert started.status_code == 202, started.text
body = started.json()
live, off = body["evaluations"]
assert (live["arm"], off["arm"]) == ("当前目录", "关目录") and body["pair_id"] == live["id"]
await es.wait_eval(live["id"])
got = (await client.get(f"/api/evals/evaluations/{live['id']}")).json()
assert got["status"] == "succeeded", got
[right] = await _results(live["id"])
[wrong] = await _results(off["id"])
assert right.path == "ran" and right.verdict == "pass", (right.error, right.scores)
assert right.scores[0]["outcome"] == "traced" and right.scores[0]["got"] == 330.0
assert right.sql_match["status"] == "proven", right.sql_match
assert wrong.verdict == "fail" and wrong.scores[0]["outcome"] == "wrong" and wrong.scores[0]["got"] == 310.0
assert wrong.sql_match["status"] == "related" and "time" in wrong.sql_match["aspects"], wrong.sql_match
# 两臂共用期望:同一份期望运行
assert right.gold_run_ids == wrong.gold_run_ids and len(right.gold_run_ids) == 2
assert right.gold["status"] == "ok"
pair = (await client.get(f"/api/evals/pairs/{body['pair_id']}")).json()
[table] = pair["table"]["pairs"]
assert (table["only_a"], table["only_b"], table["both"], table["neither"]) == (1, 0, 0, 0)
assert table["p_value"] == 1.0 and table["note"] == "这批题上看不出差别"
summary = got["summary"]
assert summary["accuracy"]["passed"] == 1 and summary["traced_accuracy"]["passed"] == 1
assert summary["accuracy"]["ci"] is not None and summary["e1"]["passed"] == 1
# 开评时拍了一份当前目录(不占数据源的队),上传表格版本和表结构哈希前后各记一次
async with SessionLocal() as session:
ev = await session.get(EvalRun, live["id"])
assert ev.catalog_snapshot and ev.fingerprint["catalog"].startswith("live:")
assert ev.fingerprint["sources_before"] == ev.fingerprint["sources_after"]
assert ev.fingerprint["sources_changed"] == [] and ev.fingerprint["prompt"]
# 评测运行:来源 eval、不进使用统计、名字写题集和题号
async with SessionLocal() as session:
run = await session.get(Run, right.run_id)
assert run.origin_kind == "eval" and run.origin["role"] == "answer"
assert run.workflow_name == "评测:虚构零售题集·第 1 题"
assert run.options["catalog_view"]["mode"] == "snapshot"
offrun = await session.get(Run, wrong.run_id)
assert offrun.options["catalog_view"] == {"mode": "off", "statuses": "all"}
counted = (await session.execute(select(Run.id).where(
Run.id.in_([right.run_id, wrong.run_id, *right.gold_run_ids]), counted_runs()))).all()
assert counted == []
async def test_comparing_with_the_previous_run_finds_the_regression(client, monkeypatch, source):
es.use_copilot(monkeypatch, es.Copilot(source["name"]))
es.use_report(monkeypatch)
suite_id = await es.make_suite(source["id"])
first = (await client.post(f"/api/evals/suites/{suite_id}/evaluations", json={})).json()["evaluations"][0]
await es.wait_eval(first["id"])
# 目录里的业务日期去掉:助手改按下单日写
await es.set_notes(source["id"], {"label": es.NOTES["label"]})
second = (await client.post(f"/api/evals/suites/{suite_id}/evaluations", json={})).json()["evaluations"][0]
await es.wait_eval(second["id"])
async with SessionLocal() as session:
row = await session.get(EvalRun, second["id"])
assert row.baseline_id == first["id"]
assert row.summary["baseline"]["counts"]["regressed"] == 1
compared = (await client.get(f"/api/evals/evaluations/{second['id']}/compare")).json()
assert compared["base"]["id"] == first["id"]
[item] = compared["transitions"]["items"]
assert item["transition"] == "regressed" and item["cause"] == "method", item
assert "catalog" in {d["key"] for d in compared["fingerprint_diff"]}, compared["fingerprint_diff"]
assert shop.RIGHT != shop.BY_ORDER_DATE
async def test_judge_on_charges_the_eval_ledger_and_text_points_stay_apart(client, monkeypatch, source):
from langchain_core.messages import AIMessage
from app.engine import judge
from app.engine.spend import eval_spend
from app.eval.judging import POINTS_SYSTEM_PROMPT
from app.providers import mock_model
es.use_copilot(monkeypatch, es.Copilot(source["name"]))
def decide(self, messages):
text = "\n".join(str(m.content) for m in messages)
if POINTS_SYSTEM_PROMPT[:20] in text:
return AIMessage(content='[{"point": 1, "verdict": "covered", "reason": "说了按支付日"}]')
return AIMessage(content=es.REPORT)
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
case = es.paid_case(source["id"])
case["expectations"].append({"kind": "text", "points": ["说明按支付日统计"]})
suite_id = await es.make_suite(source["id"], cases=[case])
judge_before = await judge.judge_spend.daily()
ledger_before = (await eval_spend.daily())["calls"]
[ev] = (await client.post(f"/api/evals/suites/{suite_id}/evaluations",
json={"variant": {"judge": True}})).json()["evaluations"]
await es.wait_eval(ev["id"])
[item] = await _results(ev["id"])
assert item.verdict == "pass", item.error
text = next(s for s in item.scores if s["kind"] == "text")
assert text["outcome"] == "covered" and text["required"] is False, "文字要点单列"
assert item.judge["claims"]["calls"] >= 1, "走了按需裁判那条路"
assert set(item.judge["claims"]["counts"]) == {"supported", "partial", "contradicted", "insufficient", "unjudged"}
assert await judge.judge_spend.daily() == judge_before, "裁判的花费记在评测那本账上"
assert (await eval_spend.daily())["calls"] > ledger_before
got = (await client.get(f"/api/evals/evaluations/{ev['id']}")).json()
assert got["summary"]["judge"]["on"] is True and got["summary"]["text"] == {"covered": 1}