Files
AgentLab/backend/app/api/evals.py
T
ThearchyHelios eb6abffc61 评测逐题结果给出这一题实际问的问题,模板原文另放 question_template
验收:评测结果列表里 question 是原始模板(带 {{ schedule.last_week_start }}),作答运行实际收到的问题已经渲染好了。
results 接口直接取的是 EvalCase.question。

改法:api/evals 的 _asked_questions 按执行时同一个算法、同一组输入渲染:题目模板 + 这次评测的截至时间(as_of,
建评测时就定下、重试不变)+ 题集时区 + 定时的 cron(runner._unit 交给 Driver 的就是这个)。题目在这次评测之后
改过(结果记的 case_hash 对不上现在的)时,现在的模板不一定是当时问的:改取作答运行输入里的 question,也没有
(反问、建图失败没有运行)就给 null,不拿现在的模板编一个。模板原文放 question_template。没有加库表的列:
渲染是确定的,不用另存。

前端:评测页逐题表的问法一格写实际问的问题;模板原文和它不同时(带变量,或题目改过)另起一行「题目原文:…」,
文案进 termsEval。类型补上 question_template。

测试:test_eval_runner 新增一条:带变量的题,结果的 question 和作答时 Driver 收到的一字不差、不含 {{,模板原文在
question_template;没有变量的题两者一样;题目改过、没有作答运行可取时 question 为 null、模板照给。去掉修复回跑挂。
check-evals 的夹具给带变量的那一题配上渲染好的 question 和模板原文,核对逐题表写的是实际问的、原文另起一行,
没有变量的题不多这一行。tsc、check-copy 通过,check-evals 102 项全过(漏到服务端的那个偶发问题另一次提交里修)。
2026-10-17 09:02:57 +08:00

1301 lines
74 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.
"""评测接口(docs/design/next-round/01-效果度量与回归评测.md):题集、题目、期望答案试算、目录快照,评测的发起、进度、结果、
对比、A/B、清理。
整组只给搭建者及以上(main.py 挂 require_role("builder")):期望值和查询原文只给搭建者。这一波没有界面(第 4 波),
接口的形状就是给界面的契约。请求体自己解析、自己校验,错误一律回中文 detail + 机器码(422 {detail, code, field},
同 api/coded.py),表单据 field 把报错落到那一格。
- 题集:GET/POST /suites,GET/PATCH/DELETE /suites/{id}(改要带读到的 version,对不上 409;有评测记录的不能删,只能归档);
GET /suites/{id}/export、POST /suites/import/preview、POST /suites/import(app/eval/transfer.py)。
- 题目:POST /suites/{id}/cases、PATCH/DELETE /suites/{id}/cases/{cid}(改要带 version)。保存时试算一次(app/eval/
gold.py):没过只读守卫、用到遮罩列、取不到值、运行失败、超时、口径升版待处置的,照实回 422、不保存。
POST /suites/{id}/cases/trial 只试算不保存(界面上「试算」按钮),POST /suites/{id}/cases/{cid}/trial 试算已有的题并记下。
- 从运行认定:GET /runs/{run_id}/queries 列出那次运行里的查询,POST /suites/{id}/cases/from-run 拷出一条查询的 SQL 建题
(记确认人、时间、出自哪次运行),之后每次评测按这条 SQL 重算。
- 口径:GET /calibers 列出能选的口径(和蓝图助手同一份清单:每个工作流最新的已发布未退役版本里的源头口径卡,能不能复用、
为什么不能);GET/POST /suites/{id}/cases/{cid}/upgrade 看、处置口径升版(recompute / dual / incomparable 三选一)。
- 目录快照:GET/POST /catalog-snapshots(当前目录)、POST /catalog-snapshots/shadow/estimate(影子起草的计划和估算)、
POST /catalog-snapshots/shadow(后台起草,202)、GET /catalog-snapshots/{id}、POST /catalog-snapshots/{id}/stop。
- 评测(app/eval/runner.py,后台任务):POST /suites/{id}/evaluations/estimate(发起前的估算:几项、按上一次每题平均花费估的
金额、生效的预算、同时评几题);POST /suites/{id}/evaluations(发起,202;variant 一臂,或 arms 几臂的 A/B,或
preset=catalog / catalog_reviewed「目录贡献」预设对照);GET /suites/{id}/evaluations(历次);GET /evaluations/{id}(进度、
总表、和上一次比的计数);GET /evaluations/{id}/results(逐题)、GET /evaluations/{id}/results/{rid}(一题的明细);
POST /evaluations/{id}/stop、/retry(只重做没做完的)、/rescore(按新评分规则重评,只读封存、不调模型)、/pin(钉住当基线);
GET /evaluations/{id}/compare?base=(和上一次或点名的那一次比:指纹差异、逐题转移和归因);GET /pairs/{pair_id}(A/B 配对表和
精确 McNemar);POST /suites/{id}/cleanup(按保留规则清理更早的明细)。「口径命中与重复度」的 E1–E4 直接用口径账本的
GET /api/ledger/stats(总方案 X2:只有一份实现)。
第 4 波评测页(细案 01 第 18 项)缺的几样,补在这里(统计、能不能做都由这里给,界面只显示):
- GET /suites 每个题集另带 latest(最近一次评测的状态和准确率)、trend(同一变体最近几次评完的准确率,旧的在前)、schedules
(目标是这个题集的定时任务)——列表页不再为每个题集再取一遍历次评测;
- GET /evaluations/{id} 另带 actions:停止、只重试没做完的、重新评分、钉住 / 取消基线现在能不能做(和那几个接口拒收的条件
同一份),以及 open(没做完的项数);
- POST /suites/{id}/cases/preview:写 SQL 定期望时的第一步——按此刻渲染、过只读守卫、跑一次和期望同一条路的 SQL 探针,交回
结果的列和前几行(界面上点选格或列);用到遮罩列的照保存时那一道拒收、照实写原因(不回 422);
- GET /signals:「口径命中与重复度」页签的判据(细案 copilot-look-and-caliber-ledger §3.8 的参考线:E1 ≥30% / <10%,
E3 ≥20%)、当前窗口和往前几个窗口各自落在哪一档、成员不少于 3 的问题类(可以加入题集或提取模板)。数都出自
ledger/stats.compute_stats 和 chat/questions(全站各一份),这里只按窗口调它们、按判据分档。每个窗口都要聚一次问题,页面
另调 /api/ledger/stats:问题的向量由 chat/questions 在进程里留一份,同一句话只取一次(配了远程向量模型时不按窗口重发)。
"""
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import actor_of
from app.db.base import SessionLocal, get_session, new_id, utcnow
from app.db.models_eval import EvalCase, EvalCatalogSnapshot, EvalRun, EvalSuite
from app.eval import cases as case_rules
from app.eval import gold as gold_rules
from app.eval import snapshots, transfer
from app.eval.cases import CaseInvalid, case_fields, case_hash, normalize_case, resolve_case
from app.eval.suites import effective_budget, normalize_suite
router = APIRouter(prefix="/api/evals", tags=["evals"])
#: 机器码
VERSION_CONFLICT = "eval_version_conflict"
SUITE_NOT_FOUND = "eval_suite_not_found"
CASE_NOT_FOUND = "eval_case_not_found"
SUITE_HAS_RUNS = "eval_suite_has_runs"
RUN_NOT_FOUND = "eval_run_query_not_found"
SNAPSHOT_NOT_FOUND = "eval_snapshot_not_found"
IMPORT_INVALID = "eval_import_invalid"
#: 导入时同时试算几道题
IMPORT_TRIALS = 4
def _invalid(e: CaseInvalid) -> CodedHTTPException:
return CodedHTTPException(422, e.message, e.code, field=e.field)
async def _body(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
raise CodedHTTPException(422, "请求内容需要是 JSON 对象", case_rules.CASE_INVALID)
return data
def _version(body: dict[str, Any], current: int, what: str) -> int:
version = body.get("version")
if isinstance(version, bool) or not isinstance(version, int):
raise CodedHTTPException(422, "保存时需要带上读到的版本号 version,请刷新后重试", case_rules.CASE_INVALID,
field="version")
if version != current:
raise CodedHTTPException(409, f"这个{what}已被修改(当前是第 {current} 版,提交的是第 {version} 版),"
"请刷新后再保存", VERSION_CONFLICT)
return version
def _iso(moment: Any) -> str | None:
return moment.isoformat() if moment is not None else None
async def _site(session: AsyncSession) -> dict[str, Any]:
"""设置里 eval 一组(全站的预算缺省、保留几次、裁判的缺省开关)。"""
from app.api.settings import eval_settings
return await eval_settings(session)
# ==========================================================================
# 形状
# ==========================================================================
def _suite_out(row: EvalSuite, *, counts: dict[str, int] | None = None, site: dict[str, Any] | None = None
) -> dict[str, Any]:
return {"id": row.id, "name": row.name, "description": row.description, "timezone": row.timezone,
"default_variant": dict(row.default_variant or {}), "budget": dict(row.budget or {}),
"effective_budget": effective_budget(row.budget, site), "archived": bool(row.archived), "version": row.version,
"created_by": row.created_by, "created_at": _iso(row.created_at), "updated_at": _iso(row.updated_at),
**({"counts": counts} if counts is not None else {})}
async def _source_names(session: AsyncSession, rows: list[EvalCase]) -> dict[str, str]:
from app.db.models import DataSource
ids = {s for r in rows for s in (r.datasource_ids or [])} | {
(r.gold or {}).get("datasource_id") for r in rows if (r.gold or {}).get("datasource_id")}
if not ids:
return {}
return dict((await session.execute(select(DataSource.id, DataSource.name).where(DataSource.id.in_(ids)))).all())
async def _upgrade_of(session: AsyncSession, row: EvalCase) -> dict[str, Any] | None:
"""口径题钉的那一版现在怎么样(gold.upgrade_state);读不出那一版的照实写原因。别的题为 None。"""
from app.ledger import derive
gold = row.gold or {}
if gold.get("kind") != "caliber" or row.archived:
return None
try:
pinned = await derive.load_pinned(session, gold["workflow_id"], gold["version"], gold["node_id"])
except derive.NotDerivable as e:
return {"pinned": gold.get("version"), "pending": True, "message": e.message, "code": e.code}
return (await gold_rules.upgrade_state(session, gold, pinned)).to_dict()
def _case_out(row: EvalCase, names: dict[str, str], upgrade: dict[str, Any] | None = None) -> dict[str, Any]:
"""gold 原样给(改题时整份带回来就行);它查的数据源的名字另放在 gold_source。"""
gold_source = (row.gold or {}).get("datasource_id")
return {**case_fields(row),
"gold_source": {"id": gold_source, "name": names.get(gold_source)} if gold_source else None,
"datasources": [{"id": s, "name": names.get(s)} for s in row.datasource_ids or []],
"archived": bool(row.archived), "superseded_by": row.superseded_by, "version": row.version,
"case_hash": row.case_hash, "trial": row.trial, "upgrade": upgrade, "created_by": row.created_by,
"created_at": _iso(row.created_at), "updated_at": _iso(row.updated_at)}
async def _cases_out(session: AsyncSession, rows: list[EvalCase]) -> list[dict[str, Any]]:
names = await _source_names(session, rows)
return [_case_out(r, names, await _upgrade_of(session, r)) for r in rows]
# ==========================================================================
# 读写的小件
# ==========================================================================
async def _suite(session: AsyncSession, suite_id: str) -> EvalSuite:
row = await session.get(EvalSuite, suite_id)
if row is None:
raise CodedHTTPException(404, "题集不存在,可能已被删除", SUITE_NOT_FOUND)
return row
async def _case(session: AsyncSession, suite_id: str, case_id: str) -> EvalCase:
row = await session.get(EvalCase, case_id)
if row is None or row.suite_id != suite_id:
raise CodedHTTPException(404, "题目不存在,可能已被删除", CASE_NOT_FOUND)
return row
def _context(suite: EvalSuite, actor: str | None, **extra: Any) -> gold_rules.Context:
return gold_rules.Context(as_of=utcnow(), tz=suite.timezone, actor=actor, suite_id=suite.id, suite_name=suite.name,
role="gold", extra={"trial": True, **extra})
async def _trial(session: AsyncSession, suite: EvalSuite, fields: dict[str, Any], actor: str | None, *,
case_id: str | None = None, seq: int | None = None) -> gold_rules.GoldOutcome:
"""按此刻试算一次期望。试算要等一次运行跑完(最多 TRIAL_TIMEOUT_S 秒):先结束这个请求的读事务,不一直开着它等
(会话的 expire_on_commit 是关的,读好的对象照用)。"""
await session.commit()
return await gold_rules.compute_gold({**fields, "id": case_id, "seq": seq}, _context(suite, actor))
def _trial_record(outcome: gold_rules.GoldOutcome, actor: str | None) -> dict[str, Any]:
return {**outcome.summary(), "at": utcnow().isoformat(), "by": actor}
def _gate(outcome: gold_rules.GoldOutcome) -> None:
"""保存题目的那一道关:试算没过就照实回 422、不保存。"""
if outcome.ok:
return
reason = outcome.reason or "试算没有通过"
if outcome.status == "skipped":
reason += ":新建、修改的口径题请固定最新的已发布版本,或在这道题上选择升版的处置"
raise CodedHTTPException(422, f"试算没有通过,题目没有保存:{reason}", outcome.code or gold_rules.GOLD_FAILED,
field="gold")
async def _next_seq(session: AsyncSession, suite_id: str) -> int:
top = await session.scalar(select(func.max(EvalCase.seq)).where(EvalCase.suite_id == suite_id))
return int(top or 0) + 1
async def _insert_case(session: AsyncSession, suite: EvalSuite, fields: dict[str, Any], actor: str | None,
trial: dict[str, Any] | None) -> EvalCase:
"""建一道题(提交)。题号撞了(两个请求同时建)重取一次。"""
for _attempt in range(3):
row = EvalCase(suite_id=suite.id, seq=await _next_seq(session, suite.id), case_hash=case_hash(fields),
trial=trial, created_by=actor, **fields)
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
continue
await session.refresh(row)
return row
raise HTTPException(409, "题集刚被别人加了题,请重试")
# ==========================================================================
# 题集
# ==========================================================================
#: 列表里每个题集最多看最近几次评测(最近一次、趋势都从这几次里取);趋势最多几个点
OVERVIEW_RUNS = 30
TREND_POINTS = 8
def _rate_of(run: EvalRun) -> dict[str, Any] | None:
"""一次评测总表里的准确率(stats.rate 的形状:passed、n、rate、ci);还没有总表的为 None。"""
acc = (run.summary or {}).get("accuracy")
return dict(acc) if isinstance(acc, dict) else None
async def _overview(session: AsyncSession, suite_ids: list[str]) -> dict[str, dict[str, Any]]:
"""每个题集:最近一次评测(不论结束没有)、同一变体最近几次评完的准确率(趋势,旧的在前)、目标是它的定时任务。
趋势只连同一变体(fingerprint.variant_key,和上一次比挑基准同一个认法)的几次:换了模型、换了目录的那几次不连成一条线,
免得把「换了配置」读成「变好了 / 变差了」。A/B 的几臂各是一次评测,同一变体的那一臂才进趋势。"""
from app.db.models import Schedule
from app.eval import runner
from app.scheduler import cron
out: dict[str, dict[str, Any]] = {sid: {"latest": None, "trend": [], "schedules": []} for sid in suite_ids}
if not suite_ids:
return out
ranked = select(EvalRun, func.row_number().over(partition_by=EvalRun.suite_id,
order_by=EvalRun.created_at.desc()).label("n")) \
.where(EvalRun.suite_id.in_(suite_ids)).subquery()
from sqlalchemy.orm import aliased
run_alias = aliased(EvalRun, ranked)
rows = list((await session.execute(select(run_alias).where(ranked.c.n <= OVERVIEW_RUNS)
.order_by(run_alias.created_at.desc()))).scalars())
await runner.reconcile_stale(session, rows)
by_suite: dict[str, list[EvalRun]] = {}
for row in rows:
by_suite.setdefault(row.suite_id, []).append(row)
for sid, runs in by_suite.items():
latest = runs[0]
out[sid]["latest"] = {"id": latest.id, "status": latest.status, "running": runner.running(latest),
"arm": latest.arm, "pair_id": latest.pair_id, "trigger": latest.trigger,
"created_at": _iso(latest.created_at), "finished_at": _iso(latest.finished_at),
"counts": dict(latest.counts or {}), "accuracy": _rate_of(latest),
"traced_accuracy": dict((latest.summary or {}).get("traced_accuracy") or {}) or None}
finished = [r for r in runs if r.status in ("succeeded", "partial") and _rate_of(r) is not None]
if finished:
key = (finished[0].fingerprint or {}).get("variant_key")
same = [r for r in finished if (r.fingerprint or {}).get("variant_key") == key][:TREND_POINTS]
out[sid]["trend"] = [{"id": r.id, "created_at": _iso(r.created_at), "arm": r.arm, **(_rate_of(r) or {})}
for r in reversed(same)]
schedules = (await session.execute(select(Schedule).where(
Schedule.target_kind == "eval_suite", Schedule.target_id.in_(suite_ids))
.order_by(Schedule.created_at))).scalars()
for row in schedules:
try:
text = cron.describe(row.cron, row.timezone)
except cron.CronError:
text = f"按 cron 表达式 {row.cron}({row.timezone})"
out[row.target_id]["schedules"].append({"id": row.id, "name": row.name, "enabled": row.enabled,
"description_text": text, "next_run_at": _iso(row.next_run_at)})
return out
@router.get("/suites")
async def list_suites(archived: bool = False, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""题集(归档的不列,archived=true 时一起列)。每个题集另带 latest、trend、schedules(_overview)。"""
query = select(EvalSuite).order_by(EvalSuite.updated_at.desc())
if not archived:
query = query.where(EvalSuite.archived.is_(False))
rows = list((await session.execute(query)).scalars())
site = await _site(session)
counts: dict[str, dict[str, int]] = {r.id: {"cases": 0, "enabled": 0} for r in rows}
if rows:
for suite_id, enabled, n in (await session.execute(
select(EvalCase.suite_id, EvalCase.enabled, func.count()).where(
EvalCase.suite_id.in_(list(counts)), EvalCase.archived.is_(False))
.group_by(EvalCase.suite_id, EvalCase.enabled))).all():
counts[suite_id]["cases"] += n
if enabled:
counts[suite_id]["enabled"] += n
overview = await _overview(session, [r.id for r in rows])
return {"suites": [{**_suite_out(r, counts=counts[r.id], site=site), **overview[r.id]} for r in rows]}
@router.post("/suites", status_code=201)
async def create_suite(request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
try:
values = normalize_suite(await _body(request))
except CaseInvalid as e:
raise _invalid(e) from e
row = EvalSuite(**values, created_by=actor_of(x_actor))
session.add(row)
await session.commit()
await session.refresh(row)
return _suite_out(row, counts={"cases": 0, "enabled": 0}, site=await _site(session))
@router.get("/suites/{suite_id}")
async def get_suite(suite_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""题集和它的题目(归档的题也在,archived 标着)。口径题带上升版状态:pending_upgrades 是待处置的题数(界面顶部提示)。"""
suite = await _suite(session, suite_id)
rows = list((await session.execute(select(EvalCase).where(EvalCase.suite_id == suite_id)
.order_by(EvalCase.seq))).scalars())
out = await _cases_out(session, rows)
active = [c for c in out if not c["archived"]]
return {**_suite_out(suite, counts={"cases": len(active), "enabled": sum(1 for c in active if c["enabled"])},
site=await _site(session)),
**(await _overview(session, [suite_id]))[suite_id],
"cases": out, "pending_upgrades": sum(1 for c in active if (c["upgrade"] or {}).get("pending"))}
@router.patch("/suites/{suite_id}")
async def update_suite(suite_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
suite = await _suite(session, suite_id)
body = await _body(request)
version = _version(body, suite.version, "题集")
current = {k: getattr(suite, k) for k in ("name", "description", "timezone", "default_variant", "budget",
"archived")}
try:
values = normalize_suite(body, current=current)
except CaseInvalid as e:
raise _invalid(e) from e
result = await session.execute(update(EvalSuite).where(EvalSuite.id == suite_id, EvalSuite.version == version)
.values(**values, version=version + 1, updated_at=utcnow())
.execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise CodedHTTPException(409, "这个题集刚被别人修改过,请刷新后再保存", VERSION_CONFLICT)
await session.commit()
await session.refresh(suite)
return await get_suite(suite_id, session)
@router.delete("/suites/{suite_id}", status_code=204)
async def delete_suite(suite_id: str, session: AsyncSession = Depends(get_session)) -> Response:
"""删题集和它的题目。有评测记录的不能删:每次评测的总表永久保留,请改为归档。"""
suite = await _suite(session, suite_id)
runs = await session.scalar(select(func.count()).select_from(EvalRun).where(EvalRun.suite_id == suite_id))
if runs:
raise CodedHTTPException(409, f"这个题集有 {runs} 次评测记录,不能删除(评测的总表要留着)。请改为归档",
SUITE_HAS_RUNS)
await session.delete(suite)
await session.commit()
return Response(status_code=204)
# ==========================================================================
# 题目
# ==========================================================================
async def _validated(session: AsyncSession, suite: EvalSuite, body: dict[str, Any], *,
current: dict[str, Any] | None = None, trusted_gold: bool = False) -> dict[str, Any]:
try:
fields = normalize_case(body, current=current, trusted_gold=trusted_gold)
await resolve_case(session, fields, tz=suite.timezone)
except CaseInvalid as e:
raise _invalid(e) from e
return fields
@router.post("/suites/{suite_id}/cases", status_code=201)
async def create_case(suite_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""建一道题:校验、试算一次(没过不保存),交回题目和这次试算的结果(含期望值)。"""
suite = await _suite(session, suite_id)
actor = actor_of(x_actor)
fields = await _validated(session, suite, await _body(request))
outcome = await _trial(session, suite, fields, actor)
_gate(outcome)
row = await _insert_case(session, suite, fields, actor, _trial_record(outcome, actor))
return {"case": _case_out(row, await _source_names(session, [row]), await _upgrade_of(session, row)),
"trial": outcome.to_dict()}
@router.post("/suites/{suite_id}/cases/trial")
async def trial_unsaved(suite_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""只试算、不保存(界面上写 SQL、点选格或列时的「试算」):交回试算结果,没过的照实写原因(不回 422)。"""
suite = await _suite(session, suite_id)
fields = await _validated(session, suite, await _body(request))
return {"trial": (await _trial(session, suite, fields, actor_of(x_actor))).to_dict()}
#: 预览最多交回几行(界面上点选格或列用;多行期望的取值仍由试算按整份结果算)
PREVIEW_ROWS = 50
@router.post("/suites/{suite_id}/cases/preview")
async def preview_sql(suite_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""写 SQL 定期望的第一步:{datasource_id, sql}(SQL 可以带 {{ schedule.* }})。按此刻渲染、过只读守卫、跑一次和期望同一条路
的 SQL 探针(裸图探索运行,来源 eval),交回结果的列和前 PREVIEW_ROWS 行,界面上据此点选格或列、再去试算。
结果里有遮罩列、取不到结果、运行失败、超时的,照保存时那一道拒收、照实写原因(status 不是 ok,reason、code 同试算)——不回
422:这是在写题的途中,界面把原因落在 SQL 那一格下面。{status, reason, code, columns, rows, row_count, truncated, run_id,
rendered}。"""
from app.core import artifact_store
from app.db.models import Artifact
suite = await _suite(session, suite_id)
body = await _body(request)
probe = {"question": "(预览)", "gold": {"kind": "sql", "datasource_id": body.get("datasource_id"),
"sql": body.get("sql")},
# 只为跑一次探针:「没有数据」这一种期望不取任何格,取不到值时下面照样交回结果
"expectations": [{"id": "preview", "kind": "none"}]}
try:
fields = normalize_case(probe)
await resolve_case(session, fields, tz=suite.timezone)
except CaseInvalid as e:
# 没选数据源、SQL 空着、变量写错、没过只读守卫:照实写原因、指明是哪一格
return {"status": "invalid", "reason": e.message, "code": e.code, "field": e.field, "columns": [], "rows": [],
"row_count": None, "truncated": False, "run_id": None, "rendered": {}}
outcome = await _trial(session, suite, fields, actor_of(x_actor))
out: dict[str, Any] = {"status": outcome.status, "reason": outcome.reason, "code": outcome.code, "columns": [],
"rows": [], "row_count": None, "truncated": False,
"run_id": outcome.run_ids[0] if outcome.run_ids else None,
"rendered": dict(outcome.rendered or {})}
if outcome.status not in ("ok", "no_value") or not outcome.run_ids:
return out
async with SessionLocal() as read:
refs = (await read.execute(select(Artifact.id).where(
Artifact.run_id == outcome.run_ids[0], Artifact.kind == "query_snapshot",
Artifact.node_id == gold_rules.QUERY_NODE).order_by(Artifact.created_at.desc()))).scalars().all()
snapshot = None
for ref in refs:
try:
snapshot = artifact_store.load(ref)
except ValueError:
snapshot = None
if isinstance(snapshot, dict):
break
if not isinstance(snapshot, dict):
return {**out, "status": "failed", "reason": "预览的运行没有留下查询结果", "code": gold_rules.GOLD_FAILED}
rows = [list(r) if isinstance(r, (list, tuple)) else [r] for r in snapshot.get("rows") or []]
return {**out, "status": "ok", "reason": None, "code": None, "columns": [str(c) for c in snapshot.get("columns") or []],
"rows": rows[:PREVIEW_ROWS], "row_count": snapshot.get("row_count", len(rows)),
"truncated": bool(snapshot.get("truncated")) or len(rows) > PREVIEW_ROWS, "shown": min(len(rows), PREVIEW_ROWS)}
@router.patch("/suites/{suite_id}/cases/{case_id}")
async def update_case(suite_id: str, case_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""改一道题(带上读到的 version)。题目内容(指纹)变了要重新试算、没过不保存;只改标签、启用的不试算。"""
suite = await _suite(session, suite_id)
row = await _case(session, suite_id, case_id)
body = await _body(request)
version = _version(body, row.version, "题目")
actor = actor_of(x_actor)
fields = await _validated(session, suite, body, current=case_fields(row))
new_hash = case_hash(fields)
outcome = None
values: dict[str, Any] = {**fields, "case_hash": new_hash}
if new_hash != row.case_hash:
outcome = await _trial(session, suite, fields, actor, case_id=row.id, seq=row.seq)
_gate(outcome)
values["trial"] = _trial_record(outcome, actor)
result = await session.execute(update(EvalCase).where(EvalCase.id == case_id, EvalCase.version == version)
.values(**values, version=version + 1, updated_at=utcnow())
.execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise CodedHTTPException(409, "这道题刚被别人修改过,请刷新后再保存", VERSION_CONFLICT)
await session.commit()
await session.refresh(row)
return {"case": _case_out(row, await _source_names(session, [row]), await _upgrade_of(session, row)),
"trial": outcome.to_dict() if outcome is not None else None}
@router.delete("/suites/{suite_id}/cases/{case_id}", status_code=204)
async def delete_case(suite_id: str, case_id: str, session: AsyncSession = Depends(get_session)) -> Response:
"""删一道题。已有的评测结果照留(按题目 id 和指纹记着,不随题目删)。"""
row = await _case(session, suite_id, case_id)
await session.delete(row)
await session.commit()
return Response(status_code=204)
@router.post("/suites/{suite_id}/cases/{case_id}/trial")
async def trial_case(suite_id: str, case_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""按此刻试算一道已有的题,记进题目的 trial(不带期望值),交回试算结果(含期望值)。不改题目的 version。"""
suite = await _suite(session, suite_id)
row = await _case(session, suite_id, case_id)
actor = actor_of(x_actor)
outcome = await _trial(session, suite, case_fields(row), actor, case_id=row.id, seq=row.seq)
await session.execute(update(EvalCase).where(EvalCase.id == case_id).values(trial=_trial_record(outcome, actor))
.execution_options(synchronize_session=False))
await session.commit()
return {"trial": outcome.to_dict()}
# ==========================================================================
# 从运行认定
# ==========================================================================
async def _run_queries(session: AsyncSession, run_id: str) -> list[dict[str, Any]]:
from app.core import artifact_store
from app.data.catalog import QUERY_SNAPSHOT_KIND
from app.db.models import Artifact
rows = (await session.execute(select(Artifact).where(Artifact.run_id == run_id, Artifact.kind == QUERY_SNAPSHOT_KIND)
.order_by(Artifact.created_at))).scalars().all()
out = []
for ref in rows:
try:
snap = artifact_store.load(ref.id) or {}
except ValueError:
continue
out.append({"artifact": ref.id, "node_id": ref.node_id, "source": snap.get("source"),
"source_id": (ref.meta or {}).get("source_id"), "sql": snap.get("sql"),
"columns": list(snap.get("columns") or []), "row_count": snap.get("row_count"),
"truncated": bool(snap.get("truncated")),
"masked": bool(snap.get("masked_at_query") or snap.get("masked_cells") or snap.get("masked_whole"))})
return out
async def _readable_run(session: AsyncSession, run_id: str) -> Any:
from app.api.deps import can_read_run, current_principal
from app.db.models import Run
run = await session.get(Run, run_id)
if run is None or not await can_read_run(current_principal(), run):
raise CodedHTTPException(404, "运行不存在,可能已被删除", RUN_NOT_FOUND)
return run
@router.get("/runs/{run_id}/queries")
async def run_queries(run_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""一次运行里执行过的查询(查询快照):选一条认定成期望。只给 SQL、列名、行数,不给结果的格。"""
await _readable_run(session, run_id)
return {"queries": await _run_queries(session, run_id)}
@router.post("/suites/{suite_id}/cases/from-run", status_code=201)
async def create_from_run(suite_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""从一次运行里认定一条查询:{run_id, query(查询快照的工件 id), expectations, question?, tags?, datasource_ids?}。
拷出那条查询的 SQL 建 from_run 题,记确认人(就是这次请求的人)、时间、出自哪次运行;之后每次评测按这条 SQL 重算。
问法没给的取那次运行的问题。同样要试算通过才保存。"""
from app.db.models import DataSource
suite = await _suite(session, suite_id)
body = await _body(request)
actor = actor_of(x_actor)
run_id, query = body.get("run_id"), body.get("query")
if not isinstance(run_id, str) or not isinstance(query, str):
raise CodedHTTPException(422, "请指定运行(run_id)和其中的一条查询(query)", case_rules.CASE_INVALID,
field="query")
run = await _readable_run(session, run_id)
picked = next((q for q in await _run_queries(session, run_id) if q["artifact"] == query), None)
if picked is None or not picked.get("sql"):
raise CodedHTTPException(404, "这次运行里没有这条查询", RUN_NOT_FOUND)
source_id = picked.get("source_id")
if not source_id and picked.get("source"):
source_id = await session.scalar(select(DataSource.id).where(DataSource.name == picked["source"]))
if not source_id or await session.get(DataSource, source_id) is None:
raise CodedHTTPException(422, f"这条查询查的数据源「{picked.get('source') or '?'}」已不在,没法按它重算",
case_rules.CASE_INVALID, field="query")
question = body.get("question")
if question in (None, "") and isinstance((run.input or {}).get("question"), str):
question = run.input["question"]
gold = {"kind": "from_run", "datasource_id": source_id, "sql": picked["sql"], "run_id": run_id, "query": query,
"node_id": picked.get("node_id"), "confirmed_by": actor, "confirmed_at": utcnow().isoformat()}
case_body = {k: body[k] for k in ("expectations", "tags", "datasource_ids", "enabled") if k in body}
fields = await _validated(session, suite, {**case_body, "question": question, "gold": gold}, trusted_gold=True)
outcome = await _trial(session, suite, fields, actor)
_gate(outcome)
row = await _insert_case(session, suite, fields, actor, _trial_record(outcome, actor))
return {"case": _case_out(row, await _source_names(session, [row])), "trial": outcome.to_dict()}
# ==========================================================================
# 口径
# ==========================================================================
@router.get("/calibers")
async def list_calibers(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""能选的口径:每个工作流最新的已发布未退役版本里的源头口径卡的每个指标(和蓝图助手同一份,look/calibers.CaliberKind)。
不能复用的照列,写明原因(选了也存不了)。"""
from app.look.calibers import CaliberKind
kind = await CaliberKind.load(session)
return {"calibers": [{"workflow_id": i.workflow_id, "workflow_name": i.workflow_name, "version": i.version,
"level": i.level, "node_id": i.node_id, "card": i.card, "caliber": i.caliber,
"metric_id": i.metric_id, "name": i.name, "unit": i.unit, "reusable": i.reason is None,
"reason": i.reason} for i in kind.items]}
@router.get("/suites/{suite_id}/cases/{case_id}/upgrade")
async def case_upgrade(suite_id: str, case_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
row = await _case(session, suite_id, case_id)
return {"upgrade": await _upgrade_of(session, row), "choices": gold_rules.UPGRADE_LABEL}
@router.post("/suites/{suite_id}/cases/{case_id}/upgrade")
async def dispose_upgrade(suite_id: str, case_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""处置口径升版:{policy: recompute|dual|incomparable, version},没有缺省。
- recompute:改钉新版本(题目指纹变,和上一次比时标不可比);
- dual:新旧两版各算一份期望(旧的那一份照旧可比);
- incomparable:旧题归档(不再评),按新版本另建一道新题、重开基线。
处置后的题同样要试算通过才生效。"""
suite = await _suite(session, suite_id)
row = await _case(session, suite_id, case_id)
body = await _body(request)
version = _version(body, row.version, "题目")
actor = actor_of(x_actor)
policy = body.get("policy")
current = case_fields(row)
try:
planned, _latest = await gold_rules.plan_dispose(session, current, policy if isinstance(policy, str) else "")
except gold_rules.DisposeRefused as e:
raise CodedHTTPException(e.status, e.message, e.code, field="policy") from e
if isinstance(planned.get("upgrade"), dict):
planned["upgrade"]["by"] = actor
fields = {**current, "gold": planned}
for key in ("id", "suite_id", "seq"):
fields.pop(key, None)
if policy != "dual":
# 改钉到新版本:按新版本再核一遍(入口字段、指标),说得清是哪一项不行;dual 的新版由试算一起算
try:
await resolve_case(session, fields, tz=suite.timezone)
except CaseInvalid as e:
raise _invalid(e) from e
outcome = await _trial(session, suite, fields, actor, case_id=row.id if policy != "incomparable" else None,
seq=row.seq if policy != "incomparable" else None)
_gate(outcome)
if policy == "incomparable":
new = await _insert_case(session, suite, fields, actor, _trial_record(outcome, actor))
result = await session.execute(update(EvalCase).where(EvalCase.id == case_id, EvalCase.version == version)
.values(archived=True, enabled=False, superseded_by=new.id,
version=version + 1, updated_at=utcnow())
.execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.delete(new)
await session.commit()
raise CodedHTTPException(409, "这道题刚被别人修改过,请刷新后再处置", VERSION_CONFLICT)
await session.commit()
await session.refresh(row)
names = await _source_names(session, [row, new])
return {"case": _case_out(row, names), "new_case": _case_out(new, names, await _upgrade_of(session, new)),
"trial": outcome.to_dict()}
result = await session.execute(update(EvalCase).where(EvalCase.id == case_id, EvalCase.version == version)
.values(gold=planned, case_hash=case_hash(fields), trial=_trial_record(outcome, actor),
version=version + 1, updated_at=utcnow())
.execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise CodedHTTPException(409, "这道题刚被别人修改过,请刷新后再处置", VERSION_CONFLICT)
await session.commit()
await session.refresh(row)
return {"case": _case_out(row, await _source_names(session, [row]), await _upgrade_of(session, row)),
"new_case": None, "trial": outcome.to_dict()}
# ==========================================================================
# 导出导入
# ==========================================================================
@router.get("/suites/{suite_id}/export")
async def export_suite(suite_id: str, session: AsyncSession = Depends(get_session)) -> Response:
"""题集导出成 JSON 文件(按数据源名、工作流名对上;不含期望值、答案、运行)。"""
import json
from urllib.parse import quote
suite = await _suite(session, suite_id)
rows = list((await session.execute(select(EvalCase).where(EvalCase.suite_id == suite_id))).scalars())
content = await transfer.export_suite(session, suite, rows)
filename = transfer.attachment_name(suite.name)
return Response(content=json.dumps(content, ensure_ascii=False, indent=2), media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=\"eval-suite-{suite.id}.json\"; "
f"filename*=UTF-8''{quote(filename)}"})
async def _plan(session: AsyncSession, request: Request) -> tuple[transfer.ImportPlan, dict[str, Any]]:
body = await _body(request)
try:
return await transfer.plan_import(session, body.get("content")), body
except transfer.TransferInvalid as e:
raise CodedHTTPException(422, str(e), IMPORT_INVALID, field="content") from e
@router.post("/suites/import/preview")
async def import_preview(request: Request, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""{content: 题集文件}:哪些题对得上、哪些跳过和原因。不写库、不查业务库(试算在导入时做)。"""
plan, _ = await _plan(session, request)
return plan.to_dict()
@router.post("/suites/import", status_code=201)
async def import_suite(request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""{content: 题集文件, name?}:建一个新题集,对得上的题逐道试算(和保存题目同一道关),过了的导进来,没过的跳过、写原因。
一道都导不进来就不建题集(422,带每道题的原因)。"""
plan, body = await _plan(session, request)
actor = actor_of(x_actor)
if isinstance(body.get("name"), str) and body["name"].strip():
plan.suite["name"] = body["name"].strip()[:200]
# 先定好 id:试算的运行记着是哪个题集的(一道都导不进来时题集不建,那几次试算的运行照样留着、点得开)
suite = EvalSuite(id=new_id(), **plan.suite, created_by=actor)
ready = [c for c in plan.cases if c.fields is not None]
gate = asyncio.Semaphore(IMPORT_TRIALS)
async def trial(item: transfer.PlannedCase) -> gold_rules.GoldOutcome:
async with gate:
return await gold_rules.compute_gold({**item.fields, "id": None, "seq": item.seq},
_context(suite, actor, imported=True))
passed: list[tuple[transfer.PlannedCase, gold_rules.GoldOutcome]] = []
await session.commit() # 试算要等运行跑完:不一直开着读事务等(同 _trial)
for item, outcome in zip(ready, await asyncio.gather(*(trial(c) for c in ready))):
if outcome.ok:
passed.append((item, outcome))
else:
item.fields, item.reason, item.code = None, f"试算没有通过:{outcome.reason}", outcome.code
if not passed:
raise CodedHTTPException(422, "题集里没有一道题能导进来:" + ";".join(
f"第 {c.seq or '?'} 题:{c.reason}" for c in plan.cases[:10]), IMPORT_INVALID, field="content")
session.add(suite)
await session.flush()
passed.sort(key=lambda pair: (pair[0].seq is None, pair[0].seq or 0))
for seq, (item, outcome) in enumerate(passed, start=1):
assert item.fields is not None
session.add(EvalCase(suite_id=suite.id, seq=seq, case_hash=case_hash(item.fields),
trial=_trial_record(outcome, actor), created_by=actor, **item.fields))
await session.commit()
detail = await get_suite(suite.id, session)
return {"suite": detail, "imported": len(passed), "skipped": [c.to_dict() for c in plan.cases if c.fields is None]}
# ==========================================================================
# 目录快照
# ==========================================================================
def _snapshot_error(e: snapshots.SnapshotInvalid) -> CodedHTTPException:
return CodedHTTPException(e.status, e.message, e.code, field=e.field)
@router.get("/catalog-snapshots")
async def list_snapshots(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = list((await session.execute(select(EvalCatalogSnapshot).order_by(EvalCatalogSnapshot.created_at.desc())
.limit(200))).scalars())
await snapshots.reconcile_stale(session, rows)
return {"snapshots": [snapshots.snapshot_out(r) for r in rows]}
@router.post("/catalog-snapshots", status_code=201)
async def take_snapshot(request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""{source_ids}:拍这几个数据源此刻的目录(当场写好、不可变)。"""
body = await _body(request)
try:
row = await snapshots.take_live(session, body.get("source_ids") or [], actor=actor_of(x_actor))
except snapshots.SnapshotInvalid as e:
raise _snapshot_error(e) from e
return snapshots.snapshot_out(row)
async def _shadow_plan(session: AsyncSession, body: dict[str, Any]) -> snapshots.ShadowPlan:
try:
_model, model_id = await snapshots.resolve_model(session, body.get("provider"), body.get("model"))
return await snapshots.plan_shadow(session, body, model_id=model_id)
except snapshots.SnapshotInvalid as e:
raise _snapshot_error(e) from e
@router.post("/catalog-snapshots/shadow/estimate")
async def shadow_estimate(request: Request, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""影子起草的计划和估算(起草哪些表、调几次模型、多少 token、多少钱):开始之前给人看。不调模型。"""
return (await _shadow_plan(session, await _body(request))).to_dict()
@router.post("/catalog-snapshots/shadow", status_code=202)
async def start_shadow(request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""开始影子起草(后台):{sources: [{id, tables?}], suite_id?, provider?, model?, guidance?, note?, strip_llm?}。
交回登记的那一行(queued);进度、用量、结果按 GET /catalog-snapshots/{id} 读。"""
plan = await _shadow_plan(session, await _body(request))
row = await snapshots.start_shadow(session, plan, actor=actor_of(x_actor))
return snapshots.snapshot_out(row)
async def _snapshot(session: AsyncSession, snapshot_id: str) -> EvalCatalogSnapshot:
row = await session.get(EvalCatalogSnapshot, snapshot_id)
if row is None:
raise CodedHTTPException(404, "目录快照不存在", SNAPSHOT_NOT_FOUND)
return row
@router.get("/catalog-snapshots/{snapshot_id}")
async def get_snapshot(snapshot_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
row = await _snapshot(session, snapshot_id)
await snapshots.reconcile_stale(session, [row])
return snapshots.snapshot_out(row)
@router.post("/catalog-snapshots/{snapshot_id}/stop")
async def stop_snapshot(snapshot_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""停止一次影子起草:不出快照。已经结束的回 409。"""
await _snapshot(session, snapshot_id)
if not await snapshots.stop(snapshot_id):
raise CodedHTTPException(409, "这次影子起草已经结束了", snapshots.SNAPSHOT_FINISHED)
session.expire_all()
return snapshots.snapshot_out(await _snapshot(session, snapshot_id))
# ==========================================================================
# 评测:发起、进度、结果
# ==========================================================================
EVALUATION_NOT_FOUND = "eval_evaluation_not_found"
PAIR_NOT_FOUND = "eval_pair_not_found"
RESULT_NOT_FOUND = "eval_result_not_found"
CLEANED = "eval_details_cleaned"
#: 预设对照
PRESETS = ("catalog", "catalog_reviewed")
def _refused(e: CaseInvalid) -> CodedHTTPException:
return CodedHTTPException(getattr(e, "status", 422), e.message, e.code, field=e.field)
async def _arms(session: AsyncSession, suite: EvalSuite, body: dict[str, Any]) -> list[dict[str, Any]]:
"""请求体 → 规范化的几臂变体:{variant}(一臂,不写用题集的缺省变体)、{arms: [变体…]}(A/B)或 {preset: catalog |
catalog_reviewed}(目录贡献对照:当前目录 vs 关,可加只用已核实的项;模型照 variant 或题集的缺省)。A/B 缺省每题 3 次。"""
from app.eval import variants as variant_rules
judge = bool((await _site(session))["judge"])
preset = body.get("preset")
raw_arms = body.get("arms")
try:
if preset is not None:
if preset not in PRESETS:
raise CaseInvalid(f"预设对照只能是 {'、'.join(PRESETS)}", "preset", variant_rules.VARIANT_INVALID)
base = variant_rules.normalize_variant({**dict(suite.default_variant or {}), **dict(body.get("variant") or {})},
judge_default=judge, repeats_default=variant_rules.PAIR_REPEATS)
return variant_rules.catalog_preset(base, reviewed=preset == "catalog_reviewed")
if raw_arms is not None:
if not isinstance(raw_arms, list) or not 2 <= len(raw_arms) <= variant_rules.ARMS_MAX:
raise CaseInvalid(f"A/B 要 2 到 {variant_rules.ARMS_MAX} 臂", "arms", variant_rules.VARIANT_INVALID)
arms = [variant_rules.normalize_variant(a, judge_default=judge, repeats_default=variant_rules.PAIR_REPEATS)
for a in raw_arms]
names = [variant_rules.arm_name(a, i) for i, a in enumerate(arms)]
if len(set(names)) != len(names):
raise CaseInvalid("几臂的名字(label)不能重复", "arms", variant_rules.VARIANT_INVALID)
return arms
return [variant_rules.normalize_variant({**dict(suite.default_variant or {}), **dict(body.get("variant") or {})},
judge_default=judge)]
except CaseInvalid as e:
raise _invalid(e) from e
def _evaluation_out(row: EvalRun) -> dict[str, Any]:
from app.eval import runner, variants as variant_rules
return {"id": row.id, "suite_id": row.suite_id, "status": row.status, "trigger": row.trigger,
"schedule_id": row.schedule_id, "variant": dict(row.variant or {}),
"resolved_models": dict(row.resolved_models or {}),
"variant_text": variant_rules.describe(row.variant or {}, row.resolved_models or {}),
"catalog_snapshot": row.catalog_snapshot, "as_of": _iso(row.as_of), "repeats": row.repeats,
"fingerprint": dict(row.fingerprint or {}), "pair_id": row.pair_id, "arm": row.arm,
"baseline_id": row.baseline_id, "pinned": bool(row.pinned), "budget": dict(row.budget or {}),
"counts": dict(row.counts or {}), "usage": dict(row.usage or {}), "summary": dict(row.summary or {}),
"stop_reason": row.stop_reason, "error": row.error, "created_by": row.created_by,
"created_at": _iso(row.created_at), "started_at": _iso(row.started_at), "finished_at": _iso(row.finished_at),
"cleaned_at": _iso(row.cleaned_at), "running": runner.running(row)}
async def _evaluation(session: AsyncSession, evaluation_id: str) -> EvalRun:
from app.eval import runner
row = await session.get(EvalRun, evaluation_id)
if row is None:
raise CodedHTTPException(404, "评测不存在", EVALUATION_NOT_FOUND)
await runner.reconcile_stale(session, [row])
return row
@router.post("/suites/{suite_id}/evaluations/estimate")
async def estimate_evaluation(suite_id: str, request: Request, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""发起之前给人看:几臂、各臂实际解析出的模型、几项、估算的花费(按这个题集上一次评测的每题平均花费)、生效的预算、
同时评几题、今天评测已经花了多少。不调模型、不查库。"""
from app.core.config import settings
from app.engine.spend import eval_spend
from app.eval import runner, variants as variant_rules
from app.eval.suites import effective_budget
suite = await _suite(session, suite_id)
arms = await _arms(session, suite, await _body(request))
out = []
try:
for i, arm in enumerate(arms):
models = await variant_rules.resolve_models(session, arm)
await variant_rules.snapshot_artifact(session, arm)
out.append({"label": variant_rules.arm_name(arm, i) if len(arms) > 1 else None, "variant": arm,
"models": models, "text": variant_rules.describe(arm, models)})
except CaseInvalid as e:
raise _refused(e) from e
cases = len(await runner._cases(session, suite_id))
repeats = max(int(a.get("repeats") or 1) for a in arms)
budget = effective_budget(suite.budget, await _site(session))
parallel = max(1, min(int(budget["max_parallel"]), int(settings.max_concurrent_runs) // 4 // len(arms) or 1))
return {"arms": out, "cases": cases, "repeats": repeats,
"estimate": await runner.estimate(session, suite_id, cases * repeats * len(arms)), "budget": budget,
"parallel": parallel, "spend": await eval_spend.daily()}
@router.post("/suites/{suite_id}/evaluations", status_code=202)
async def start_evaluation(suite_id: str, request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""发起一次评测(后台):{variant?} 一臂,或 {arms: [变体…]} / {preset} 几臂的 A/B(共用期望、同一题同时开跑),可带
baseline_id 点名和哪一次比。交回 {evaluations: [每一臂], pair_id};进度按 GET /evaluations/{id} 读。"""
from app.eval import runner
suite = await _suite(session, suite_id)
body = await _body(request)
arms = await _arms(session, suite, body)
baseline = body.get("baseline_id")
if baseline is not None and not isinstance(baseline, str):
raise CodedHTTPException(422, "基线需要是一次评测的 id", runner.EVAL_INVALID, field="baseline_id")
try:
runs = await runner.start(session, suite, arms, actor=actor_of(x_actor), baseline_id=baseline or None)
except CaseInvalid as e:
raise _refused(e) from e
return {"evaluations": [_evaluation_out(r) for r in runs], "pair_id": runs[0].pair_id}
@router.get("/suites/{suite_id}/evaluations")
async def list_evaluations(suite_id: str, limit: int = 50, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""这个题集的历次评测(新的在前;明细清理了的总表还在)。"""
from app.eval import runner
await _suite(session, suite_id)
rows = list((await session.execute(select(EvalRun).where(EvalRun.suite_id == suite_id)
.order_by(EvalRun.created_at.desc()).limit(max(1, min(limit, 200))))).scalars())
await runner.reconcile_stale(session, rows)
return {"evaluations": [_evaluation_out(r) for r in rows]}
@router.get("/evaluations/{evaluation_id}")
async def get_evaluation(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""一次评测:进度(counts、正在评的几题在做什么)、总表(summary,结束后才有)、和上一次比的计数(summary.baseline)。"""
from app.db.models_eval import EvalResult
row = await _evaluation(session, evaluation_id)
running = (await session.execute(select(EvalResult.case_seq, EvalResult.repeat, EvalResult.arm, EvalResult.current)
.where(EvalResult.eval_run_id == row.id, EvalResult.status == "running")
.order_by(EvalResult.seq))).all()
return {**_evaluation_out(row),
"current": [{"case_seq": seq, "repeat": rep, "arm": arm, "doing": doing} for seq, rep, arm, doing in running],
"actions": await _actions(session, row)}
async def _actions(session: AsyncSession, row: EvalRun) -> dict[str, Any]:
"""这次评测现在能做哪几件事(界面照它摆按钮,不自己判):和 stop / retry / rescore / pin 几个接口拒收的条件同一份。
open 是这次评测(A/B 的话几臂合起来)还没做完的项数:停止、中断、出错、到了上限没开评的,重试就是把它们接着做。"""
from app.core import job_runtime as runtime
from app.db.models_eval import EvalResult
from app.eval import runner
active = row.status in runtime.ACTIVE
leader = runner.leader_of(row)
arms = list((await session.execute(select(EvalRun.id).where(
(EvalRun.id == leader) | (EvalRun.pair_id == leader)))).scalars())
open_items = int(await session.scalar(select(func.count()).select_from(EvalResult).where(
EvalResult.eval_run_id.in_(arms), EvalResult.status != "ok")) or 0)
suite = await session.get(EvalSuite, row.suite_id)
cleaned = row.cleaned_at is not None
finished = row.status in ("succeeded", "partial")
return {"stop": active, "retry": not active and not cleaned and open_items > 0 and suite is not None
and not suite.archived, "rescore": not active and not cleaned,
"pin": finished and not row.pinned, "unpin": bool(row.pinned), "open": open_items, "cleaned": cleaned}
@router.get("/evaluations/{evaluation_id}/results")
async def evaluation_results(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""逐题结果:结论、各期望的结论(期望值和答案里对上的数、有没有引用、维度)、作答路径、复核、出具档位、SQL 差在哪、look、
耗时、按角色的用量,点 run_id 去运行详情。明细清理了的照实写 cleaned。question 是这一题实际问的问题(变量渲染好了,
见 _asked_questions;说不准时为 null),question_template 是题目的原文(带 {{ schedule.… }} 变量的模板)。"""
from app.eval import runner
row = await _evaluation(session, evaluation_id)
results = [runner.result_dict(r) for r in (await session.execute(
select(runner.EvalResult).where(runner.EvalResult.eval_run_id == row.id)
.order_by(runner.EvalResult.seq))).scalars()]
cases = {c.id: (c.question, c.case_hash) for c in (await session.execute(select(EvalCase).where(
EvalCase.id.in_({r["case_id"] for r in results if r["case_id"]})))).scalars()} if results else {}
asked = await _asked_questions(session, row, results, cases)
for r in results:
template = (cases.get(r["case_id"]) or (None, None))[0]
r["question"], r["question_template"] = asked.get(r["id"]), template
r["cost"] = _result_cost(r.get("usage") or {})
return {"evaluation_id": row.id, "cleaned": row.cleaned_at is not None, "results": results}
async def _asked_questions(session: AsyncSession, row: EvalRun, results: list[dict[str, Any]],
cases: dict[str, tuple[str, str]]) -> dict[str, str | None]:
"""每一题实际问的问题 {结果 id: 问题}:题目的模板按这次评测的截至时间(as_of)、题集的时区、定时的 cron 渲染——和执行时
runner._unit 交给作答的是同一个算法、同一组输入,所以一字不差(as_of 在建评测时就定下、重试也不变)。题目在这次评测之后
改过(结果记的 case_hash 对不上现在的):现在的模板不一定是当时问的,改取作答运行的输入里的 question;也没有(反问、
建图失败没有运行,工作流题的输入里没有问题)就是 None,不拿现在的模板编一个。"""
from app.db.models import Run
from app.eval import runner
from app.eval.cases import render, variable_values
if not results:
return {}
leader = row if runner.leader_of(row) == row.id else await session.get(EvalRun, runner.leader_of(row))
suite = await session.get(EvalSuite, row.suite_id)
as_of = (leader.as_of if leader is not None else None) or row.as_of
values = variable_values(as_of, suite.timezone, ((leader or row).fingerprint or {}).get("cron")) \
if as_of is not None and suite is not None else None
stale = {r["id"] for r in results
if r["case_id"] not in cases or cases[r["case_id"]][1] != r["case_hash"] or values is None}
runs = {r["run_id"] for r in results if r["id"] in stale and r["run_id"]}
inputs = dict((await session.execute(select(Run.id, Run.input).where(Run.id.in_(runs))))
.all()) if runs else {}
out: dict[str, str | None] = {}
for r in results:
if r["id"] in stale:
given = (inputs.get(r["run_id"]) or {}).get("question") if r["run_id"] else None
out[r["id"]] = given if isinstance(given, str) and given else None
else:
out[r["id"]] = render(cases[r["case_id"]][0] or "", values or {})
return out
def _result_cost(usage: dict[str, Any]) -> dict[str, Any]:
"""一题的花费:助手(runner 记的 assistant 已含复核)+ 写作 + 结论裁判,和这次评测记在账上的同一份;估不出金额的调用另计
次数(界面写「≥」或「金额无法估算」)。加在服务端:界面不算数。"""
roles = [usage.get(k) or {} for k in ("assistant", "writer", "judge")]
return {"cost_usd": round(sum(float(r.get("cost_usd") or 0) for r in roles), 6),
"unpriced_calls": sum(int(r.get("unpriced_calls") or 0) for r in roles)}
@router.get("/evaluations/{evaluation_id}/results/{result_id}")
async def evaluation_result(evaluation_id: str, result_id: str, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""一题的明细:结果的列,加上明细工件(这一轮作答的经过、读出来的答案、裁判的判断、评分的原样)。"""
from app.core import artifact_store
from app.eval import runner
row = await _evaluation(session, evaluation_id)
result = await session.get(runner.EvalResult, result_id)
if result is None or result.eval_run_id != row.id:
raise CodedHTTPException(404, "这一题的结果不存在(明细可能已清理)", RESULT_NOT_FOUND)
details = None
if result.details_artifact:
try:
details = artifact_store.load(result.details_artifact)
except ValueError:
details = None
return {**runner.result_dict(result), "details": details}
@router.post("/evaluations/{evaluation_id}/stop")
async def stop_evaluation(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""停止(A/B 几臂一起停)。已经结束的回 409。正在跑的作答、算期望的运行一并取消。"""
from app.eval import runner
await _evaluation(session, evaluation_id)
await session.commit()
if not await runner.stop(evaluation_id):
raise CodedHTTPException(409, "这次评测已经结束了", runner.EVAL_FINISHED)
session.expire_all()
return _evaluation_out(await _evaluation(session, evaluation_id))
@router.post("/evaluations/{evaluation_id}/retry", status_code=202)
async def retry_evaluation(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""只把没做完的题接着做(停止、中断、出错、到了上限的那几题;A/B 时同一题几臂一起重做)。"""
from app.eval import runner
await _evaluation(session, evaluation_id)
await session.commit()
try:
ids = await runner.retry(evaluation_id)
except CaseInvalid as e:
raise _refused(e) from e
session.expire_all()
return {"evaluations": [_evaluation_out(await _evaluation(session, i)) for i in ids]}
@router.post("/evaluations/{evaluation_id}/rescore")
async def rescore_evaluation(evaluation_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按现在的评分规则(score.SCORER_VERSION)重新评分:只读封存的作答运行和存下的期望、裁判判断,不调模型、不查业务库。
总表跟着重算。还在跑、明细已清理的不能重评。"""
from app.eval import rescore, runner
row = await _evaluation(session, evaluation_id)
if row.status in ("queued", "running"):
raise CodedHTTPException(409, "这次评测还没结束,结束后再重新评分", runner.EVAL_BUSY)
if row.cleaned_at is not None:
raise CodedHTTPException(409, "这次评测的明细已清理,不能重新评分", CLEANED)
await session.commit()
changed = await rescore.rescore(evaluation_id)
session.expire_all()
return {**_evaluation_out(await _evaluation(session, evaluation_id)), "changed": changed}
@router.post("/evaluations/{evaluation_id}/pin")
async def pin_evaluation(evaluation_id: str, request: Request, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""钉住当基线({pinned: true})或取消(false):和上一次比时优先用同一变体里钉住的那一次,清理时也留着它。"""
row = await _evaluation(session, evaluation_id)
body = await _body(request)
pinned = body.get("pinned")
if not isinstance(pinned, bool):
raise CodedHTTPException(422, "pinned 需要是 true 或 false", case_rules.CASE_INVALID, field="pinned")
if pinned and row.status not in ("succeeded", "partial"):
raise CodedHTTPException(409, "只有评完了(或部分评完)的评测能设为基线", case_rules.CASE_INVALID, field="pinned")
row.pinned = pinned
await session.commit()
await session.refresh(row)
return _evaluation_out(row)
@router.get("/evaluations/{evaluation_id}/compare")
async def compare_evaluation(evaluation_id: str, base: str | None = None, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""和上一次比:base 不给就按规则挑(同一变体钉住的基线,或比这次先发起、已经结束的最近一次)。{base, fingerprint_diff,
transitions: {counts, causes, items}};没有可比的 base 为 null。任何一边的明细已清理,逐题部分为空、照实写 cleaned。"""
from app.eval import compare, runner
row = await _evaluation(session, evaluation_id)
picked = await compare.pick_baseline(session, row, explicit=base or row.baseline_id)
if picked is None:
return {"base": None, "fingerprint_diff": [], "transitions": None, "cleaned": False}
cleaned = row.cleaned_at is not None or picked.cleaned_at is not None
moved = None if cleaned else compare.transitions(await runner.result_rows(session, picked.id),
await runner.result_rows(session, row.id))
return {"base": _evaluation_out(picked),
"fingerprint_diff": compare.fingerprint_diff(picked.fingerprint or {}, row.fingerprint or {}),
"transitions": moved, "cleaned": cleaned}
@router.get("/pairs/{pair_id}")
async def get_pair(pair_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""一次 A/B:几臂各自的准确率、两两配对表(都对 / 都错 / 只 A 对 / 只 B 对)和精确 McNemar 双侧 p 值;p ≥ 0.05 写
「这批题上看不出差别」。几臂共用期望答案。"""
from app.eval import compare, runner
rows = list((await session.execute(select(EvalRun).where(EvalRun.pair_id == pair_id)
.order_by(EvalRun.created_at))).scalars())
if not rows:
raise CodedHTTPException(404, "这次 A/B 不存在", PAIR_NOT_FOUND)
await runner.reconcile_stale(session, rows)
cleaned = any(r.cleaned_at is not None for r in rows)
table = None if cleaned else compare.pair_table(
[(r.arm or r.id, await runner.result_rows(session, r.id)) for r in rows])
return {"pair_id": pair_id, "evaluations": [_evaluation_out(r) for r in rows], "table": table, "cleaned": cleaned}
# ==========================================================================
# 口径命中与重复度(E1、E3):判据、分档、窗口趋势、成员不少于 3 的问题类
# ==========================================================================
#: 判据(docs/design/copilot-look-and-caliber-ledger.md §3.8,已修订为持续统计的参考线):E1 等价命中率 ≥30% 值得按口径走、
#: <10% 先只做对账、介于两者之间先加强规范化;E3 问题重复度 ≥20% 沉淀有价值。只作参考,不卡门
E1_ROUTE, E1_LOW, E3_WORTH = 0.30, 0.10, 0.20
SIGNAL_CRITERIA = {
"e1": [{"band": "route", "at_least": E1_ROUTE, "text": "等价命中率 ≥ 30%:值得按已治理的口径走"},
{"band": "normalize", "at_least": E1_LOW, "text": "10%–30%:先加强规范化,让写法不同的等价 SQL 认得出"},
{"band": "reconcile", "at_least": 0.0, "text": "< 10%:先只做口径对账"}],
"e3": [{"band": "worth", "at_least": E3_WORTH, "text": "问题重复度 ≥ 20%:把反复问的问题沉淀成口径有价值"},
{"band": "not_yet", "at_least": 0.0, "text": "< 20%:反复问的问题还不多"}],
"note": "参考线出自口径账本的设计文档,只作参考,不卡门",
}
SIGNAL_DAYS_MAX = 365
SIGNAL_POINTS_MAX = 12
#: 每类最多列几个问题
CLUSTER_MEMBERS = 10
def _e1_band(rate: float | None) -> str | None:
if rate is None:
return None
return "route" if rate >= E1_ROUTE else "normalize" if rate >= E1_LOW else "reconcile"
def _e3_band(share: float | None) -> str | None:
if share is None:
return None
return "worth" if share >= E3_WORTH else "not_yet"
def _point(stats: dict[str, Any], since: Any, until: Any) -> dict[str, Any]:
"""一个窗口的 E1、E3(取自 ledger/stats.compute_stats 的结果,不另算)和各自落在哪一档。"""
e1, e3 = stats.get("e1") or {}, stats.get("e3") or {}
rate = (e1.get("rates") or {}).get("proven")
share = e3.get("cluster_share")
return {"since": _iso(since), "until": _iso(until),
"e1": {"rate": rate, "n": int(e1.get("with_candidates") or 0), "queries": int(e1.get("queries") or 0),
"band": _e1_band(rate)},
"e3": {"share": share, "n": int(e3.get("questions") or 0), "lower_bound": e3.get("repeated_share"),
"band": _e3_band(share)}}
@router.get("/signals")
async def signals(days: int = 30, points: int = 6, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""「口径命中与重复度」页签:判据、最近 days 天和往前 points - 1 个同样长的窗口各自的 E1、E3(趋势,旧的在前),以及最近
days 天里成员不少于 3 的问题类。
E1、E3 的数只出自口径账本的 compute_stats(总方案 X2,全站一份;评测运行不计入,见 engine/origin.counted_runs),问题类只出自
chat/questions(和 E3、认证队列同一份:只取问题原文、不含 history、排除系统派生的运行)。界面上的详细分布另调
GET /api/ledger/stats。每一类给代表问题、最近几个成员和最近一个挂着运行的成员(「提取模板」就从那次运行提取)。"""
from datetime import timedelta
from app.api.deps import current_principal
from app.chat.questions import cluster_questions, embedder, recent_questions
from app.ledger.stats import QUESTION_LIMIT, REPEAT_RUNS, compute_stats
if not 1 <= days <= SIGNAL_DAYS_MAX:
raise CodedHTTPException(422, f"窗口需要是 1 到 {SIGNAL_DAYS_MAX} 天", case_rules.CASE_INVALID, field="days")
if not 1 <= points <= SIGNAL_POINTS_MAX:
raise CodedHTTPException(422, f"趋势需要是 1 到 {SIGNAL_POINTS_MAX} 个窗口", case_rules.CASE_INVALID,
field="points")
principal = current_principal()
now = utcnow()
span = timedelta(days=days)
trend = []
for i in reversed(range(points)):
until = now - span * i
since = until - span
trend.append(_point(await compute_stats(session, since=since, until=until, principal=principal), since, until))
since = now - span
questions = [q for q in await recent_questions(session, principal, limit=QUESTION_LIMIT)
if q.at is None or q.at >= since]
clusters = []
for c in await cluster_questions(questions) if questions else []:
if c.count < REPEAT_RUNS:
continue
with_run = next((m for m in c.members if m.run_id), None)
clusters.append({"count": c.count, "representative": c.members[0].text[:500],
"first_at": _iso(c.members[-1].at), "last_at": _iso(c.members[0].at),
"members": [{"text": m.text[:500], "at": _iso(m.at), "run_id": m.run_id, "turn_id": m.turn_id}
for m in c.members[:CLUSTER_MEMBERS]],
"run_id": with_run.run_id if with_run else None})
clusters.sort(key=lambda c: c["count"], reverse=True)
return {"window": {"since": _iso(since), "until": _iso(now), "days": days}, "criteria": SIGNAL_CRITERIA,
"current": trend[-1], "trend": trend, "clusters": clusters, "questions": len(questions),
"threshold": REPEAT_RUNS, "embedder": embedder()}
@router.post("/suites/{suite_id}/cleanup")
async def cleanup_suite(suite_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按保留规则清理这个题集更早的评测明细(最近 keep_runs 次、钉住的基线、A/B 的另几臂留着;总表永久保留)。"""
from app.eval import cleanup
await _suite(session, suite_id)
await session.commit()
return await cleanup.clean_suite(suite_id)
__all__ = ["router"]