Files
AgentLab/backend/app/eval/cleanup.py
T
ThearchyHelios 2371e75f17 评测执行的主干:后台评测、无头作答、评分、统计、对比与 A/B、清理(下一轮第 3 波 W3-c 第一段)
为什么:第 2 波只有题集和期望答案的来源,评测本身跑不起来。这一段把细案 01 做法 9–12、16、17 的后端接上,
量的是用户在问数据页拿到的同一轮,而不是另抄一份编排。

- eval/answer.py:无头作答,串起 answer_ops → chat/turn 的决定 → launch_run(origin=eval,带目录视图、只读、遮罩)
  → 等终态(停在审批的取消、记「评测不代批」;驳回记路径「被驳回」)→ review_of_run → 按同一规则重来一次;
  工作流题按钉住版本的图跑探索运行(不发正式运行,免得被 pick_base 和应用视图取到)。用量按角色记
- eval/runner.py:用 core/job_runtime,一次评测一个任务,题 × 重复 × 臂一项;A/B 几臂共用期望、同一题同时开跑;
  开评拍目录快照、记上传表格版本和表结构哈希的前后;并发不超过 max_parallel 和全局并发的四分之一;每题时限;
  评测单独一本账(开跑前估算、每题预留后找补、触顶不再开新题);止损;能停、只重试没做完的、重启标中断不续跑
- eval/score.py:六种期望、五种结论(量纲不同单列)、维度配对与弱匹配、路径不对直接不通过、口径 dual 两版都报;
  逐题 E1 用口径账本的 compare_queries(总方案 X2,不另写 signals);SCORER_VERSION 可重评(eval/rescore.py,只读封存)
- eval/judging.py:结论裁判走按需裁判同一条路(evidence._judge_now),文字要点由裁判判、单列不计准确率;花费经
  judge.charged_to 记在评测那本账上(engine/judge.py 只加了一个上下文变量和取账本的一处)
- eval/stats.py、eval/compare.py:准确率与可追溯准确率 + Wilson 区间、各项分布、P50/P90、按角色成本、引用率、逐题 E1;
  选基准、逐题转移与归因、指纹差异、A/B 配对表与精确 McNemar;「目录贡献」预设对照(当前 vs 关,可加只用已核实)
- eval/cleanup.py + core/artifact_store.forget:保留最近 N 次 + 钉住的基线 + A/B 另几臂,删结果、明细工件、底下的运行和
  引用行,工件文件一条引用都不剩才删;总表永久保留
- engine/tolerance.py:双盲复算的 _agree / _delta_pct 原样搬来共用(dual_check 改 import,行为不变)
- 设置加 eval 一组(builder 读、admin 写)和 GET /api/settings/eval/spend;题集预算在全站缺省上覆盖,每日上限只能往下收
- api/evals.py:发起(单臂 / A/B / 目录贡献预设)、估算、进度、逐题、停止、重试、重评、钉基线、和上一次比、配对表、清理
- main.py 的 lifespan:影子起草和评测的启动整理、关停(评测先于运行停,停时取消自己发起的运行)
- 测试:test_eval_e2e(dual_check_shop + 剧本模型:按支付日 330 通过且 SQL 等价,按下单日 310 不通过、差在 time;
  目录对照 1:0;和上一次比找到退步、归因做法变);通知事件、定时接口形状的老测试按新事件和新字段更新
2026-10-16 16:38:09 +08:00

