Files
AgentLab/backend/tests/test_eval_schedule.py
T
ThearchyHelios 56ca53ad35 问数据建图用量不再是下限;评测通知链到评测详情;评测接口补界面要的几样
用量(第 1 波遗留):查清楚了,丢的不是浏览器那边——问数据页一直读到 SSE 结束、usage 那一条收得到——而是服务端生成过程
(api/copilot_gen._iter_ops)读到 done、look、ask 那一行就关掉模型的流,流末尾那一段才报的用量(Anthropic 的输出 token、
OpenAI 兼容系的 stream_usage)一次都拿不到,每次建图都记成「估不出」,这一轮的花费多半是下限。改成交出那一行之后先把流读完
再关(_drain,内容丢掉):照约定那一行就是最后一行,读完只多收用量那一段;模型在那一行之后还往下写超过 DRAIN_CHARS 字闲话的
当场关流、照实记成没报全(不为闲话付钱,也不当成报全了)。评测的作答(app/eval/answer.Driver)走同一个 answer_ops,
用量跟着报全,和问数据页同一个口径。test_usage_meter:原先断言「输出 token 为 0、记成估不出」的那条改成报全;另加 look 后、
闲话超限、评测和问数据页收到同一份用量三条;去掉修复回跑,三条都抓得住。providers/usage.py 的说明跟着改。

通知:notify_eval 的链接从定时任务的触发历史改成这次评测的详情页 /evals/runs/{id}(评测页这一波上线);测试设上对外访问地址、
断言链接(原来的断言在没设地址时对空列表恒真)。

评测接口(只加不改,app/api/evals.py):题集列表和详情带 latest(最近一次评测)、trend(同一变体最近几次评完的准确率,旧的
在前,换了模型、目录的不连成一条线)、schedules(目标是它的定时任务);评测详情带 actions(停止、只重试没做完的、重新评分、
钉住 / 取消基线能不能做,和接口拒收的条件同一份)和没做完的项数;POST /suites/{id}/cases/preview 写 SQL 定期望的预览
(同期望的 SQL 探针,交回列和前 50 行,遮罩列照保存时那一道拒收、没过守卫的指明是哪一格,都不回 422);GET /signals
「口径命中与重复度」的判据和分档、窗口趋势(数只出自 ledger/stats.compute_stats)、成员不少于 3 的问题类(只出自
chat/questions,评测运行不进)。为什么放服务端:界面层不放逻辑,统计、能不能做都由服务端给。tests/test_evals_ui_api.py。
2026-10-16 19:41:57 +08:00

