Files
AgentLab/backend/app/db/models_eval.py
T

250 lines
18 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 做法 6、7)。
由 app/db/models.py 末尾整模块导入,create_all 建表(只加不改:都是新表,总方案 X11)。这里不导入 app.db.models:
models.py 导入本模块时自己还没加载完,外键一律按表名写。
**为什么一次定全。** 第 2 波只做题集、题目和期望答案的来源;第 3 波的执行、评分、对比、清理直接用 EvalRun、EvalResult
的这些列。列一次定好,第 3 波不再改表(SQLite 上 ADD COLUMN 式的迁移改不了约束,只加列也要进迁移列表)。
**为什么是强类型的几张表,不是「kind + JSON」的通用任务表。** 评测的进度(每题正在做什么、作答路径、结论、分段耗时、
按角色的用量)要能按列筛、按列汇总;通用任务表丢掉这些列,后台任务运行时的说明里已经否决过(core/job_runtime.py)。
- EvalSuite 题集:名称、说明、时区(题目里 {{ schedule.* }} 变量按它算)、默认变体、预算、是否归档、乐观锁 version。
- EvalCase 题目:kind(ask 问数据题 / workflow 钉版本的工作流题)、问法、数据源、期望(expectations)和期望答案的来源
(gold:sql / caliber / from_run,见 app/eval/cases.py)、标签、乐观锁 version、case_hash(题目改过 hash 就变,和
上一次比时标「题目改过,不可比」)。history 是给多轮追问留的位置(评测-D12:本轮只做单轮,必须为空)。
- EvalRun 一次评测(第 3 波起用):状态用后台任务的状态词,变体原样存一份、另存实际解析出的模型 id,指纹、配对(A/B)、
基准、预算快照、计数、按角色的用量、停下的原因。
- EvalResult 每题 × 重复 × 臂一项(第 3 波起用):状态用逐项状态词,作答路径、结论、评分、复核、出具档位、分段耗时……
job_id 是 eval_run_id 的别名:后台任务运行时的收尾(close_job、cancel_queued)按 task_model.job_id 认逐项。
- EvalCatalogSnapshot 目录快照的登记(做法 6):快照内容是内容寻址工件(kind=eval_catalog_snapshot,读法见
data/catalog_view.py),这里记谁、何时、来源(当前目录 / 影子起草:起草模型、起草要求、是否先去掉模型起草的项)、影子
起草的进度和用量。工件一旦写好就不变,这一行写好 artifact_id 之后也不再改。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, synonym
from app.db.base import Base, current_owner, TimestampMixin, UTCDateTime, new_id
#: 题目的种类:ask 问数据题(走问数据同一条路作答)、workflow 钉版本的工作流题
CASE_KINDS = ("ask", "workflow")
#: 一次评测怎么发起的:手动、定时、A/B 一次发起两臂
RUN_TRIGGERS = ("manual", "schedule", "pair")
#: 逐题的作答路径(第 3 波):直接回答、反问、只建不跑、建图失败、运行失败、跑完了、被驳回(总方案 X9:驳回记成路径,
#: 不算失败)
RESULT_PATHS = ("reply", "asked", "ready", "build_failed", "run_failed", "ran", "rejected")
#: 逐题的结论(第 3 波):通过、不通过、不能评分(遮罩、期望没算出来、作答期间数据变了)、跳过(口径升版待处置、到了上限)
VERDICTS = ("pass", "fail", "ungradable", "skipped")
#: 目录快照的来源:当前目录、影子起草
SNAPSHOT_ORIGINS = ("live", "shadow")
#: 预算的缺省(评测-D9,用户拍板):同时 2 题、每题 900 秒、单次评测 2 美元、评测每日 5 美元(评测单独一本账,不挤占
#: 裁判、核查的额度)。题集的 budget 只存改过的项,生效的是缺省加上它(app/eval/suites.effective_budget);发起评测时
#: 整份快照进 EvalRun.budget。这一波只定字段,执行、预留与找补在第 3 波。首次真实验收后按实际花费调
DEFAULT_BUDGET: dict[str, Any] = {
"max_parallel": 2,
"per_case_timeout_s": 900,
"per_run_max_usd": 2.0,
"daily_max_usd": 5.0,
}
class EvalSuite(Base, TimestampMixin):
"""一个题集。归档的不出现在缺省列表里、不能发起评测(第 3 波),题目和历次评测都留着。"""
__tablename__ = "eval_suites"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
# 题目、期望 SQL、口径输入里的 {{ schedule.* }} 按评测的截至时间在这个时区算(scheduler/service.template_values)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
# 发起评测时缺省用的变体(助手模型、写作模型、裁判、目录视图、重复次数……,第 3 波解析)
default_variant: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 只存改过的预算项,缺省见 DEFAULT_BUDGET
budget: Mapped[dict[str, Any]] = mapped_column(default=dict)
archived: Mapped[bool] = mapped_column(default=False)
# 乐观锁:改题集带上读到的 version,对不上回 409
version: Mapped[int] = mapped_column(Integer, default=1)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
class EvalCase(Base, TimestampMixin):
"""题集里的一道题。字段的取值和校验见 app/eval/cases.py(normalize_case)。"""
__tablename__ = "eval_cases"
__table_args__ = (UniqueConstraint("suite_id", "seq"), Index("ix_eval_cases_suite", "suite_id"))
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 题」按它写)。和上一次比按题目 id 和指纹配对,不按题号
seq: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20), default="ask")
# 问法。可以带 {{ schedule.* }} 变量(按评测的截至时间渲染成明确的日期)
question: Mapped[str] = mapped_column(Text, default="")
# 问数据题限定的数据源(空 = 不限)。导出导入按名字对上(app/eval/transfer.py)
datasource_ids: Mapped[list[Any]] = mapped_column(default=list)
# workflow 题:钉住的工作流版本和输入模板(输入模板同定时任务,可以带 {{ schedule.* }})
workflow_id: Mapped[str | None] = mapped_column(String(32), default=None)
workflow_version: Mapped[int | None] = mapped_column(Integer, default=None)
input_template: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 多轮追问的前几轮(评测-D12:本轮只做单轮,这里必须为空;留位置是为了以后不改表)
history: Mapped[list[Any] | None] = mapped_column(default=None)
# 期望:[{id, kind: scalar|table|set|none|ask|text, required, 取哪一格/列/表……}]。数值期望不许人填(评测-D4)
expectations: Mapped[list[Any]] = mapped_column(default=list)
# 期望答案的来源:{kind: sql|caliber|from_run, …};只有反问、文字要点的题为空
gold: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# 标签:口径陷阱、拒答、清单、相对日期……(自由填写,统计时分组用)
tags: Mapped[list[Any]] = mapped_column(default=list)
enabled: Mapped[bool] = mapped_column(default=True)
# 口径升版选了「不可比」:旧题归档(不再评),superseded_by 指向按新版本另建的那道题
archived: Mapped[bool] = mapped_column(default=False)
superseded_by: Mapped[str | None] = mapped_column(String(32), default=None)
# 乐观锁
version: Mapped[int] = mapped_column(Integer, default=1)
# 题目内容的指纹(app/eval/cases.case_hash):问法、数据源、期望、期望来源变了它就变
case_hash: Mapped[str] = mapped_column(String(64), default="")
# 最近一次试算:{status, code, reason, at, by, as_of, run_ids}。不存期望值:期望每次评测都重算,不冻结数值
trial: Mapped[dict[str, Any] | None] = mapped_column(default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
class EvalCatalogSnapshot(Base, TimestampMixin):
"""一份目录快照的登记。内容在工件里(artifact_id,kind=eval_catalog_snapshot);变体用工件 id 指定目录
(data/catalog_view.CatalogView(mode="snapshot", snapshot=工件 id)),同一份快照可以被多次评测引用。
当前目录的快照建出来就是 succeeded;影子起草是后台任务(app/eval/snapshots.py),状态用后台任务的状态词,起草完、
工件写好才有 artifact_id。"""
__tablename__ = "eval_catalog_snapshots"
__table_args__ = (Index("ix_eval_catalog_snapshots_artifact", "artifact_id"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
origin: Mapped[str] = mapped_column(String(20), default="live")
# queued | running | succeeded | partial | failed | cancelled | interrupted
status: Mapped[str] = mapped_column(String(20), default="succeeded")
#: 认领这一行的工作进程(app/cluster;缺省是建它的进程)。多进程时启动整理、接手只碰属主不在了的行
owner: Mapped[str | None] = mapped_column(String(120), default=current_owner)
artifact_id: Mapped[str | None] = mapped_column(String(64), default=None)
# 拍了哪些数据源:[{id, name}](名字是拍的那一刻的)
sources: Mapped[list[Any]] = mapped_column(default=list)
# 影子起草起草了哪些表:{源 id: [表名]};当前目录的快照为空(整个源的目录照拍)
tables: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# 影子起草的设定:{provider, model, model_id, guidance, note, strip_llm, suite_id}
draft: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# 开始前的估算:{tables, columns, calls, input_tokens, output_tokens, cost_usd, unpriced, note}
estimate: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# 实际用量:{calls, input_tokens, output_tokens, timeouts, failed_calls, cost_usd, unpriced}
usage: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 进度:{tables_total, tables_done, tables_failed, current, notice, failures: {表名: 原因}}
progress: Mapped[dict[str, Any]] = mapped_column(default=dict)
error: Mapped[str | None] = mapped_column(Text, default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
class EvalRun(Base, TimestampMixin):
"""一次评测(第 3 波的 app/eval/runner.py 写)。一次评测是一个后台任务,每题 × 重复次数 × 臂是一项(EvalResult)。
题集有评测时不能删除(只能归档):每次评测的总表(summary)永久保留,明细可以按保留规则清理(cleaned_at)。"""
__tablename__ = "eval_runs"
__table_args__ = (Index("ix_eval_runs_suite_created", "suite_id", "created_at"),
Index("ix_eval_runs_pair", "pair_id"))
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"))
# queued | running | succeeded | partial | failed | cancelled | interrupted(core/job_runtime 的状态词)
status: Mapped[str] = mapped_column(String(20), default="queued")
#: 认领这一行的工作进程(app/cluster;缺省是建它的进程)。多进程时启动整理、接手只碰属主不在了的行
owner: Mapped[str | None] = mapped_column(String(120), default=current_owner)
trigger: Mapped[str] = mapped_column(String(20), default="manual")
# 定时发起的那一条(ScheduleFire.eval_run_id 反过来指向这里)
schedule_id: Mapped[str | None] = mapped_column(String(32), default=None)
# 变体原样存一份;resolved_models 是实际解析出的模型 id(助手、写作、裁判……)
variant: Mapped[dict[str, Any]] = mapped_column(default=dict)
resolved_models: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 这次用的目录快照(工件 id):开始时拍一份(缺省「当前目录」也拍),起草、剖析照常进行也不影响可比性
catalog_snapshot: Mapped[str | None] = mapped_column(String(64), default=None)
# 截至时间:题目里的 {{ schedule.* }} 按它渲染
as_of: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
repeats: Mapped[int] = mapped_column(Integer, default=1)
# 指纹:提示词指纹、目录快照 id 和哈希、各数据源 schema 哈希、题目哈希、评分规则版本
fingerprint: Mapped[dict[str, Any]] = mapped_column(default=dict)
# A/B:两臂共用 pair_id,arm 是这一臂的名字
pair_id: Mapped[str | None] = mapped_column(String(32), default=None)
arm: Mapped[str | None] = mapped_column(String(40), default=None)
# 和哪一次比;pinned 是用户钉住的基线(清理时保留)
baseline_id: Mapped[str | None] = mapped_column(String(32), default=None)
pinned: Mapped[bool] = mapped_column(default=False)
# 发起时的预算快照(DEFAULT_BUDGET 加题集改过的项)
budget: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 计数:{total, done, passed, failed, ungradable, skipped, …};按角色的用量;总表(永久保留)
counts: Mapped[dict[str, Any]] = mapped_column(default=dict)
usage: Mapped[dict[str, Any]] = mapped_column(default=dict)
summary: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 停下的原因(止损、到了上限、手动停止),整体出错的原因
stop_reason: Mapped[str | None] = mapped_column(Text, default=None)
error: Mapped[str | None] = mapped_column(Text, default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
# 明细(逐题结果、底下的运行)按保留规则清理掉的时间;总表还在
cleaned_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
class EvalResult(Base, TimestampMixin):
"""一次评测里的一项:一道题 × 第几次重复 × 哪一臂(第 3 波写)。题目删了结果照留(case_id 不设外键)。"""
__tablename__ = "eval_results"
__table_args__ = (Index("ix_eval_results_run_case", "eval_run_id", "case_id"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
eval_run_id: Mapped[str] = mapped_column(ForeignKey("eval_runs.id", ondelete="CASCADE"))
#: 后台任务运行时按 task_model.job_id 认一个任务的逐项(close_job、cancel_queued)
job_id = synonym("eval_run_id")
# 排序用:同一次评测里从 1 起
seq: Mapped[int] = mapped_column(Integer, default=0)
case_id: Mapped[str | None] = mapped_column(String(32), default=None)
case_seq: Mapped[int | None] = mapped_column(Integer, default=None)
case_hash: Mapped[str] = mapped_column(String(64), default="")
repeat: Mapped[int] = mapped_column(Integer, default=0)
arm: Mapped[str | None] = mapped_column(String(40), default=None)
# pending | running | ok | partial | failed | skipped | cancelled(core/job_runtime 的逐项状态词)
status: Mapped[str] = mapped_column(String(20), default="pending")
# 作答的那次运行、算期望的那几次运行(作答前后各一次,口径题两版各一次)
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
gold_run_ids: Mapped[list[Any]] = mapped_column(default=list)
# 期望:作答前后两次的结果和是否一致(app/eval/gold.settle 的形状)
gold: Mapped[dict[str, Any] | None] = mapped_column(default=None)
path: Mapped[str | None] = mapped_column(String(20), default=None)
verdict: Mapped[str | None] = mapped_column(String(20), default=None)
# 每个期望的结论(对且可追溯 / 对但没引用 / 错 / 量纲不同 / 不能评分)
scores: Mapped[list[Any]] = mapped_column(default=list)
review: Mapped[dict[str, Any] | None] = mapped_column(default=None)
tier: Mapped[str | None] = mapped_column(String(20), default=None)
judge: Mapped[dict[str, Any] | None] = mapped_column(default=None)
looks: Mapped[dict[str, Any] | None] = mapped_column(default=None)
calls: Mapped[int] = mapped_column(Integer, default=0)
# 分段耗时(建图、运行、复核、整题)、按角色的用量
timings: Mapped[dict[str, Any]] = mapped_column(default=dict)
usage: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 答错的题:引用那格所在查询的 SQL 和期望 SQL 的比对(逐题的 E1)
sql_match: Mapped[dict[str, Any] | None] = mapped_column(default=None)
details_artifact: Mapped[str | None] = mapped_column(String(64), default=None)
# 正在做什么(正在算期望 / 正在建图 / 运行中 / 正在复核 / 正在评分)
current: Mapped[str | None] = mapped_column(String(200), default=None)
error: Mapped[str | None] = mapped_column(Text, default=None)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
__all__ = ["CASE_KINDS", "DEFAULT_BUDGET", "EvalCase", "EvalCatalogSnapshot", "EvalResult", "EvalRun", "EvalSuite",
"RESULT_PATHS", "RUN_TRIGGERS", "SNAPSHOT_ORIGINS", "VERDICTS"]