127 lines
6.1 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.
"""评测的清理与工件回收(细案 01 做法 16)。
每次评测有几十次运行(作答、作答前后算期望)和它们的查询快照、报告文档,只增不减,库和工件目录会一直涨。按保留规则清掉更早
那几次的**明细**,总表(EvalRun.summary:准确率、区间、分布、和上一次比的计数)永久保留,界面上写「明细已清理」(cleaned_at)。
**保留哪些**(每个题集):最近 keep_runs 次(设置 eval.keep_runs,缺省 20);钉住的基线;以及这些里面 A/B 的另几臂(同一个
pair_id,配对表要两边都在)。还没结束的不动。按时间一刀切会把还在当基线的那次删掉,所以钉住的另算。
**删什么**:逐题结果行、它们的明细工件、底下的运行(作答、算期望的探索运行,来源都是 eval;事件随运行级联删掉)和这些运行
在工件引用表里的引用行。**工件文件**要等引用行删完之后一条引用都不剩才删(core/artifact_store.forget):同样的内容可能被别的
运行引用(同一份表结构快照、同一份查询结果),不能跟着删。评测运行全是探索运行,删正式运行的那道拦截(api/runs.delete_run)
不受影响;这里也只删来源是 eval、已经结束的运行。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import SessionLocal, utcnow
from app.db.models_eval import EvalResult, EvalRun
logger = logging.getLogger(__name__)
#: 删运行时还在这几种状态的不删(运行在跑、停在审批上)
_LIVE = ("queued", "running", "interrupted")
_background: set[asyncio.Task[Any]] = set()
def keep_set(runs: list[EvalRun], keep: int) -> set[str]:
"""要留明细的那几次(见模块说明)。runs 是一个题集的全部评测,新的在前。"""
from app.core.job_runtime import ACTIVE
kept = {r.id for r in runs[:max(1, keep)]} | {r.id for r in runs if r.pinned or r.status in ACTIVE}
pairs = {r.pair_id for r in runs if r.id in kept and r.pair_id}
kept |= {r.id for r in runs if r.pair_id and r.pair_id in pairs}
return kept
async def _clean_one(session: AsyncSession, run: EvalRun) -> tuple[int, int, set[str]]:
"""清一次评测的明细:(删了几项结果, 删了几次运行, 要回收的工件 id)。不提交。"""
from app.db.models import Artifact, Run
results = list((await session.execute(select(EvalResult).where(EvalResult.eval_run_id == run.id))).scalars())
run_ids: set[str] = set()
artifacts: set[str] = set()
for r in results:
if r.run_id:
run_ids.add(r.run_id)
run_ids |= {x for x in r.gold_run_ids or [] if isinstance(x, str)}
if r.details_artifact:
artifacts.add(r.details_artifact)
await session.execute(delete(Artifact).where(Artifact.id == r.details_artifact, Artifact.run_id == "")
.execution_options(synchronize_session=False))
# 这次评测发起的、结果里没记下的运行(重来的前一次、停止时没写进结果的):按来源找
for rid, origin in (await session.execute(select(Run.id, Run.origin).where(Run.origin_kind == "eval"))).all():
if (origin or {}).get("eval_run_id") == run.id:
run_ids.add(rid)
removed = 0
for rid in sorted(run_ids):
row = await session.get(Run, rid)
if row is None or row.origin_kind != "eval" or row.status in _LIVE:
continue
artifacts |= set((await session.execute(select(Artifact.id).where(Artifact.run_id == rid))).scalars())
await session.execute(delete(Artifact).where(Artifact.run_id == rid).execution_options(synchronize_session=False))
await session.delete(row)
removed += 1
for r in results:
await session.delete(r)
run.cleaned_at = utcnow()
return len(results), removed, artifacts
async def clean_suite(suite_id: str, *, keep: int | None = None) -> dict[str, Any]:
"""按保留规则清一个题集:{runs: 清了几次评测, results, deleted_runs, files: 回收了几个工件文件}。"""
from app.api.settings import eval_settings
from app.core.artifact_store import forget
async with SessionLocal() as session:
if keep is None:
keep = int((await eval_settings(session))["keep_runs"])
runs = list((await session.execute(select(EvalRun).where(EvalRun.suite_id == suite_id)
.order_by(EvalRun.created_at.desc()))).scalars())
kept = keep_set(runs, keep)
targets = [r for r in runs if r.id not in kept and r.cleaned_at is None]
results = removed = 0
artifacts: set[str] = set()
for run in targets:
n, m, found = await _clean_one(session, run)
results += n
removed += m
artifacts |= found
await session.commit()
files = await forget(artifacts) if artifacts else []
if targets:
logger.info("评测清理:题集 %s 清了 %d 次评测的明细(%d 项结果、%d 次运行、%d 个工件文件)", suite_id, len(targets),
results, removed, len(files))
return {"runs": len(targets), "results": results, "deleted_runs": removed, "files": len(files)}
def spawn(suite_id: str) -> None:
"""在后台清理(评测收尾时调,不挡收尾)。出错只记日志。"""
if not suite_id:
return
async def go() -> None:
try:
await clean_suite(suite_id)
except Exception: # noqa: BLE001
logger.exception("评测清理出错(题集 %s)", suite_id)
task = asyncio.get_running_loop().create_task(go())
_background.add(task)
task.add_done_callback(_background.discard)
async def drain() -> None:
"""等后台的清理做完(测试、关停用)。"""
while _background:
await asyncio.gather(*list(_background), return_exceptions=True)
__all__ = ["clean_suite", "drain", "keep_set", "spawn"]