Files
AgentLab/backend/app/eval/variants.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

214 lines
12 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 做法 9、12;评测-D5、D10)。
变体原样存进 EvalRun.variant(发起时规范化过),实际解析出的模型 id 另存 resolved_models。几项:
- assistant {provider, model}:助手(建图、挑表、复核)用哪个模型;不写按「编排」页助手栏的设置(copilot 那一项),和
问数据页同一条取值链(GenerateIn.provider / model 按请求覆盖,挑表、复核跟着换)。
- writer {provider, model}:写作模型——答案运行里模型节点的缺省(写进图的 defaults)。节点自己点了模型的不覆盖,结果里
照实标出(answer.py 的 own_models)。不写就是各节点自己的、再不然按接入的缺省(和问数据页一样)。
- judge:结论裁判开不开(缺省按设置 eval.judge)。打开了才判,走按需裁判同一条路,花费记在评测那本账上;模型按设置里的
证据裁判模型(按需裁判就是这么取的,变体不另换裁判模型)。文字要点的期望也只在打开时判。
- catalog {mode, snapshot}:读哪一份目录(评测-D5)——live 当前目录、reviewed 只用已核实和已确认的项、off 关目录、snapshot
指定一份目录快照(EvalCatalogSnapshot 的 id,影子起草的或拍好的)。live、reviewed 在开评时都拍一份当前目录的快照,按
快照读:评测一跑几十分钟,期间起草、剖析照常改目录,也不影响这次的可比性(做法 9「不占数据源的队」)。
- repeats:每题重复几次(1–5)。定时跑 1 次,对照缺省 3 次(评测-D10)。
- label:这一臂叫什么(A/B 时给人看,「当前目录」「关目录」……)。
**同一变体**(和上一次比时挑基准用,做法 12):助手、写作两项实际解析出的模型、目录的 mode(指定快照的连同快照 id);
重复次数、裁判开不开不算——它们不改变答案本身。variant_key 是它的指纹。
**目录贡献对照**(做法 12):一组预设的 A/B——当前目录 vs 关,可以再加第三臂「只用已核实和已确认的项」(catalog_preset)。
"""
from __future__ import annotations
import copy
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.eval.cases import CaseInvalid
#: 变体不合规(422)的机器码
VARIANT_INVALID = "eval_variant_invalid"
#: 变体里点名的模型、快照用不了(422)
VARIANT_UNAVAILABLE = "eval_variant_unavailable"
CATALOG_MODES = ("live", "reviewed", "off", "snapshot")
CATALOG_LABEL = {"live": "当前目录", "reviewed": "只用已核实和已确认的项", "off": "关目录", "snapshot": "目录快照"}
REPEATS_MAX = 5
#: 定时跑 1 次,A/B 对照缺省 3 次(评测-D10)
SCHEDULE_REPEATS = 1
PAIR_REPEATS = 3
#: 一次 A/B 最多几臂(目录贡献对照最多三臂)
ARMS_MAX = 3
LABEL_MAX = 40
_KEYS = ("assistant", "writer", "judge", "catalog", "repeats", "label")
def _invalid(message: str, field_name: str) -> CaseInvalid:
return CaseInvalid(message, field_name, VARIANT_INVALID)
def _model_pair(raw: Any, where: str, field_name: str) -> dict[str, str | None]:
if raw in (None, {}):
return {"provider": None, "model": None}
if not isinstance(raw, Mapping) or set(raw) - {"provider", "model"}:
raise _invalid(f"{where}需要是 {{provider, model}} 的对象(都可以留空)", field_name)
out: dict[str, str | None] = {}
for key in ("provider", "model"):
value = raw.get(key)
if value is not None and not isinstance(value, str):
raise _invalid(f"{where}的「{key}」需要是文字", f"{field_name}.{key}")
out[key] = value.strip()[:200] or None if isinstance(value, str) else None
return out
def normalize_variant(raw: Any, *, judge_default: bool = False, repeats_default: int = 1) -> dict[str, Any]:
"""请求里的变体 → 规范化的形状(全部键都在)。不合规抛 CaseInvalid(code=eval_variant_invalid)。"""
if raw in (None, {}):
raw = {}
if not isinstance(raw, Mapping):
raise _invalid("变体需要是一个对象", "variant")
unknown = sorted(set(raw) - set(_KEYS))
if unknown:
raise _invalid(f"变体里不认识这些项:{'、'.join(unknown)}(认识的:{'、'.join(_KEYS)})", f"variant.{unknown[0]}")
judge = raw.get("judge", judge_default)
if not isinstance(judge, bool):
raise _invalid("变体的「结论裁判」需要是 true 或 false", "variant.judge")
catalog = raw.get("catalog") or {"mode": "live"}
if isinstance(catalog, str):
catalog = {"mode": catalog}
if not isinstance(catalog, Mapping) or catalog.get("mode") not in CATALOG_MODES \
or set(catalog) - {"mode", "snapshot"}:
raise _invalid(f"变体的目录需要是 {{mode, snapshot}},mode 只能是 {'、'.join(CATALOG_MODES)}", "variant.catalog")
snapshot = catalog.get("snapshot")
if catalog["mode"] == "snapshot":
if not isinstance(snapshot, str) or not snapshot.strip():
raise _invalid("目录选「目录快照」时要指定是哪一份快照(snapshot)", "variant.catalog.snapshot")
snapshot = snapshot.strip()
elif snapshot not in (None, ""):
raise _invalid("只有目录选「目录快照」时才指定快照", "variant.catalog.snapshot")
else:
snapshot = None
repeats = raw.get("repeats", repeats_default)
if isinstance(repeats, bool) or not isinstance(repeats, int) or not 1 <= repeats <= REPEATS_MAX:
raise _invalid(f"每题重复的次数需要是 1 到 {REPEATS_MAX} 的整数", "variant.repeats")
label = raw.get("label")
if label is not None and not isinstance(label, str):
raise _invalid("这一臂的名字需要是文字", "variant.label")
return {"assistant": _model_pair(raw.get("assistant"), "助手模型", "variant.assistant"),
"writer": _model_pair(raw.get("writer"), "写作模型", "variant.writer"),
"judge": judge, "catalog": {"mode": catalog["mode"], "snapshot": snapshot}, "repeats": repeats,
"label": (label or "").strip()[:LABEL_MAX] or None}
def catalog_preset(base: Mapping[str, Any], *, reviewed: bool = False) -> list[dict[str, Any]]:
"""「目录的贡献」预设对照:当前目录 vs 关(reviewed 时再加「只用已核实和已确认的项」)。模型、裁判、重复次数照 base。"""
arms = [("live", CATALOG_LABEL["live"]), ("off", CATALOG_LABEL["off"])]
if reviewed:
arms.insert(1, ("reviewed", CATALOG_LABEL["reviewed"]))
return [{**copy.deepcopy(dict(base)), "catalog": {"mode": mode, "snapshot": None}, "label": label}
for mode, label in arms]
def arm_name(variant: Mapping[str, Any], index: int) -> str:
"""这一臂的名字:变体里写了就用,否则按目录(只有目录不同时一看就懂),再不然 A、B、C。"""
return str(variant.get("label") or "") or "ABC"[index]
# ==========================================================================
# 解析:实际用哪个模型、读哪份目录
# ==========================================================================
@dataclass
class Resolved:
"""发起时解析出来的:各角色实际的模型 id、目录快照(工件 id)、目录视图(Run.options['catalog_view'] 的形状)。"""
models: dict[str, Any] = field(default_factory=dict)
snapshot_artifact: str | None = None
view: dict[str, Any] | None = None
async def _model_id(session: AsyncSession, pair: Mapping[str, Any], *, assistant: bool) -> str:
"""这一对 {provider, model} 实际是哪个模型 id。助手没点名的按助手栏的设置;用不了照实拒(VARIANT_UNAVAILABLE)。"""
from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model
try:
if assistant:
from app.api.copilot import GenerateIn, copilot_model_spec
spec = await copilot_model_spec(session, GenerateIn(instruction="eval", provider=pair.get("provider"),
model=pair.get("model")))
else:
spec = ModelSpec(provider=pair.get("provider"), model=pair.get("model"))
_model, model_id = await get_chat_model(session, spec)
except ProviderNotConfigured as e:
what = "助手模型" if assistant else "写作模型"
raise CaseInvalid(f"{what}用不了:{e}", "variant.assistant" if assistant else "variant.writer",
VARIANT_UNAVAILABLE) from e
return model_id
async def resolve_models(session: AsyncSession, variant: Mapping[str, Any]) -> dict[str, Any]:
"""{assistant: 模型 id, writer: 模型 id 或 None(没换写作模型:各节点自己的), judge: 开不开}。"""
writer = variant.get("writer") or {}
return {"assistant": await _model_id(session, variant.get("assistant") or {}, assistant=True),
"writer": await _model_id(session, writer, assistant=False) if (writer.get("provider") or writer.get("model"))
else None,
"judge": bool(variant.get("judge"))}
def view_of(variant: Mapping[str, Any], snapshot_artifact: str | None) -> dict[str, Any]:
"""这一臂的目录视图(data/catalog_view.CatalogView 的形状)。live、reviewed 按开评时拍的快照读。"""
mode = (variant.get("catalog") or {}).get("mode") or "live"
if mode == "off":
return {"mode": "off", "statuses": "all"}
if not snapshot_artifact:
raise ValueError("目录视图要读快照,却没有快照工件")
return {"mode": "snapshot", "statuses": "reviewed" if mode == "reviewed" else "all", "snapshot": snapshot_artifact}
async def snapshot_artifact(session: AsyncSession, variant: Mapping[str, Any]) -> str | None:
"""变体指定的那份目录快照的工件 id(mode=snapshot);别的模式为 None。快照不在、还没起草完的照实拒。"""
from app.db.models_eval import EvalCatalogSnapshot
catalog = variant.get("catalog") or {}
if catalog.get("mode") != "snapshot":
return None
row = await session.get(EvalCatalogSnapshot, str(catalog.get("snapshot") or ""))
if row is None:
raise CaseInvalid("变体指定的目录快照不存在", "variant.catalog.snapshot", VARIANT_UNAVAILABLE)
if not row.artifact_id or row.status not in ("succeeded", "partial"):
raise CaseInvalid("变体指定的目录快照还没有出来(影子起草没做完、停止了或失败了)", "variant.catalog.snapshot",
VARIANT_UNAVAILABLE)
return row.artifact_id
def variant_key(variant: Mapping[str, Any], models: Mapping[str, Any]) -> str:
"""「同一变体」的指纹:助手、写作实际解析出的模型,目录的 mode(指定快照的连同快照 id)。"""
from app.core.artifact_store import canonical_json, content_hash
catalog = variant.get("catalog") or {}
body = {"assistant": models.get("assistant"), "writer": models.get("writer"), "catalog": catalog.get("mode"),
"snapshot": catalog.get("snapshot") if catalog.get("mode") == "snapshot" else None}
return content_hash(canonical_json(body))[:16]
def describe(variant: Mapping[str, Any], models: Mapping[str, Any] | None = None) -> str:
"""给人看的一句:「助手 x · 写作 y · 当前目录 · 裁判关 · 每题 1 次」。"""
models = models or {}
catalog = variant.get("catalog") or {}
parts = [f"助手 {models.get('assistant') or (variant.get('assistant') or {}).get('model') or '缺省'}",
f"写作 {models.get('writer') or (variant.get('writer') or {}).get('model') or '各节点自己的'}",
CATALOG_LABEL.get(catalog.get("mode") or "live", "当前目录"),
f"结论裁判{'开' if variant.get('judge') else '关'}", f"每题 {variant.get('repeats') or 1} 次"]
return " · ".join(parts)
__all__ = ["ARMS_MAX", "CATALOG_LABEL", "CATALOG_MODES", "PAIR_REPEATS", "REPEATS_MAX", "Resolved", "SCHEDULE_REPEATS",
"VARIANT_INVALID", "VARIANT_UNAVAILABLE", "arm_name", "catalog_preset", "describe", "normalize_variant",
"resolve_models", "snapshot_artifact", "variant_key", "view_of"]