289 lines
15 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.
"""评测题集的定时与通知(app/eval/scheduled.py、notify/service.notify_eval;细案 01 做法 15,总方案 X4、X6)。
- 定时接口按 target_kind 收评测题集:核对目标交给 app/eval/scheduled.check_target(题集在、没归档、有启用的题);非工作流
目标不收工作流、输入模板、Webhook(Webhook 的事件说的是那一次运行,非工作流目标收不到,照实拒);接口返回目标的种类、id、
名字;工作流的定时行为一字不变(test_schedules_api.py);
- 到点建一次评测:定时跑 1 次,截至时间是计划触发时间,ScheduleFire.eval_run_id 指向它;上一次还没结束就 skipped_overlap;
错过的按宽限补(同一套调度循环);
- 有一道题从通过变成不通过推「定时评测有退步」,没评完推「定时评测没有评完」:只推订阅了的全局渠道,消息里只有题集名、
通过数、退步题数、链接,不放期望值和答案里的数。手动发起的评测不推(叫法照实带「定时」)。题集名、渠道都是虚构的。
"""
from __future__ import annotations
import asyncio
import json
import uuid
from datetime import timedelta, timezone
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
from app.core.crypto import encrypt
from app.db.base import SessionLocal, utcnow
from app.db.models import NotifyChannel, Schedule
from app.db.models_eval import EvalResult, EvalRun
from app.engine.runner import run_manager
from app.eval import answer as answer_mod
from app.eval import runner
from app.main import app
from app.notify import core
from app.notify import service as notify
from app.scheduler import loop, service
from tests import eval_samples as es
UTC = timezone.utc
URL = "https://hooks.example.com/agentlab/eval-7c2a"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Schedule))
await session.commit()
yield
await runner.manager.shutdown()
await notify.drain()
async with SessionLocal() as session:
await session.execute(delete(Schedule))
await session.execute(delete(NotifyChannel).where(NotifyChannel.name.like("评测通知-%")))
await session.commit()
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
def received(monkeypatch):
got: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
got.append(request)
return httpx.Response(200, json={"ok": True})
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
return got
async def _public_url(url: str):
"""临时设对外访问地址(通知里的链接按它拼),交回复原的函数:别的测试不受影响。"""
from app.core import public_url
from app.db.models import Setting
async with SessionLocal() as session:
row = await session.get(Setting, "notify")
before = dict(row.value or {}) if row is not None else None
if row is None:
session.add(Setting(key="notify", value={"public_base_url": url}))
else:
row.value = {**(row.value or {}), "public_base_url": url}
await session.commit()
await public_url.refresh(session)
async def restore() -> None:
async with SessionLocal() as session:
row = await session.get(Setting, "notify")
if before is None and row is not None:
await session.delete(row)
elif row is not None:
row.value = before
await session.commit()
await public_url.refresh(session)
return restore
def stub(monkeypatch, *, delay: float = 0.0, path: str = "asked"):
async def drive(self):
await asyncio.sleep(delay)
self.turn.path = path
return self.turn
monkeypatch.setattr(answer_mod.Driver, "drive", drive)
ASK = [{"question": "含糊的问题", "expectations": [{"kind": "ask"}]}]
async def _schedule(client, suite_id: str, **extra) -> dict:
made = await client.post("/api/schedules", json={"name": "每周回归", "target_kind": "eval_suite",
"target_id": suite_id, "cron": "0 9 * * 1",
"timezone": "Asia/Shanghai", **extra})
assert made.status_code == 201, made.text
return made.json()
async def test_the_schedule_api_takes_an_eval_suite(client):
suite_id = await es.make_suite("none", name="虚构回归题集", cases=ASK)
body = await _schedule(client, suite_id)
assert (body["target_kind"], body["target_id"], body["target_name"]) == ("eval_suite", suite_id, "虚构回归题集")
assert body["target_label"] == "评测题集" and body["workflow_id"] == "" and body["webhook"] is None
assert body["input_template"] == {} and body["target_exists"] is True
listed = (await client.get("/api/schedules")).json()["items"]
assert [s["target_name"] for s in listed] == ["虚构回归题集"]
for bad, field in (({"target_id": "nope"}, "target_id"), ({"target_id": None}, "target_id"),
({"webhook": {"url": URL}}, "webhook"), ({"input_template": {"a": "b"}}, "input_template"),
({"workflow_id": "wf"}, "workflow_id"), ({"target_kind": "nope"}, "target_kind")):
refused = await client.post("/api/schedules", json={"name": "x", "target_kind": "eval_suite",
"target_id": suite_id, "cron": "0 9 * * 1",
"timezone": "Asia/Shanghai", **bad})
assert refused.status_code == 422 and refused.json()["field"] == field, (bad, refused.text)
# 改名、改时间、停用都照常;目标种类建好不能改
patched = await client.patch(f"/api/schedules/{body['id']}", json={"name": "每天回归", "cron": "0 9 * * *",
"enabled": False, "webhook": None,
"version": body["version"]})
assert patched.status_code == 200, patched.text
assert patched.json()["name"] == "每天回归" and patched.json()["next_run_at"] is None
moved = await client.patch(f"/api/schedules/{body['id']}", json={"target_kind": "workflow",
"version": patched.json()["version"]})
assert moved.status_code == 422 and moved.json()["field"] == "target_kind"
# 题集归档了:照实拒
archived = await es.make_suite("none", name="归档的题集", cases=ASK)
async with SessionLocal() as session:
from app.db.models_eval import EvalSuite
(await session.get(EvalSuite, archived)).archived = True
await session.commit()
refused = await client.post("/api/schedules", json={"name": "x", "target_kind": "eval_suite", "target_id": archived,
"cron": "0 9 * * 1", "timezone": "Asia/Shanghai"})
assert refused.status_code == 422 and "已归档" in refused.json()["detail"]
async def test_firing_starts_one_evaluation_and_skips_on_overlap(client, monkeypatch):
stub(monkeypatch, delay=0.5)
suite_id = await es.make_suite("none", cases=ASK, default_variant={"repeats": 3})
body = await _schedule(client, suite_id)
now = utcnow().replace(microsecond=0)
fire = await service.fire_once(body["id"], now, "manual", actor="王芳")
assert fire.status == "started" and fire.eval_run_id and fire.run_id is None
async with SessionLocal() as session:
run = await session.get(EvalRun, fire.eval_run_id)
assert run.trigger == "schedule" and run.schedule_id == body["id"] and run.repeats == 1, "定时只跑 1 次"
assert run.as_of.replace(tzinfo=UTC) == now and run.created_by == "王芳"
again = await service.fire_once(body["id"], now + timedelta(seconds=1), "manual")
assert again.status == "skipped_overlap" and f"#{run.id[:6]}" in again.reason
same = await service.fire_once(body["id"], now, "manual")
assert same.id == fire.id, "同一次触发只发起一次"
await es.wait_eval(run.id)
history = (await client.get(f"/api/schedules/{body['id']}/fires")).json()["items"]
assert {f["eval_run_id"] for f in history if f["status"] == "started"} == {run.id}
async def test_a_missed_fire_within_grace_is_made_up(client, monkeypatch):
stub(monkeypatch)
suite_id = await es.make_suite("none", cases=ASK)
body = await _schedule(client, suite_id)
due = utcnow().replace(microsecond=0) - timedelta(minutes=20)
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == body["id"]).values(next_run_at=due))
await session.commit()
fires = await loop.fire_due(due + timedelta(minutes=20))
[made_up] = [f for f in fires if f.status == "started"]
assert made_up.kind == "misfire" and made_up.eval_run_id
await es.wait_eval(made_up.eval_run_id)
async def _channel(events: list[str]) -> str:
async with SessionLocal() as session:
row = NotifyChannel(name=f"评测通知-{uuid.uuid4().hex[:6]}", scope="global", format="generic", url=encrypt(URL),
events=events)
session.add(row)
await session.commit()
return row.id
async def test_a_regression_is_pushed_without_numbers(client, monkeypatch, received, tmp_path):
name, sid = await es.make_source(tmp_path)
es.use_copilot(monkeypatch, es.Copilot(name))
es.use_report(monkeypatch)
suite_id = await es.make_suite(sid, name="虚构周回归")
body = await _schedule(client, suite_id)
await _channel(["eval.regressed", "eval.failed"])
first = await service.fire_once(body["id"], utcnow().replace(microsecond=0) - timedelta(minutes=5), "manual")
await es.wait_eval(first.eval_run_id)
assert received == [], "第一次没有可比的、也评完了:不推"
await es.set_notes(sid, {"label": es.NOTES["label"]})
second = await service.fire_once(body["id"], utcnow().replace(microsecond=0), "manual")
await es.wait_eval(second.eval_run_id)
[request] = received
message = json.loads(request.content)
assert message["event"] == "eval.regressed" and message["title"] == "定时评测「虚构周回归」有退步"
text = message["text"]
assert "通过:0 / 1 题" in text and "退步:1 题" in text
for secret in ("330", "310", "gmv", "orders", "paid_date"):
assert secret not in json.dumps(message, ensure_ascii=False), f"通知里不该有 {secret}"
from app.db.models import NotifyDelivery
async with SessionLocal() as session:
[delivery] = (await session.execute(select(NotifyDelivery).where(
NotifyDelivery.subject_id == second.eval_run_id))).scalars().all()
assert delivery.subject_kind == "eval_run" and delivery.status == "ok"
from tests.fixtures.sources import drop_source
await drop_source(sid)
async def test_an_unfinished_scheduled_evaluation_pushes_failed(client, monkeypatch, received):
async def drive(self):
self.turn.path, self.turn.copilot_failed, self.turn.reason = "build_failed", True, "助手本轮未完成"
return self.turn
monkeypatch.setattr(answer_mod.Driver, "drive", drive)
suite_id = await es.make_suite("none", cases=ASK * 4, name="虚构失败题集", budget={"max_parallel": 1})
body = await _schedule(client, suite_id)
await _channel(["eval.failed"])
restore = await _public_url("https://agentlab.example.com")
try:
fire = await service.fire_once(body["id"], utcnow().replace(microsecond=0), "manual")
await es.wait_eval(fire.eval_run_id)
finally:
await restore()
async with SessionLocal() as session:
run = await session.get(EvalRun, fire.eval_run_id)
skipped = (await session.execute(select(EvalResult).where(
EvalResult.eval_run_id == run.id, EvalResult.status == "skipped"))).scalars().all()
assert run.status == "partial" and run.stop_reason.startswith("连续 3 题") and len(skipped) == 1
[request] = received
message = json.loads(request.content)
assert message["event"] == "eval.failed" and message["title"] == "定时评测「虚构失败题集」没有评完"
assert "通过:0 / 3 题" in message["text"]
# 链接去这次评测的详情页(第 4 波评测页上线之前去定时任务的触发历史);设了对外访问地址才有链接
assert [link["url"] for link in message["links"]] == [f"https://agentlab.example.com/evals/runs/{fire.eval_run_id}"], \
message["links"]
def test_the_eval_events_say_they_are_for_scheduled_evaluations():
"""这两个事件只在定时发起的评测收尾时推(runner._conclude 认 trigger == "schedule"):手动发起的那次人自己在看,不推。
叫法得照实说「定时」——只写「评测失败」,人勾上之后手动评测失败了永远收不到,界面上也没处说为什么(复核 L3);
eval.failed 也不只是失败:到了上限、止损停下(partial)同样推,所以说「没有评完」。"""
assert notify.EVENTS["eval.regressed"].label == "定时评测有退步"
assert notify.EVENTS["eval.failed"].label == "定时评测没有评完"
async def test_a_manual_evaluation_that_stops_short_pushes_nothing(client, monkeypatch, received):
"""手动发起的评测止损停下:订阅了「定时评测没有评完」的渠道也不推(叫法说的就是定时的)。"""
async def drive(self):
self.turn.path, self.turn.copilot_failed, self.turn.reason = "build_failed", True, "助手本轮未完成"
return self.turn
monkeypatch.setattr(answer_mod.Driver, "drive", drive)
suite_id = await es.make_suite("none", cases=ASK * 4, name="虚构手动题集", budget={"max_parallel": 1})
await _channel(["eval.regressed", "eval.failed"])
started = await client.post(f"/api/evals/suites/{suite_id}/evaluations", json={})
assert started.status_code == 202, started.text
[ev] = started.json()["evaluations"]
await es.wait_eval(ev["id"])
await notify.drain()
async with SessionLocal() as session:
run = await session.get(EvalRun, ev["id"])
assert run.trigger == "manual" and run.status == "partial" and run.stop_reason.startswith("连续 3 题")
assert received == [], "手动发起的评测不推"