Files
AgentLab/backend/app/eval/cleanup.py
T
ThearchyHelios 0d9f1d9339 用户看得到的列表补决胜键(复核 R-a9):按名称、时间等可能并列的排序后面补上同一个模型的 id,并列时两种库排出来一样
- 实测 /api/evals/calibers、/api/schedules/trigger-options 按 Workflow.name 排序遇同名并列时 SQLite、PostgreSQL 顺序不同。用 AST 逐个过了一遍 order_by:最后一个排序键是 created_at / updated_at / last_active_at / at / name / 各种 *_at 之类、不唯一的列,又不带 DISTINCT、GROUP BY 的,补「, 模型.id」(降序补 id.desc()),共 104 处,分布在 api、apps、auth、blueprints、chat、data、engine、eval、ledger、look、memory、process、providers、scheduler(runs.py 里的三处在上一个提交里)
- 按唯一列(seq、version、唯一的 name)排的不动;按聚合、coalesce 之类表达式排的说明见 db/postgres
2026-10-19 12:07:06 +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(), EvalRun.id.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"]