mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:51:58 +00:00
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 补:开评拍目录快照、记表结构前后;打开裁判时结论裁判走按需裁判那条路、花费记在评测账上,文字要点单列 - 修:期望反问的题在建图、运行失败(含超时)时把原因写全;改非工作流目标的定时任务时提交后不再读过期对象
144 lines
7.3 KiB
Python
144 lines
7.3 KiB
Python
"""评测的清理与工件回收(app/eval/cleanup.py、core/artifact_store.forget;细案 01 做法 16)。
|
|
|
|
- 保留规则:最近 N 次、钉住的基线、A/B 的另几臂,还没结束的不动;
|
|
- 清掉的:逐题结果、明细工件、底下的运行(作答、算期望的)和它们的工件引用行;总表永久保留,cleaned_at 写明已清理;
|
|
- 工件文件一条引用都不剩才删:被别的运行引用着的同一份内容照留;
|
|
- 清理过的评测不能重试、不能重评。
|
|
题集、问法都是虚构的。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import func, select
|
|
|
|
from app.core import artifact_store
|
|
from app.db.base import SessionLocal, utcnow
|
|
from app.db.models import Artifact, Run
|
|
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 cleanup, runner
|
|
from app.main import app
|
|
from tests import eval_samples as es
|
|
from tests.fixtures.sources import drop_source
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await runner.manager.shutdown()
|
|
await es.drop_suites()
|
|
await run_manager.shutdown()
|
|
|
|
|
|
def test_the_keep_rule():
|
|
now = utcnow()
|
|
|
|
def run(i: int, **extra) -> EvalRun:
|
|
return EvalRun(id=f"r{i}", suite_id="s", status=extra.pop("status", "succeeded"),
|
|
created_at=now - timedelta(minutes=i), **extra)
|
|
|
|
runs = [run(1), run(2, pair_id="r2"), run(3, pair_id="r2"), run(4, pinned=True), run(5), run(6, status="running")]
|
|
kept = cleanup.keep_set(runs, 2)
|
|
assert kept == {"r1", "r2", "r3", "r4", "r6"}, "最近 2 次 + A/B 另一臂 + 钉住的 + 还没结束的"
|
|
|
|
|
|
async def test_forget_only_removes_files_nobody_refers_to():
|
|
lonely = await artifact_store.put_json({"评测": "只有一处引用", "n": 1}, kind="test_forget")
|
|
shared = await artifact_store.put_json({"评测": "两处引用", "n": 2}, kind="test_forget")
|
|
async with SessionLocal() as session:
|
|
session.add(Artifact(id=shared, run_id="x" * 32, kind="test_forget"))
|
|
await session.execute(Artifact.__table__.delete().where(Artifact.id.in_([lonely, shared]),
|
|
Artifact.run_id == ""))
|
|
await session.commit()
|
|
removed = await artifact_store.forget([lonely, shared, "not-a-hash", lonely])
|
|
assert removed == [lonely]
|
|
assert artifact_store.load(lonely) is None and artifact_store.load(shared)["n"] == 2
|
|
async with SessionLocal() as session:
|
|
await session.execute(Artifact.__table__.delete().where(Artifact.id == shared))
|
|
await session.commit()
|
|
assert await artifact_store.forget([shared]) == [shared]
|
|
|
|
|
|
async def test_cleaning_keeps_the_summary_and_shared_files(monkeypatch, tmp_path):
|
|
async def drive(self):
|
|
self.turn.path, self.turn.reply = "reply", "不查了"
|
|
return self.turn
|
|
|
|
monkeypatch.setattr(answer_mod.Driver, "drive", drive)
|
|
name, sid = await es.make_source(tmp_path)
|
|
try:
|
|
suite_id = await es.make_suite(sid)
|
|
made: list[str] = []
|
|
for i in range(3):
|
|
# 中间那一次换一下数:它的期望查询结果只有它自己有(别的两次查到的是同一份,文件共用)
|
|
_amount(tmp_path, 101.0 if i == 1 else 100.0)
|
|
async with SessionLocal() as session:
|
|
from app.db.models_eval import EvalSuite
|
|
from app.eval.variants import normalize_variant
|
|
|
|
suite = await session.get(EvalSuite, suite_id)
|
|
[run] = await runner.start(session, suite, [normalize_variant({})], actor="评测测试")
|
|
await es.wait_eval(run.id)
|
|
made.append(run.id)
|
|
first, middle, last = made
|
|
async with SessionLocal() as session:
|
|
(await session.get(EvalRun, first)).pinned = True
|
|
gold_runs = (await session.execute(select(EvalResult.gold_run_ids).where(
|
|
EvalResult.eval_run_id == middle))).scalar_one()
|
|
owned = list((await session.execute(select(Artifact.id).where(Artifact.run_id.in_(gold_runs)))).scalars())
|
|
details = (await session.execute(select(EvalResult.details_artifact).where(
|
|
EvalResult.eval_run_id == middle))).scalar_one()
|
|
# 同一份内容另有一次运行(不是这次评测的)也引用着:文件要留
|
|
shared = owned[0]
|
|
session.add(Artifact(id=shared, run_id="y" * 32, kind="query_snapshot"))
|
|
await session.commit()
|
|
summary_before = (await _run(middle)).summary
|
|
out = await cleanup.clean_suite(suite_id, keep=1)
|
|
assert out["runs"] == 1 and out["results"] == 1 and out["deleted_runs"] == 2 and out["files"] >= 1
|
|
cleaned = await _run(middle)
|
|
assert cleaned.cleaned_at is not None and cleaned.summary == summary_before, "总表永久保留"
|
|
assert (await _run(first)).cleaned_at is None and (await _run(last)).cleaned_at is None
|
|
async with SessionLocal() as session:
|
|
assert await session.scalar(select(func.count()).select_from(EvalResult).where(
|
|
EvalResult.eval_run_id == middle)) == 0
|
|
assert await session.scalar(select(func.count()).select_from(Run).where(Run.id.in_(gold_runs))) == 0
|
|
assert await session.scalar(select(func.count()).select_from(Artifact).where(
|
|
Artifact.run_id.in_(gold_runs))) == 0
|
|
assert artifact_store.load(shared) is not None, "别的运行还引用着的文件不删"
|
|
async with SessionLocal() as session:
|
|
still = set((await session.execute(select(Artifact.id).where(Artifact.id.in_(owned)))).scalars())
|
|
# 同样的内容被另外两次评测的运行引用着的(同一条期望 SQL 查到同一份结果)也留着;一条引用都不剩的才删
|
|
for artifact in owned:
|
|
assert (artifact_store.load(artifact) is not None) == (artifact in still), artifact
|
|
assert any(a not in still for a in owned), "这次评测独有的工件文件删掉了"
|
|
assert artifact_store.load(details) is None, "明细工件跟着删"
|
|
with pytest.raises(runner.EvalRefused):
|
|
await runner.retry(middle)
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
refused = await client.post(f"/api/evals/evaluations/{middle}/rescore")
|
|
assert refused.status_code == 409 and refused.json()["code"] == "eval_details_cleaned"
|
|
compared = (await client.get(f"/api/evals/evaluations/{last}/compare",
|
|
params={"base": middle})).json()
|
|
assert compared["cleaned"] is True and compared["transitions"] is None
|
|
again = (await client.post(f"/api/evals/suites/{suite_id}/cleanup")).json()
|
|
assert again["runs"] == 0, "已经清过的不再清"
|
|
finally:
|
|
await drop_source(sid)
|
|
|
|
|
|
def _amount(tmp_path, value: float) -> None:
|
|
import sqlite3
|
|
|
|
with sqlite3.connect(tmp_path / "shop.db") as db:
|
|
db.execute("UPDATE orders SET amount = ? WHERE id = 1", (value,))
|
|
|
|
|
|
async def _run(run_id: str) -> EvalRun:
|
|
async with SessionLocal() as session:
|
|
return await session.get(EvalRun, run_id)
|