评测接口与题集导出导入、零售样例题集(第 2 波 W2-c)

- api/evals.py(骨架已登记、只给搭建者):题集、题目的增删改查(改要带读到的 version,对不上 409;有评测记录的题集不能删,
  只能归档)。保存题目时试算一次(细案 01 做法 8):没过只读守卫、用到遮罩列、取不到值、运行失败、超时、口径升版待处置的,
  照实回 422(带机器码和 field)、不保存;只改标签、启用的不重新试算。另有只试算不保存、试算已有的题。
- 从运行认定:GET /runs/{id}/queries 列出那次运行的查询(只给 SQL、列名、行数,不给格),POST …/cases/from-run 拷出一条
  查询的 SQL 建题,记确认人、时间、出自哪次运行;客户端不能自己写 from_run,要改 SQL 就改成 SQL 题。
- 口径:GET /calibers 和蓝图助手同一份口径清单(能不能复用、为什么);GET/POST …/upgrade 看、处置口径升版,
  recompute / dual / incomparable 三选一、没有缺省;不可比的旧题归档、按新版本另建新题。
- 目录快照:当前目录(当场写好)、影子起草的计划和估算、后台影子起草(202)、查看、停止。
- app/eval/transfer.py:题集导出成 JSON(数据源、工作流按名字;不含期望值、答案、运行,从运行认定只带确认人和时间),导入
  按名字对上、逐题过同一套校验和同一道试算关,对不上、没过的跳过并写原因,一道都导不进来就不建题集。
- tests/fixtures/eval/retail_suite.json:零售样例题集,配 tests/fixtures/acceptance/retail.py(数据源名 retail_demo,
  retail.SOURCE_NAME):支付日归属、退款、取消、华南高于华东,一道「华中区」期望没有数据,一道问法含糊期望反问。测试在
  按今天生成的零售库上导入,六道题都试算通过、期望和直接查库一样、陷阱按口径算。
