Files
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

121 lines
6.2 KiB
Python
Raw Permalink 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 做法 7、17;评测-D9)。
题集:name(必填)、description、timezone(题目里的 {{ schedule.* }} 按它算)、default_variant(发起评测时缺省用的变体,
形状按 app/eval/variants.normalize_variant 核对)、budget(只存改过的项)、archived。改题集带上读到的 version(乐观锁,接口回 409)。
**预算**(评测-D9,用户拍板):缺省见 db/models_eval.DEFAULT_BUDGET——同时 2 题、每题 900 秒、单次评测 2 美元、评测每日
5 美元。全站的缺省在设置 eval 一组(管理员改),题集上改过的项盖过它;daily_max_usd 是评测那本账(engine/spend.eval_spend)
的当日上限:评测单独记账,不挤占裁判、核查的额度,题集只能往下收。执行(预留、找补、触顶不再开新题)见 app/eval/runner.py。
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from app.db.models_eval import DEFAULT_BUDGET
from app.eval.cases import CaseInvalid
#: 题集不合规(422)的机器码
SUITE_INVALID = "eval_suite_invalid"
NAME_MAX = 200
DESCRIPTION_MAX = 2000
#: 变体存成 JSON:太大的多半是传错了东西
VARIANT_MAX = 20_000
#: 预算每一项的取值范围:(说法, 整数还是金额, 下限, 上限)。下限都大于 0:0 美元、0 秒的上限只会让评测一题都开不了
BUDGET_RULES: dict[str, tuple[str, type, float, float]] = {
"max_parallel": ("同时评几道题", int, 1, 8),
"per_case_timeout_s": ("每题时限(秒)", int, 30, 7200),
"per_run_max_usd": ("单次评测的金额上限(美元)", float, 0.01, 1000),
"daily_max_usd": ("评测每日的金额上限(美元)", float, 0.01, 10_000),
}
def _invalid(message: str, field: str) -> CaseInvalid:
return CaseInvalid(message, field, SUITE_INVALID)
def normalize_budget(raw: Any) -> dict[str, Any]:
"""题集里改过的预算项 → 规范化(只留认识的、和缺省不同的)。"""
if raw in (None, {}):
return {}
if not isinstance(raw, Mapping):
raise _invalid("预算需要是一个对象", "budget")
unknown = sorted(set(raw) - set(BUDGET_RULES))
if unknown:
raise _invalid(f"预算里不认识这些项:{'、'.join(unknown)}(认识的:{'、'.join(BUDGET_RULES)})", f"budget.{unknown[0]}")
out: dict[str, Any] = {}
for key, value in raw.items():
label, kind, low, high = BUDGET_RULES[key]
if value is None:
continue
ok = not isinstance(value, bool) and isinstance(value, (int, float)) and (kind is float or isinstance(value, int))
if not ok or not low <= value <= high:
unit = "整数" if kind is int else "数"
raise _invalid(f"{label}需要是 {low:g} 到 {high:g} 之间的{unit}", f"budget.{key}")
value = kind(value)
if value != DEFAULT_BUDGET[key]:
out[key] = value
if out.get("per_run_max_usd", DEFAULT_BUDGET["per_run_max_usd"]) > \
out.get("daily_max_usd", DEFAULT_BUDGET["daily_max_usd"]):
raise _invalid("单次评测的金额上限不能高于评测每日的上限", "budget.per_run_max_usd")
return out
def effective_budget(stored: Mapping[str, Any] | None, site: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""生效的预算:评测-D9 的缺省 ← 设置里 eval 一组(全站,管理员定,api/settings.eval_settings)← 题集改过的项。
评测每日的金额上限是全站那一本账(engine/spend.eval_spend)的上限:题集只能往下收(取两者小的),搭建者改题集不能
越过管理员定的每日上限。"""
base = {**DEFAULT_BUDGET, **{k: v for k, v in (site or {}).items() if k in DEFAULT_BUDGET}}
own = {k: v for k, v in (stored or {}).items() if k in DEFAULT_BUDGET}
out = {**base, **own}
out["daily_max_usd"] = min(float(base["daily_max_usd"]), float(own.get("daily_max_usd", base["daily_max_usd"])))
return out
def normalize_suite(body: Mapping[str, Any], *, current: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""请求体(改的时候和库里的合起来:没带的项照旧)→ 题集各列的值。不合规抛 CaseInvalid(code=eval_suite_invalid)。"""
from app.core.artifact_store import canonical_json
from app.scheduler import cron
if not isinstance(body, Mapping):
raise _invalid("题集需要是一个对象", "name")
cur = dict(current or {})
def pick(key: str, default: Any = None) -> Any:
return body[key] if key in body else cur.get(key, default)
name = pick("name", "")
name = name.strip() if isinstance(name, str) else ""
if not name:
raise _invalid("请填写题集的名称", "name")
if len(name) > NAME_MAX:
raise _invalid(f"题集的名称不能超过 {NAME_MAX} 个字符", "name")
description = pick("description", "") or ""
if not isinstance(description, str) or len(description.strip()) > DESCRIPTION_MAX:
raise _invalid(f"说明需要是不超过 {DESCRIPTION_MAX} 个字符的文字", "description")
try:
timezone = cron.timezone_name(pick("timezone", "Asia/Shanghai") or "")
except cron.TimezoneError as e:
raise _invalid(str(e), "timezone") from e
variant = pick("default_variant", {}) or {}
if not isinstance(variant, Mapping) or len(canonical_json(variant)) > VARIANT_MAX:
raise _invalid("默认变体需要是一个对象", "default_variant")
# 形状按变体的规矩核对(app/eval/variants.py);存的是写进来的样子,没写的项发起时按设置补
from app.eval.variants import normalize_variant
try:
normalize_variant(variant)
except CaseInvalid as e:
raise _invalid(f"默认变体:{e.message}", "default_variant") from e
archived = pick("archived", False)
if not isinstance(archived, bool):
raise _invalid("是否归档需要是 true 或 false", "archived")
return {"name": name, "description": description.strip(), "timezone": timezone, "default_variant": dict(variant),
"budget": normalize_budget(pick("budget", {})), "archived": archived}
__all__ = ["BUDGET_RULES", "SUITE_INVALID", "effective_budget", "normalize_budget", "normalize_suite"]