Files
AgentLab/backend/app/eval/transfer.py
ThearchyHelios a7e91b16a1 评测接口与题集导出导入、零售样例题集(第 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):支付日归属、退款、取消、华南高于华东,一道「华中区」期望没有数据,一道问法含糊期望反问。测试在
  按今天生成的零售库上导入,六道题都试算通过、期望和直接查库一样、陷阱按口径算。
2026-10-16 13:58:36 +08:00

256 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""题集的导出导入(细案 01 做法 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"]