This commit is contained in:
ThearchyHelios
2026-10-16 13:58:36 +08:00
parent 2b9dcc55e3
commit a7e91b16a1
7 changed files with 1749 additions and 4 deletions
+683 -3
View File
@@ -1,9 +1,689 @@
"""评测接口(docs/design/next-round/01-效果度量与回归评测.md):题集、题目、期望答案试算、评测、结果、对比。
"""评测接口(docs/design/next-round/01-效果度量与回归评测.md):题集、题目、期望答案试算、目录快照;第 3 波加评测、结果、对比。
整组只给搭建者及以上(main.py 挂 require_role("builder"))。第 2 波 W2-c 起填。
整组只给搭建者及以上(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。
"""
from __future__ import annotations
from fastapi import APIRouter
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 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
# ==========================================================================
# 形状
# ==========================================================================
def _suite_out(row: EvalSuite, *, counts: dict[str, int] | 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), "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(suite: EvalSuite, fields: dict[str, Any], actor: str | None, *, case_id: str | None = None,
seq: int | None = None) -> gold_rules.GoldOutcome:
"""按此刻试算一次期望。"""
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, "题集刚被别人加了题,请重试")
# ==========================================================================
# 题集
# ==========================================================================
@router.get("/suites")
async def list_suites(archived: bool = False, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
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())
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
return {"suites": [_suite_out(r, counts=counts[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})
@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"])}),
"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(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(suite, fields, actor_of(x_actor))).to_dict()}
@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(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(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(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)
outcome = await _trial(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]] = []
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))
__all__ = ["router"]
+1 -1
View File
@@ -82,7 +82,7 @@ class EvalCase(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
suite_id: Mapped[str] = mapped_column(ForeignKey("eval_suites.id", ondelete="CASCADE"))
# 题号:题集里从 1 起,删了的题号不复用(运行名「评测:题集名·第 n 题」按它写)
# 题号:题集里从 1 起、按建的先后(运行名「评测:题集名·第 n 题」按它写)。和上一次比按题目 id 和指纹配对,不按题号
seq: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20), default="ask")
# 问法。可以带 {{ schedule.* }} 变量(按评测的截至时间渲染成明确的日期)
+255
View File
@@ -0,0 +1,255 @@
"""题集的导出导入(细案 01 做法 19):本机整理好的题集带到部署实例上;仓库里的「零售样例题集」也按这个格式写
(tests/fixtures/eval/retail_suite.json)。
**为什么要有它。** 真实业务的题集只在部署实例上建、上跑(评测-D15:真实数据不出部署实例、不进公开仓库);样例题集、
本机写好的题目要能带过去。硬编码在测试里的题集部署实例上用不了。
**文件**(export_suite):{format: "agentlab-eval-suite", version: 1, exported_at, suite: {name, description, timezone,
budget}, cases: [{seq, kind, question, datasources: [数据源名], workflow: {name, version}, input_template, expectations,
gold, tags, enabled}]}。期望来源里的数据源、工作流写名字(id 只在这台机器上有效):sql 题 {kind, datasource, sql},
口径题 {kind, workflow, version, node_id, inputs},从运行认定 {kind, datasource, sql, confirmed_by, confirmed_at}。
**不含期望值、答案、运行**:期望每次评测都重算,不冻结数值;从运行认定的那次运行、升版的处置记录只在这台机器上有意义,
不导出。默认变体也不导出(里面是这台机器上的模型、目录快照)。归档的题不导出。
**导入**(plan_import 预览和导入共用这一份规矩):
- 数据源、工作流按名字对上(一字不差);对不上的题跳过、写原因。
- 每道题过和新建题目同一套校验(cases.normalize_case + resolve_case),导入时再试算一次(和保存题目时同一道关:只读守卫、
遮罩拒收、取得到值),没过的跳过、写原因(接口层做,见 api/evals.import_suite)。一道都导不进来就不建题集。
- 文件格式不对(不是对象、格式标记或版本不对)抛 TransferInvalid,接口回 422。
"""
from __future__ import annotations
import copy
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.eval.cases import CaseInvalid, normalize_case, resolve_case
from app.eval.suites import normalize_suite
FORMAT = "agentlab-eval-suite"
VERSION = 1
FILE_SUFFIX = "-题集.json"
#: 一个文件最多几道题
MAX_CASES = 500
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
#: 跳过的原因
SOURCE_UNMATCHED = "数据源「{name}」在这里没有(按名字对,一字不差)"
WORKFLOW_UNMATCHED = "工作流「{name}」在这里没有(按名字对,一字不差)"
class TransferInvalid(ValueError):
"""文件格式不对。message 给人看(接口回 422)。"""
def attachment_name(suite_name: str) -> str:
name = _BAD_FILENAME.sub("_", suite_name or "").strip(" .")[:80].strip(" .")
return f"{name or '题集'}{FILE_SUFFIX}"
# ==========================================================================
# 导出
# ==========================================================================
async def _names(session: AsyncSession, model: Any, ids: set[str]) -> dict[str, str]:
if not ids:
return {}
rows = await session.execute(select(model.id, model.name).where(model.id.in_(sorted(ids))))
return {i: n for i, n in rows.all()}
def _gold_out(gold: Mapping[str, Any] | None, sources: Mapping[str, str], workflows: Mapping[str, str]
) -> dict[str, Any] | None:
if not gold:
return None
kind = gold.get("kind")
if kind == "caliber":
return {"kind": "caliber", "workflow": workflows.get(gold.get("workflow_id"), gold.get("workflow_id")),
"version": gold.get("version"), "node_id": gold.get("node_id"),
"inputs": copy.deepcopy(gold.get("inputs") or {})}
out = {"kind": kind, "datasource": sources.get(gold.get("datasource_id"), gold.get("datasource_id")),
"sql": gold.get("sql")}
if kind == "from_run":
out |= {k: gold[k] for k in ("confirmed_by", "confirmed_at") if gold.get(k)}
return out
async def export_suite(session: AsyncSession, suite: Any, cases: list[Any]) -> dict[str, Any]:
from app.db.base import utcnow
from app.db.models import DataSource, Workflow
kept = [c for c in cases if not c.archived]
source_ids = {sid for c in kept for sid in c.datasource_ids or []} | {
(c.gold or {}).get("datasource_id") for c in kept if (c.gold or {}).get("datasource_id")}
workflow_ids = {c.workflow_id for c in kept if c.workflow_id} | {
(c.gold or {}).get("workflow_id") for c in kept if (c.gold or {}).get("workflow_id")}
sources = await _names(session, DataSource, {s for s in source_ids if s})
workflows = await _names(session, Workflow, {w for w in workflow_ids if w})
out_cases = []
for c in sorted(kept, key=lambda c: c.seq):
item: dict[str, Any] = {"seq": c.seq, "kind": c.kind, "question": c.question,
"datasources": [sources.get(s, s) for s in c.datasource_ids or []],
"expectations": copy.deepcopy(list(c.expectations or [])),
"gold": _gold_out(c.gold, sources, workflows), "tags": list(c.tags or []),
"enabled": bool(c.enabled)}
if c.kind == "workflow":
item["workflow"] = {"name": workflows.get(c.workflow_id, c.workflow_id), "version": c.workflow_version}
item["input_template"] = dict(c.input_template or {})
out_cases.append(item)
return {"format": FORMAT, "version": VERSION, "exported_at": utcnow().isoformat(),
"suite": {"name": suite.name, "description": suite.description or "", "timezone": suite.timezone,
"budget": dict(suite.budget or {})},
"cases": out_cases}
# ==========================================================================
# 导入
# ==========================================================================
@dataclass
class PlannedCase:
"""文件里的一道题:对上了的(fields 是 normalize_case 的结果,可以直接建)或者跳过的(reason)。"""
seq: int | None
question: str
fields: dict[str, Any] | None = None
reason: str | None = None
code: str | None = None
def to_dict(self) -> dict[str, Any]:
return {"seq": self.seq, "question": self.question, "ok": self.fields is not None, "reason": self.reason,
"code": self.code}
@dataclass
class ImportPlan:
suite: dict[str, Any]
cases: list[PlannedCase] = field(default_factory=list)
#: 文件里提到的数据源、工作流名 → 这里的 id(对不上为 None)
sources: dict[str, str | None] = field(default_factory=dict)
workflows: dict[str, str | None] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {"suite": dict(self.suite), "cases": [c.to_dict() for c in self.cases],
"sources": dict(self.sources), "workflows": dict(self.workflows),
"importable": sum(1 for c in self.cases if c.fields is not None),
"skipped": sum(1 for c in self.cases if c.fields is None)}
def read_file(content: Any) -> tuple[dict[str, Any], list[Any]]:
if not isinstance(content, Mapping):
raise TransferInvalid("题集文件需要是一个 JSON 对象")
if content.get("format") != FORMAT:
raise TransferInvalid(f"不是题集文件(format 应为 {FORMAT})")
if content.get("version") != VERSION:
raise TransferInvalid(f"题集文件的版本是 {content.get('version')},这里只认 {VERSION}")
suite = content.get("suite")
cases = content.get("cases")
if not isinstance(suite, Mapping) or not isinstance(cases, list):
raise TransferInvalid("题集文件里需要有 suite(对象)和 cases(列表)")
if len(cases) > MAX_CASES:
raise TransferInvalid(f"一个题集文件最多 {MAX_CASES} 道题")
return dict(suite), cases
async def _lookup(session: AsyncSession, model: Any, names: set[str]) -> dict[str, str | None]:
found = dict((await session.execute(select(model.name, model.id).where(model.name.in_(sorted(names))))).all()) \
if names else {}
return {n: found.get(n) for n in sorted(names)}
async def plan_import(session: AsyncSession, content: Any) -> ImportPlan:
"""按这台机器上的数据源、工作流对上文件里的每道题,逐题过 normalize_case + resolve_case。不写库、不查业务库。"""
from app.db.models import DataSource, Workflow
raw_suite, raw_cases = read_file(content)
try:
suite = normalize_suite({k: raw_suite.get(k) for k in ("name", "description", "timezone", "budget")
if raw_suite.get(k) is not None})
except CaseInvalid as e:
raise TransferInvalid(f"题集:{e.message}") from e
source_names = set()
workflow_names = set()
for c in raw_cases:
if not isinstance(c, Mapping):
continue
source_names |= {s for s in c.get("datasources") or [] if isinstance(s, str)}
gold = c.get("gold") if isinstance(c.get("gold"), Mapping) else {}
if isinstance(gold.get("datasource"), str):
source_names.add(gold["datasource"])
if isinstance(gold.get("workflow"), str):
workflow_names.add(gold["workflow"])
if isinstance(c.get("workflow"), Mapping) and isinstance(c["workflow"].get("name"), str):
workflow_names.add(c["workflow"]["name"])
plan = ImportPlan(suite=suite, sources=await _lookup(session, DataSource, source_names),
workflows=await _lookup(session, Workflow, workflow_names))
for i, c in enumerate(raw_cases):
plan.cases.append(await _plan_case(session, c, i, plan, suite["timezone"]))
return plan
async def _plan_case(session: AsyncSession, raw: Any, i: int, plan: ImportPlan, tz: str) -> PlannedCase:
if not isinstance(raw, Mapping):
return PlannedCase(seq=None, question="", reason=f"第 {i + 1} 道题不是一个对象")
seq = raw.get("seq") if isinstance(raw.get("seq"), int) and not isinstance(raw.get("seq"), bool) else None
question = str(raw.get("question") or "")
planned = PlannedCase(seq=seq, question=question)
def source_id(name: Any) -> str | None:
return plan.sources.get(name) if isinstance(name, str) else None
def workflow_id(name: Any) -> str | None:
return plan.workflows.get(name) if isinstance(name, str) else None
body: dict[str, Any] = {k: copy.deepcopy(raw[k]) for k in ("kind", "question", "expectations", "tags", "enabled",
"input_template") if k in raw}
ids = []
for name in raw.get("datasources") or []:
if (sid := source_id(name)) is None:
planned.reason = SOURCE_UNMATCHED.format(name=name)
return planned
ids.append(sid)
body["datasource_ids"] = ids
if isinstance(raw.get("workflow"), Mapping):
if (wid := workflow_id(raw["workflow"].get("name"))) is None:
planned.reason = WORKFLOW_UNMATCHED.format(name=raw["workflow"].get("name"))
return planned
body["workflow_id"], body["workflow_version"] = wid, raw["workflow"].get("version")
gold = raw.get("gold")
trusted = False
if isinstance(gold, Mapping):
gold = dict(gold)
if "datasource" in gold:
name = gold.pop("datasource")
if (sid := source_id(name)) is None:
planned.reason = SOURCE_UNMATCHED.format(name=name)
return planned
gold["datasource_id"] = sid
if "workflow" in gold:
name = gold.pop("workflow")
if (wid := workflow_id(name)) is None:
planned.reason = WORKFLOW_UNMATCHED.format(name=name)
return planned
gold["workflow_id"] = wid
# 从运行认定的题带着认定人和时间过来(运行本身不导出);它只能由服务端建,这里就是服务端
trusted = gold.get("kind") == "from_run"
body["gold"] = gold
try:
fields = normalize_case(body, trusted_gold=trusted)
await resolve_case(session, fields, tz=tz)
except CaseInvalid as e:
planned.reason, planned.code = e.message, e.code
return planned
planned.fields = fields
return planned
__all__ = ["FORMAT", "FILE_SUFFIX", "ImportPlan", "MAX_CASES", "PlannedCase", "TransferInvalid", "VERSION",
"attachment_name", "export_suite", "plan_import", "read_file"]
+5
View File
@@ -13,6 +13,9 @@
customers.phone 是手机号,验收时把它设成遮罩列,检验质询、证据面板、可验证报告不露原值。append_day 给定时任务
验收用:两次触发之间往「昨天」追加几单,制造「只数据变」。
评测的零售样例题集(tests/fixtures/eval/retail_suite.json)按数据源名 SOURCE_NAME 对上这个库:生成之后登记成这个名字的
只读数据源,再导入题集(POST /api/evals/suites/import),题目的期望都按这个库算。
"""
from __future__ import annotations
@@ -24,6 +27,8 @@ from pathlib import Path
DAYS = 60
SEED = 20261006
#: 零售样例题集里写的数据源名
SOURCE_NAME = "retail_demo"
#: (门店 id, 店名, 地区, 城市):华南门店多、客单价高,华东次之
STORES = [
+172
View File
@@ -0,0 +1,172 @@
{
"format": "agentlab-eval-suite",
"version": 1,
"exported_at": "2026-10-16T00:00:00+00:00",
"suite": {
"name": "零售样例题集",
"description": "配 tests/fixtures/acceptance/retail.py 的虚构零售库(数据源名 retail_demo):支付日归属、退款、取消、华南高于华东几个口径陷阱,一道期望答「没有数据」的题,一道问法含糊、期望反问的题。用于演示、验收和真实模型回归;店名、金额都是虚构的",
"timezone": "Asia/Shanghai",
"budget": {}
},
"cases": [
{
"seq": 1,
"kind": "ask",
"question": "上周({{ schedule.last_week_start }} 至 {{ schedule.last_week_end }})的销售额是多少?",
"datasources": [
"retail_demo"
],
"tags": [
"口径陷阱",
"支付日归属",
"相对日期"
],
"gold": {
"kind": "sql",
"datasource": "retail_demo",
"sql": "SELECT ROUND(SUM(amount), 2) AS 销售额 FROM orders WHERE status = 'paid' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'"
},
"expectations": [
{
"id": "sales",
"kind": "scalar",
"column": "销售额",
"label": "上周销售额(按支付日归属、不含退款和取消)"
}
],
"enabled": true
},
{
"seq": 2,
"kind": "ask",
"question": "上周({{ schedule.last_week_start }} 至 {{ schedule.last_week_end }})有多少单退款?退款金额是多少?",
"datasources": [
"retail_demo"
],
"tags": [
"口径陷阱",
"退款"
],
"gold": {
"kind": "sql",
"datasource": "retail_demo",
"sql": "SELECT COUNT(*) AS 退款单数, ROUND(SUM(amount), 2) AS 退款金额 FROM orders WHERE status = 'refunded' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'"
},
"expectations": [
{
"id": "refund_orders",
"kind": "scalar",
"column": "退款单数",
"label": "上周退款单数"
},
{
"id": "refund_amount",
"kind": "scalar",
"column": "退款金额",
"label": "上周退款金额"
}
],
"enabled": true
},
{
"seq": 3,
"kind": "ask",
"question": "上周({{ schedule.last_week_start }} 至 {{ schedule.last_week_end }})下单后取消的订单有多少单?",
"datasources": [
"retail_demo"
],
"tags": [
"口径陷阱",
"取消"
],
"gold": {
"kind": "sql",
"datasource": "retail_demo",
"sql": "SELECT COUNT(*) AS 取消单数 FROM orders WHERE status = 'cancelled' AND order_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'"
},
"expectations": [
{
"id": "cancelled",
"kind": "scalar",
"column": "取消单数",
"label": "上周取消的订单数(按下单日)"
}
],
"enabled": true
},
{
"seq": 4,
"kind": "ask",
"question": "上周({{ schedule.last_week_start }} 至 {{ schedule.last_week_end }})华南和华东的销售额各是多少?哪个地区更高?",
"datasources": [
"retail_demo"
],
"tags": [
"口径陷阱",
"地区对比"
],
"gold": {
"kind": "sql",
"datasource": "retail_demo",
"sql": "SELECT s.region AS 地区, ROUND(SUM(o.amount), 2) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' AND s.region IN ('华南', '华东') GROUP BY s.region ORDER BY 销售额 DESC"
},
"expectations": [
{
"id": "by_region",
"kind": "table",
"keys": [
"地区"
],
"ordered": true,
"label": "华南、华东上周的销售额,按销售额从高到低"
}
],
"enabled": true
},
{
"seq": 5,
"kind": "ask",
"question": "上周({{ schedule.last_week_start }} 至 {{ schedule.last_week_end }})华中区的销售额是多少?",
"datasources": [
"retail_demo"
],
"tags": [
"拒答",
"没有数据"
],
"gold": {
"kind": "sql",
"datasource": "retail_demo",
"sql": "SELECT s.region AS 地区, ROUND(SUM(o.amount), 2) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE s.region = '华中' AND o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' GROUP BY s.region"
},
"expectations": [
{
"id": "no_data",
"kind": "none",
"label": "没有华中区的门店,应答「没有数据」,不给数"
}
],
"enabled": true
},
{
"seq": 6,
"kind": "ask",
"question": "最近卖得怎么样?",
"datasources": [
"retail_demo"
],
"tags": [
"问法含糊"
],
"gold": null,
"expectations": [
{
"id": "clarify",
"kind": "ask",
"label": "问法含糊(哪段时间、哪个指标都没说),应先反问"
}
],
"enabled": true
}
]
}
+130
View File
@@ -0,0 +1,130 @@
"""题目的形状与校验(app/eval/cases.py、suites.py):数值期望不许人填(评测-D4)、文字要点不计入准确率、只做单轮
(评测-D12)、变量只认定时任务那份、期望和期望来源配得上、题目指纹只看内容。纯函数,不碰库。"""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from app.eval import cases, suites
from app.eval.cases import CaseInvalid, normalize_case
SQL = {"kind": "sql", "datasource_id": "ds1", "sql": "SELECT SUM(amount) AS gmv FROM orders"}
def invalid(body: dict, **kw) -> CaseInvalid:
with pytest.raises(CaseInvalid) as caught:
normalize_case(body, **kw)
return caught.value
@pytest.mark.parametrize("key", cases.HUMAN_VALUE_KEYS)
def test_numbers_cannot_be_filled_in_by_hand(key):
e = invalid({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "column": "gmv", key: 330}]})
assert "数值期望不许人填" in e.message and e.field == f"expectations.0.{key}"
def test_text_points_never_count_toward_accuracy():
fields = normalize_case({"question": "为什么涨了?", "expectations": [
{"kind": "text", "points": [" 华南门店多 ", "客单价高"], "required": True}]})
[exp] = fields["expectations"]
assert exp == {"id": "e1", "kind": "text", "required": False, "points": ["华南门店多", "客单价高"]}
assert fields["gold"] is None
def test_only_single_turn_cases_this_round():
assert invalid({"question": "q", "history": [{"q": "上一轮"}], "expectations": [{"kind": "ask"}]}).field == "history"
assert normalize_case({"question": "q", "history": [], "expectations": [{"kind": "ask"}]})["history"] is None
@pytest.mark.parametrize("body, field", [
({"question": "q", "expectations": [{"kind": "scalar", "column": "gmv"}]}, "gold"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "ask"}]}, "gold"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "ask"}, {"kind": "none"}]}, "expectations"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "metric": "gmv"}]}, "expectations.0.metric"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar"}]}, "expectations.0.column"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "column": "g", "row": 1, "where": {"a": 1}}]},
"expectations.0.row"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "table"}]}, "expectations.0.keys"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "column": "g", "colum": "x"}]},
"expectations.0.colum"),
({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "column": "g", "tolerance": 2}]},
"expectations.0.tolerance"),
({"question": "q", "gold": SQL, "expectations": [{"id": "a", "kind": "none"}, {"id": "a", "kind": "none"}]},
"expectations.1.id"),
({"question": "", "expectations": [{"kind": "ask"}]}, "question"),
({"question": "q", "expectations": []}, "expectations"),
({"question": "q", "workflow_id": "w1", "expectations": [{"kind": "ask"}]}, "workflow_id"),
({"kind": "workflow", "workflow_id": "w1", "workflow_version": 1, "expectations": [{"kind": "ask"}]},
"expectations"),
({"question": "上周 {{ schedule.lastweek }}", "expectations": [{"kind": "ask"}]}, "question"),
({"question": "q", "gold": {**SQL, "sql": "SELECT {{ schedule.today | shout }}"},
"expectations": [{"kind": "none"}]}, "gold.sql"),
({"question": "q", "gold": {**SQL, "sql": "SELECT {{ schedule.today "}, "expectations": [{"kind": "none"}]},
"gold.sql"),
({"question": "q", "gold": {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "c",
"inputs": {"start": "{{ vars.x }}"}}, "expectations": [{"kind": "scalar",
"metric": "m"}]},
"gold.inputs"),
({"question": "q", "gold": {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "c"},
"expectations": [{"kind": "table", "keys": ["a"]}]}, "expectations.0.kind"),
({"question": "q", "gold": {"kind": "magic"}, "expectations": [{"kind": "none"}]}, "gold.kind"),
])
def test_expectations_and_gold_must_fit_together(body, field):
assert invalid(body).field == field
def test_from_run_gold_is_only_built_by_the_server():
gold = {"kind": "from_run", "datasource_id": "ds1", "sql": "SELECT 1 AS n", "run_id": "r1", "query": "a1",
"confirmed_by": "甲", "confirmed_at": "2026-10-01T00:00:00+00:00"}
assert invalid({"question": "q", "gold": gold, "expectations": [{"kind": "scalar", "column": "n"}]}).field \
== "gold.kind"
fields = normalize_case({"question": "q", "gold": gold, "expectations": [{"kind": "scalar", "column": "n"}]},
trusted_gold=True)
# 原样带回来的可以过(改标签时整份 gold 跟着回来)
again = normalize_case({"gold": gold, "tags": ["x"]}, current=fields)
assert again["gold"] == fields["gold"] and again["tags"] == ["x"]
def test_the_case_hash_follows_content_not_records():
base = normalize_case({"question": "q", "gold": SQL, "expectations": [{"kind": "scalar", "column": "gmv"}],
"tags": ["a"]})
assert cases.case_hash({**base, "tags": ["b"], "enabled": False}) == cases.case_hash(base)
assert cases.case_hash({**base, "question": "q2"}) != cases.case_hash(base)
caliber = {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "c", "inputs": {}}
pinned = {**base, "gold": caliber}
# 升版处置的记录不进指纹(dual 时旧的那一份照旧可比),改钉版本才进
assert cases.case_hash({**pinned, "gold": {**caliber, "upgrade": {"policy": "dual", "to_version": 2}}}) == \
cases.case_hash(pinned)
assert cases.case_hash({**pinned, "gold": {**caliber, "version": 2}}) != cases.case_hash(pinned)
def test_the_disposal_record_is_kept_only_by_the_server():
caliber = {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "c", "inputs": {}}
stored = {**caliber, "upgrade": {"policy": "dual", "from_version": 1, "to_version": 2}}
body = {"question": "q", "expectations": [{"kind": "scalar", "metric": "m"}]}
kept = normalize_case({**body, "gold": {**caliber, "upgrade": {"policy": "recompute", "to_version": 9}}},
current={"gold": stored})
assert kept["gold"]["upgrade"] == stored["upgrade"], "客户端带来的不算,照库里的留着"
moved = normalize_case({**body, "gold": {**caliber, "node_id": "other"}}, current={"gold": stored})
assert "upgrade" not in moved["gold"], "换了卡就没有处置"
fresh = normalize_case({**body, "gold": {**caliber, "upgrade": {"policy": "dual"}}})
assert "upgrade" not in fresh["gold"]
def test_variables_render_at_the_as_of_moment():
as_of = datetime(2027, 1, 1, 8, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
values = cases.variable_values(as_of, "Asia/Shanghai")
assert values["last_week"] == "2026-W52" and values["yesterday"] == "2026-12-31"
assert cases.render("'{{ schedule.last_week_start }}'..'{{ schedule.last_week_end }}'", values) == \
"'2026-12-21'..'2026-12-27'"
def test_suite_budget_defaults_and_ranges():
assert suites.effective_budget({}) == {"max_parallel": 2, "per_case_timeout_s": 900, "per_run_max_usd": 2.0,
"daily_max_usd": 5.0}
assert suites.normalize_budget({"max_parallel": 2, "per_run_max_usd": 1}) == {"per_run_max_usd": 1.0}
with pytest.raises(CaseInvalid) as caught:
suites.normalize_budget({"max_parallel": 1.5})
assert caught.value.field == "budget.max_parallel" and caught.value.code == suites.SUITE_INVALID
+503
View File
@@ -0,0 +1,503 @@
"""评测接口(app/api/evals.py):题集和题目的增删改查(乐观锁)、保存时试算、从运行认定、口径升版处置、导出导入、
目录快照;零售样例题集(tests/fixtures/eval/retail_suite.json)在虚构零售库上试算通过。
库、表、数字、人名都是虚构的。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import quote
from zoneinfo import ZoneInfo
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.data import introspect
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, Workflow, WorkflowVersion
from app.db.models_eval import EvalRun, EvalSuite
from app.engine.runner import run_manager
from app.eval import snapshots
from app.main import app
from tests.fixtures import dual_check_shop as shop
from tests.fixtures.acceptance import retail
from tests.fixtures.catalog.fake_model import FakeModel
from tests.fixtures.sources import drop_source
SUITE_FILE = Path(__file__).parent / "fixtures" / "eval" / "retail_suite.json"
LAST_WEEK = "BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'"
#: dual_check_shop 的订单都在 2026-09:「上周」按截至时间算,这里用写死日期的 SQL
SEPT = "BETWEEN '2026-09-01' AND '2026-09-07'"
ACTOR = {"X-Actor": quote("评测员甲")}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def source(tmp_path):
name, sid = await shop.add_source(shop.build(tmp_path / "shop.db"))
yield {"name": name, "id": sid}
await drop_source(sid)
async def new_suite(client, **body) -> dict:
res = await client.post("/api/evals/suites", json={"name": f"虚构题集_{uuid.uuid4().hex[:6]}", **body}, headers=ACTOR)
assert res.status_code == 201, res.text
return res.json()
def sql_case(source_id: str, sql: str = f"SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' "
f"AND paid_date {SEPT}", **kw) -> dict:
return {"question": "9 月第一周按支付日、不含退款的销售额?", "datasource_ids": [source_id],
"gold": {"kind": "sql", "datasource_id": source_id, "sql": sql},
"expectations": [{"kind": "scalar", "column": "gmv"}], **kw}
# --------------------------------------------------------------------------
# 题集
# --------------------------------------------------------------------------
async def test_suites_crud_with_optimistic_lock(client):
made = await new_suite(client, description="说明", budget={"max_parallel": 3})
assert made["created_by"] == "评测员甲" and made["version"] == 1 and made["timezone"] == "Asia/Shanghai"
assert made["budget"] == {"max_parallel": 3}
assert made["effective_budget"] == {"max_parallel": 3, "per_case_timeout_s": 900, "per_run_max_usd": 2.0,
"daily_max_usd": 5.0}
listed = (await client.get("/api/evals/suites")).json()["suites"]
assert any(s["id"] == made["id"] and s["counts"] == {"cases": 0, "enabled": 0} for s in listed)
res = await client.patch(f"/api/evals/suites/{made['id']}", json={"name": "改了名"})
assert res.status_code == 422 and res.json()["field"] == "version"
res = await client.patch(f"/api/evals/suites/{made['id']}", json={"name": "改了名", "version": 1})
assert res.status_code == 200 and res.json()["name"] == "改了名" and res.json()["version"] == 2
assert res.json()["budget"] == {"max_parallel": 3}, "没带的项照旧"
res = await client.patch(f"/api/evals/suites/{made['id']}", json={"name": "再改", "version": 1})
assert res.status_code == 409 and res.json()["code"] == "eval_version_conflict"
for body, field in (({"name": ""}, "name"), ({"name": "x", "timezone": "火星"}, "timezone"),
({"name": "x", "budget": {"per_case_timeout_s": 5}}, "budget.per_case_timeout_s"),
({"name": "x", "budget": {"per_run_max_usd": 50, "daily_max_usd": 10}}, "budget.per_run_max_usd"),
({"name": "x", "budget": {"nope": 1}}, "budget.nope")):
res = await client.post("/api/evals/suites", json=body)
assert res.status_code == 422 and res.json()["field"] == field, (body, res.text)
# 有评测记录的不能删,只能归档
async with SessionLocal() as session:
session.add(EvalRun(suite_id=made["id"], status="succeeded"))
await session.commit()
res = await client.delete(f"/api/evals/suites/{made['id']}")
assert res.status_code == 409 and res.json()["code"] == "eval_suite_has_runs"
async with SessionLocal() as session:
await session.execute(delete(EvalRun).where(EvalRun.suite_id == made["id"]))
await session.commit()
assert (await client.delete(f"/api/evals/suites/{made['id']}")).status_code == 204
assert (await client.get(f"/api/evals/suites/{made['id']}")).status_code == 404
# --------------------------------------------------------------------------
# 题目:保存时试算
# --------------------------------------------------------------------------
async def test_a_case_is_saved_only_after_a_trial_passes(client, source):
suite = await new_suite(client)
url = f"/api/evals/suites/{suite['id']}/cases"
res = await client.post(url, json=sql_case(source["id"]), headers=ACTOR)
assert res.status_code == 201, res.text
body = res.json()
case, trial = body["case"], body["trial"]
assert case["seq"] == 1 and case["version"] == 1 and case["created_by"] == "评测员甲" and len(case["case_hash"]) == 64
assert trial["status"] == "ok" and trial["sets"][0]["values"]["e1"]["value"] == 330.0
# 题目上只记试算的结论和运行,不存期望值(期望每次重算)
assert case["trial"]["status"] == "ok" and case["trial"]["by"] == "评测员甲" and "sets" not in case["trial"]
assert case["gold_source"] == {"id": source["id"], "name": source["name"]}
async with SessionLocal() as session:
run = await session.get(Run, trial["run_ids"][0])
assert run.origin_kind == "eval" and run.origin["trial"] is True and run.origin["suite_id"] == suite["id"]
# 只试算、不保存
res = await client.post(f"{url}/trial", json=sql_case(source["id"], sql="SELECT nope FROM orders"))
assert res.status_code == 200 and res.json()["trial"]["status"] == "failed"
for bad, code, field in (
(sql_case(source["id"], expectations=[{"kind": "scalar", "column": "gmv", "value": 330}]),
"eval_case_invalid", "expectations.0.value"), # 评测-D4:数值期望不许人填
(sql_case(source["id"], history=[{"question": "上一轮"}]), "eval_case_invalid", "history"), # 评测-D12
(sql_case(source["id"], sql="DELETE FROM orders"), "eval_gold_guard", "gold.sql"),
(sql_case(source["id"], sql="SELECT SUM(amount) AS gmv FROM orders WHERE {{ schedule.nope }}"),
"eval_case_invalid", "gold.sql"),
(sql_case(source["id"], sql="SELECT region FROM orders WHERE region = '华中'"), "eval_gold_no_value", "gold"),
({"question": "q", "expectations": [{"kind": "scalar", "column": "gmv"}]}, "eval_case_invalid", "gold"),
({"question": "最近卖得怎么样", "expectations": [{"kind": "ask"}, {"kind": "none"}],
"gold": {"kind": "sql", "datasource_id": source["id"], "sql": "SELECT 1"}}, "eval_case_invalid", "expectations"),
(sql_case("nope"), "eval_case_invalid", "gold.datasource_id"),
):
res = await client.post(url, json=bad)
assert res.status_code == 422, (bad, res.text)
assert (res.json()["code"], res.json().get("field")) == (code, field), res.json()
detail = (await client.get(f"/api/evals/suites/{suite['id']}")).json()
assert [c["seq"] for c in detail["cases"]] == [1], "没过的一道都没存"
async def test_masked_columns_are_refused_at_save(client, tmp_path):
name, sid = await shop.add_source(shop.build(tmp_path / "masked.db"), options={"mask_columns": ["region"]})
try:
suite = await new_suite(client)
body = sql_case(sid, sql="SELECT region AS 地区, SUM(amount) AS gmv FROM orders GROUP BY region",
expectations=[{"kind": "table", "keys": ["地区"]}])
res = await client.post(f"/api/evals/suites/{suite['id']}/cases", json=body)
assert res.status_code == 422 and res.json()["code"] == "eval_gold_masked" and "遮罩" in res.json()["detail"]
finally:
await drop_source(sid)
async def test_editing_a_case_retries_only_when_its_content_changes(client, source):
suite = await new_suite(client)
made = (await client.post(f"/api/evals/suites/{suite['id']}/cases", json=sql_case(source["id"]))).json()["case"]
url = f"/api/evals/suites/{suite['id']}/cases/{made['id']}"
res = await client.patch(url, json={"tags": ["口径陷阱"], "version": 1})
assert res.status_code == 200 and res.json()["trial"] is None, "只改标签不试算"
case = res.json()["case"]
assert case["tags"] == ["口径陷阱"] and case["version"] == 2 and case["case_hash"] == made["case_hash"]
res = await client.patch(url, json={"gold": {"kind": "sql", "datasource_id": source["id"],
"sql": f"SELECT SUM(amount) AS gmv FROM orders WHERE paid_date {SEPT}"},
"version": 2})
assert res.status_code == 200 and res.json()["trial"]["sets"][0]["values"]["e1"]["value"] == 380.0
assert res.json()["case"]["case_hash"] != made["case_hash"], "题目改过,指纹跟着变"
res = await client.patch(url, json={"gold": {"kind": "sql", "datasource_id": source["id"],
"sql": "DELETE FROM orders"}, "version": 3})
assert res.status_code == 422 and res.json()["code"] == "eval_gold_guard"
res = await client.patch(url, json={"tags": [], "version": 2})
assert res.status_code == 409
# 已有的题按此刻再试算一次:记进 trial、不改 version
res = await client.post(f"{url}/trial")
assert res.status_code == 200 and res.json()["trial"]["status"] == "ok"
assert (await client.get(f"/api/evals/suites/{suite['id']}")).json()["cases"][0]["version"] == 3
assert (await client.delete(url)).status_code == 204
# --------------------------------------------------------------------------
# 从运行认定
# --------------------------------------------------------------------------
async def test_a_query_from_a_run_becomes_a_case(client, source):
from app.engine.launch import launch_run
sql = f"SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {SEPT}"
graph = {"nodes": [{"id": "start", "type": "input", "data": {"config": {}}},
{"id": "q", "type": "tool", "data": {"config": {"tool": f"db_query__{source['name']}",
"args": {"sql": sql}}}},
{"id": "out", "type": "output", "data": {"config": {}}}],
"edges": [{"source": "start", "target": "q"}, {"source": "q", "target": "out"}]}
async with SessionLocal() as session:
run = await launch_run(session, graph=graph, input_payload={"question": "9 月第一周卖了多少?"}, actor="提问人",
origin_kind="chat")
await run_manager.wait_idle(run.id, timeout=30)
queries = (await client.get(f"/api/evals/runs/{run.id}/queries")).json()["queries"]
assert len(queries) == 1 and queries[0]["sql"] == sql and queries[0]["source_id"] == source["id"]
assert "rows" not in queries[0], "只给 SQL、列名、行数,不给结果的格"
suite = await new_suite(client)
res = await client.post(f"/api/evals/suites/{suite['id']}/cases/from-run", headers=ACTOR,
json={"run_id": run.id, "query": queries[0]["artifact"],
"expectations": [{"kind": "scalar", "column": "gmv"}]})
assert res.status_code == 201, res.text
case = res.json()["case"]
assert case["question"] == "9 月第一周卖了多少?", "问法没给就取那次运行的问题"
gold = case["gold"]
assert gold["kind"] == "from_run" and gold["sql"] == sql and gold["run_id"] == run.id
assert gold["confirmed_by"] == "评测员甲" and gold["confirmed_at"] and gold["query"] == queries[0]["artifact"]
assert res.json()["trial"]["sets"][0]["values"]["e1"]["value"] == 330.0
# 之后按这条 SQL 重算,不冻结数值;要改 SQL 就改成 SQL 题,不能在「从运行认定」上改
res = await client.patch(f"/api/evals/suites/{suite['id']}/cases/{case['id']}",
json={"gold": {**gold, "sql": "SELECT 1 AS gmv"}, "version": 1})
assert res.status_code == 422 and res.json()["field"] == "gold.kind"
res = await client.patch(f"/api/evals/suites/{suite['id']}/cases/{case['id']}",
json={"gold": gold, "tags": ["从运行"], "version": 1})
assert res.status_code == 200 and res.json()["case"]["gold"]["confirmed_by"] == "评测员甲"
res = await client.post(f"/api/evals/suites/{suite['id']}/cases/from-run",
json={"run_id": run.id, "query": "nope", "expectations": [{"kind": "none"}]})
assert res.status_code == 404
# 客户端不能自己写「从运行认定」
res = await client.post(f"/api/evals/suites/{suite['id']}/cases", json={**sql_case(source["id"]), "gold": gold})
assert res.status_code == 422 and res.json()["field"] == "gold.kind"
# --------------------------------------------------------------------------
# 口径题:清单与升版处置
# --------------------------------------------------------------------------
def _weekly(source_name: str, *, refunds: bool = False) -> dict:
status = "" if refunds else "status <> 'refunded' AND "
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}}
nodes = [node("start", "input", fields=[{"name": "start", "default": "2026-09-01"},
{"name": "end", "default": "2026-09-07"}]),
node("fetch", "tool", tool=f"db_query__{source_name}", args={"sql": (
f"SELECT SUM(amount) AS gmv FROM orders WHERE {status}"
"paid_date BETWEEN '{{ input.start }}' AND '{{ input.end }}'")}),
node("card", "metrics", caliber="周销售", metrics=[{"id": "gmv", "name": "销售额",
"expression": "cell(nodes.fetch, 0, 'gmv')"}]),
node("out", "output")]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def test_caliber_cases_list_and_dispose_upgrades(client, source):
async with SessionLocal() as session:
wf = Workflow(name=f"周销售_{uuid.uuid4().hex[:6]}", graph=_weekly(source["name"]), status="published",
published_version=1, version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=_weekly(source["name"]), level="published"))
await session.commit()
wf_id = wf.id
calibers = (await client.get("/api/evals/calibers")).json()["calibers"]
mine = [c for c in calibers if c["workflow_id"] == wf_id]
assert mine == [{"workflow_id": wf_id, "workflow_name": mine[0]["workflow_name"], "version": 1,
"level": "published", "node_id": "card", "card": "card", "caliber": "周销售 @ v1",
"metric_id": "gmv", "name": "销售额", "unit": "", "reusable": True, "reason": None}]
suite = await new_suite(client)
body = {"question": "上周的销售额?", "gold": {"kind": "caliber", "workflow_id": wf_id, "version": 1,
"node_id": "card", "inputs": {}},
"expectations": [{"kind": "scalar", "metric": "gmv"}]}
res = await client.post(f"/api/evals/suites/{suite['id']}/cases", json=body)
assert res.status_code == 201, res.text
case = res.json()["case"]
assert case["upgrade"]["pending"] is False and res.json()["trial"]["sets"][0]["values"]["e1"]["value"] == 330.0
bad = {**body, "expectations": [{"kind": "scalar", "metric": "nope"}]}
assert (await client.post(f"/api/evals/suites/{suite['id']}/cases", json=bad)).json()["field"] == \
"expectations.0.metric"
async with SessionLocal() as session:
wf = await session.get(Workflow, wf_id)
wf.published_version = wf.version = 2
session.add(WorkflowVersion(workflow_id=wf_id, version=2, graph=_weekly(source["name"], refunds=True),
level="published"))
await session.commit()
detail = (await client.get(f"/api/evals/suites/{suite['id']}")).json()
assert detail["pending_upgrades"] == 1 and detail["cases"][0]["upgrade"]["message"] == "口径已升版到 v2,待处置"
url = f"/api/evals/suites/{suite['id']}/cases/{case['id']}/upgrade"
info = (await client.get(url)).json()
assert set(info["choices"]) == {"recompute", "dual", "incomparable"}
res = await client.post(url, json={"version": 1})
assert res.status_code == 422 and res.json()["code"] == "eval_dispose_invalid", "没有缺省"
# 不可比:旧题归档,按新版本另建新题
res = await client.post(url, json={"policy": "incomparable", "version": 1}, headers=ACTOR)
assert res.status_code == 200, res.text
old, new = res.json()["case"], res.json()["new_case"]
assert old["archived"] and not old["enabled"] and old["superseded_by"] == new["id"]
assert new["gold"]["version"] == 2 and new["seq"] == 2 and new["upgrade"]["pending"] is False
assert res.json()["trial"]["sets"][0]["values"]["e1"]["value"] == 380.0
detail = (await client.get(f"/api/evals/suites/{suite['id']}")).json()
assert detail["pending_upgrades"] == 0 and detail["counts"]["cases"] == 1
# 归档的题不再待处置;在新题上再来一版就改钉
async with SessionLocal() as session:
wf = await session.get(Workflow, wf_id)
wf.published_version = wf.version = 3
session.add(WorkflowVersion(workflow_id=wf_id, version=3, graph=_weekly(source["name"]), level="published"))
await session.commit()
res = await client.post(f"/api/evals/suites/{suite['id']}/cases/{new['id']}/upgrade",
json={"policy": "recompute", "version": 1}, headers=ACTOR)
assert res.status_code == 200, res.text
moved = res.json()["case"]
assert moved["gold"]["version"] == 3 and moved["gold"]["upgrade"]["by"] == "评测员甲"
assert moved["case_hash"] != new["case_hash"] and moved["version"] == 2
res = await client.post(f"/api/evals/suites/{suite['id']}/cases/{new['id']}/upgrade",
json={"policy": "dual", "version": 2})
assert res.status_code == 409 and res.json()["code"] == "eval_dispose_nothing_pending"
# --------------------------------------------------------------------------
# 导出导入
# --------------------------------------------------------------------------
async def test_export_then_import_matches_sources_by_name(client, source):
suite = await new_suite(client, description="导出用")
await client.post(f"/api/evals/suites/{suite['id']}/cases", json=sql_case(source["id"], tags=["口径陷阱"]))
await client.post(f"/api/evals/suites/{suite['id']}/cases",
json={"question": "最近怎么样?", "expectations": [{"kind": "ask"}]})
res = await client.get(f"/api/evals/suites/{suite['id']}/export")
assert res.status_code == 200 and "attachment" in res.headers["content-disposition"]
content = res.json()
text = json.dumps(content, ensure_ascii=False)
assert content["format"] == "agentlab-eval-suite" and len(content["cases"]) == 2
# 按名字、不含期望值、答案、运行
first = content["cases"][0]
assert first["datasources"] == [source["name"]] and first["gold"]["datasource"] == source["name"]
assert "datasource_id" not in first["gold"] and source["id"] not in text
assert "330" not in text and "run_id" not in text and "trial" not in text
preview = (await client.post("/api/evals/suites/import/preview", json={"content": content})).json()
assert preview["importable"] == 2 and preview["sources"] == {source["name"]: source["id"]}
res = await client.post("/api/evals/suites/import", json={"content": content, "name": "导进来的"}, headers=ACTOR)
assert res.status_code == 201, res.text
imported = res.json()["suite"]
assert imported["name"] == "导进来的" and res.json()["imported"] == 2 and res.json()["skipped"] == []
assert [c["question"] for c in imported["cases"]] == [c["question"] for c in content["cases"]]
assert imported["cases"][0]["gold"]["datasource_id"] == source["id"]
assert imported["cases"][0]["trial"]["status"] == "ok"
# 对不上的数据源:跳过、写原因;一道都导不进来就不建题集
other = json.loads(json.dumps(content).replace(source["name"], "ghost_source"))
preview = (await client.post("/api/evals/suites/import/preview", json={"content": other})).json()
assert preview["skipped"] == 1 and "ghost_source" in preview["cases"][0]["reason"]
only_bad = {**other, "cases": other["cases"][:1]}
res = await client.post("/api/evals/suites/import", json={"content": only_bad})
assert res.status_code == 422 and "ghost_source" in res.json()["detail"]
res = await client.post("/api/evals/suites/import/preview", json={"content": {"format": "x"}})
assert res.status_code == 422 and res.json()["code"] == "eval_import_invalid"
async def test_the_retail_sample_suite_trials_on_the_retail_database(client, tmp_path):
"""仓库里附的零售样例题集:在虚构零售库上导入,每道题都试算通过,期望和直接查库一样,陷阱按口径算。"""
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
path = retail.build(tmp_path / "retail.db", today)
await drop_source(name=retail.SOURCE_NAME)
async with SessionLocal() as session:
row = DataSource(name=retail.SOURCE_NAME, kind="sqlite", database=str(path), readonly=True,
options={"mask_columns": ["phone"]})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
sid = row.id
try:
content = json.loads(SUITE_FILE.read_text(encoding="utf-8"))
res = await client.post("/api/evals/suites/import", json={"content": content}, headers=ACTOR)
assert res.status_code == 201, res.text
assert res.json()["imported"] == 6 and res.json()["skipped"] == []
suite = res.json()["suite"]
cases = {c["seq"]: c for c in suite["cases"]}
assert all(c["trial"]["status"] == "ok" for c in cases.values())
monday = today - timedelta(days=today.weekday())
start, end = (monday - timedelta(days=7)).isoformat(), (monday - timedelta(days=1)).isoformat()
def one(sql: str):
with sqlite3.connect(path) as db:
return db.execute(sql).fetchone()
async def trial(seq: int) -> dict:
out = (await client.post(f"/api/evals/suites/{suite['id']}/cases/{cases[seq]['id']}/trial")).json()["trial"]
assert out["status"] == "ok", out
assert f"'{start}' AND '{end}'" in (out["rendered"].get("sql") or f"'{start}' AND '{end}'")
return out["sets"][0]["values"] if out["sets"] else {}
paid = one(f"SELECT ROUND(SUM(amount), 2) FROM orders WHERE status = 'paid' AND paid_date BETWEEN '{start}' "
f"AND '{end}'")[0]
by_order_date = one(f"SELECT ROUND(SUM(amount), 2) FROM orders WHERE status = 'paid' AND order_date BETWEEN "
f"'{start}' AND '{end}'")[0]
with_refunds = one(f"SELECT ROUND(SUM(amount), 2) FROM orders WHERE status <> 'cancelled' AND paid_date "
f"BETWEEN '{start}' AND '{end}'")[0]
values = await trial(1)
assert values["sales"]["value"] == paid
# 陷阱确实埋着:按下单日、含退款算出来的都不一样
assert paid not in (by_order_date, with_refunds)
refunds = await trial(2)
assert (refunds["refund_orders"]["value"], refunds["refund_amount"]["value"]) == one(
f"SELECT COUNT(*), ROUND(SUM(amount), 2) FROM orders WHERE status = 'refunded' AND paid_date BETWEEN "
f"'{start}' AND '{end}'")
assert refunds["refund_orders"]["value"] > 0
cancelled = await trial(3)
assert cancelled["cancelled"]["value"] == one(
f"SELECT COUNT(*) FROM orders WHERE status = 'cancelled' AND order_date BETWEEN '{start}' AND '{end}'")[0]
regions = (await trial(4))["by_region"]
assert [r["地区"] for r in regions["rows"]] == ["华南", "华东"] and regions["ordered"] is True
assert regions["rows"][0]["销售额"] > regions["rows"][1]["销售额"]
assert (await trial(5)) == {"no_data": {"kind": "none", "rows": 0}}
ask = (await client.post(f"/api/evals/suites/{suite['id']}/cases/{cases[6]['id']}/trial")).json()["trial"]
assert ask["status"] == "ok" and ask["run_ids"] == [] and cases[6]["expectations"][0]["kind"] == "ask"
assert cases[1]["tags"] == ["口径陷阱", "支付日归属", "相对日期"]
finally:
async with SessionLocal() as session:
await session.execute(delete(EvalSuite).where(EvalSuite.name == "零售样例题集"))
await session.commit()
await drop_source(sid)
# --------------------------------------------------------------------------
# 目录快照
# --------------------------------------------------------------------------
async def test_catalog_snapshots_live_and_shadow(client, source, monkeypatch):
res = await client.post("/api/evals/catalog-snapshots", json={"source_ids": [source["id"]]}, headers=ACTOR)
assert res.status_code == 201, res.text
live = res.json()
assert live["status"] == "succeeded" and live["view"] == {"mode": "snapshot", "statuses": "all",
"snapshot": live["artifact_id"]}
assert (await client.post("/api/evals/catalog-snapshots", json={"source_ids": []})).status_code == 422
# 测试环境没有真实接入:起草模型用不了,照实回 409,不建任务
res = await client.post("/api/evals/catalog-snapshots/shadow/estimate",
json={"sources": [{"id": source["id"], "tables": ["orders"]}]})
assert res.status_code == 409 and res.json()["code"] == snapshots.SNAPSHOT_MODEL_UNAVAILABLE
async def fake_model(session, provider, model):
return FakeModel({"orders": {"label": "影子订单"}}), "fake-draft"
monkeypatch.setattr(snapshots, "resolve_model", fake_model)
monkeypatch.setattr(snapshots.ShadowDraft, "model_override", FakeModel({"orders": {"label": "影子订单"}}))
est = (await client.post("/api/evals/catalog-snapshots/shadow/estimate",
json={"sources": [{"id": source["id"], "tables": ["orders"]}]})).json()
assert est["sources"][0]["tables"] == ["orders"] and est["estimate"]["calls"] == 1
assert est["estimate"]["unpriced"] is True and est["estimate"]["model"] == "fake-draft"
res = await client.post("/api/evals/catalog-snapshots/shadow", headers=ACTOR,
json={"sources": [{"id": source["id"], "tables": ["orders"]}], "guidance": "按支付日"})
assert res.status_code == 202 and res.json()["status"] in ("queued", "running")
snap_id = res.json()["id"]
await snapshots.manager.wait(snap_id, timeout=30)
done = (await client.get(f"/api/evals/catalog-snapshots/{snap_id}")).json()
assert done["status"] == "succeeded" and done["artifact_id"] and done["created_by"] == "评测员甲"
assert done["draft"]["guidance"] == "按支付日"
listed = (await client.get("/api/evals/catalog-snapshots")).json()["snapshots"]
assert {live["id"], snap_id} <= {s["id"] for s in listed}
res = await client.post(f"/api/evals/catalog-snapshots/{snap_id}/stop")
assert res.status_code == 409 and res.json()["code"] == snapshots.SNAPSHOT_FINISHED
assert (await client.get("/api/evals/catalog-snapshots/nope")).status_code == 404
async def test_evals_are_for_builders_only():
"""期望值和查询原文只给搭建者及以上:使用者 403,搭建者能用。"""
from tests.fakes import auth_env
try:
admin = await auth_env.turn_on_local()
await admin.aclose()
await auth_env.make_user("eval_user", "user")
await auth_env.make_user("eval_builder", "builder")
user = await auth_env.login("eval_user")
res = await user.get("/api/evals/suites")
assert res.status_code == 403 and res.json()["code"] == "role_required"
assert (await user.post("/api/evals/suites", json={"name": "x"})).status_code == 403
await user.aclose()
builder = await auth_env.login("eval_builder")
res = await builder.post("/api/evals/suites", json={"name": "搭建者的题集"})
assert res.status_code == 201 and res.json()["created_by"] == "eval_builder"
await builder.aclose()
finally:
await auth_env.reset()
async with SessionLocal() as session:
await session.execute(delete(EvalSuite).where(EvalSuite.name == "搭建者的题集"))
await session.commit()