Files
AgentLab/backend/app/eval/gold.py
T

721 lines
38 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.
"""期望答案:SQL 探针、口径探针、从运行认定(细案 01 做法 8、14;评测-D4)。
**期望不靠人填数,也不冻结数值。** 每次要期望(保存题目时试算一次、评测时作答前后各一次)都在业务库上重算一遍:
- SQL 题、从运行认定:建一张「入口 → 调用工具(db_query__数据源,args.sql = 按截至时间渲染好的 SQL)→ 出口」的裸图,
以来源 eval 跑探索运行。期望值取这次运行封存的查询快照里指定的格、列或整张表(cases.py 的 expectations)。从运行认定
的题和 SQL 题算法一样,只是 SQL 出自某次运行里的一条查询、记着谁在什么时候认定的。
- 口径题:app/ledger/derive.caliber_probe 从钉住版本拷出口径卡读的取数节点、接一张 caliber_from 固定到那一版的口径卡,
跑探索运行,期望值就是口径卡的指标(取整、单位、比率都由口径卡自己定)。不可复用的卡拒收并给原因。
为什么不发起钉版本的正式运行:会被 pick_base 和应用视图的「最近一次封存」取到,正式运行也不该为了评测而发;为什么不引用
最近一次封存的正式运行:数据一直在变,期望会过期;为什么不直接调 run_query:期望没有封存快照,事后说不清当时查的是什么。
**守住的几条**(每次都查,不只保存时):
- 只读:渲染好的 SQL 先过只读守卫(data/guard.check,readonly=True);运行再带上 read_only_data(可写源上的写语句也由
守卫拒掉)和 mask_results 两个选项,同使用者的问数据运行。
- 遮罩:查询结果在工具边界就按 engine/masking 的派生遮罩遮好(按 SQL 认出改了名字的遮罩列,认不全的整份遮)。快照里有
任何一列、一格是遮掉的,期望就拒收——评测页会显示期望值,遮罩值不能从这里露出来。认法和证据面板同一套,认不准就拒。
- 照实说:取不到值(列不在、行不够、空值、该没有数据的却有数据)、运行失败、超时、停在人工确认上(评测不代批,直接
取消),各写各的原因;不混进「答错」。
**变量**:问法、期望 SQL、口径输入里的 {{ schedule.* }} 用定时任务那份 VARIABLES / template_values,按评测的「截至时间」
在题集的时区渲染(cases.variable_values)。
**口径升版**(做法 14):口径题钉 workflow_id@version。每次算期望之前,对探针图调一次 engine/governance.
unresolved_caliber_upgrades(和正式运行用的是同一个判断),再按 apps/versions.subgraph_pin_problems 看钉住的版本退役
没有。有了新的已发布版本、或者钉住的版本退役了,又没有处置到那一版,这道题就跳过,写「口径已升版到 vN,待处置」。
题目上三选一(dispose),没有缺省:
- recompute:改钉新版本,历史在这里断开(题目指纹跟着变,和上一次比时标不可比);
- dual:新旧两版各算一份期望,都报(旧的那一份照旧可比);
- incomparable:旧题归档,按新版本另建一道新题、重开基线。
**作答前后各算一次**(bracket / settle,第 3 波的执行调它):两次一样才判;不一样就记「作答期间数据变了,不判」。A/B 两臂
共用同一次算出的期望:在 answer 里同时跑两臂即可。
"""
from __future__ import annotations
import asyncio
import copy
import dataclasses
import logging
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, TypeVar
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.eval.cases import CALIBER_NOT_REUSABLE, GOLD_GUARD, NEEDS_GOLD, render, variable_values
logger = logging.getLogger(__name__)
T = TypeVar("T")
#: 结论:ok 算出来了;skipped 口径升版待处置(跳过这道题);refused 规矩不许(守卫、遮罩、卡不能复用);no_value 运行
#: 好好的、按期望取不到值;failed 运行没成(SQL 错、数据源用不了、停在人工确认上);timeout 超时没算完(已停止那次运行)
STATUSES = ("ok", "skipped", "refused", "no_value", "failed", "timeout")
#: 机器码(试算拒收时接口回 422 的 code)
GOLD_MASKED = "eval_gold_masked"
GOLD_NO_VALUE = "eval_gold_no_value"
GOLD_FAILED = "eval_gold_failed"
GOLD_TIMEOUT = "eval_gold_timeout"
UPGRADE_PENDING = "eval_caliber_upgrade_pending"
#: 作答前后两次期望不一样
GOLD_DRIFTED = "eval_gold_drifted"
#: 两次算期望的角色(记进运行 origin 的 role):作答前、作答后
ROLES = ("gold", "gold_after")
ROLE_LABEL = {"gold": "算期望", "gold_after": "作答后复算期望"}
#: 保存题目时试算一次等多久(秒)。评测里每题的时限另有预算(per_case_timeout_s)
TRIAL_TIMEOUT_S = 120.0
#: 超时停止那次运行之后,最多再等它收尾多久
CANCEL_GRACE_S = 15.0
#: SQL 探针里调用工具节点的 id
QUERY_NODE = "gold_q"
#: 口径升版的三种处置(评测里的说法;节点上的 upgrade_policy 叫法见 engine/labels)
UPGRADE_LABEL = {"recompute": "改为固定新版本(历史在这里断开)", "dual": "新旧两版各算一份期望",
"incomparable": "旧题归档,按新版本另建新题"}
# ==========================================================================
# 结果的形状
# ==========================================================================
@dataclass
class GoldSet:
"""一次探针算出来的一份期望。version 是口径题这一份按的版本(SQL 题为空);values 是 {期望 id: 取到的值}。"""
run_id: str
snapshot: str | None
values: dict[str, dict[str, Any]]
version: int | None = None
def to_dict(self) -> dict[str, Any]:
return {"run_id": self.run_id, "snapshot": self.snapshot, "version": self.version,
"values": copy.deepcopy(self.values)}
@classmethod
def from_dict(cls, raw: Mapping[str, Any]) -> GoldSet:
return cls(run_id=str(raw.get("run_id") or ""), snapshot=raw.get("snapshot"),
values=copy.deepcopy(dict(raw.get("values") or {})), version=raw.get("version"))
@dataclass
class GoldOutcome:
"""一次算期望的结果。sets:一份(SQL 题、口径题)或两份(口径题选了 dual:旧版在前、新版在后)。run_ids 是这次发起的
全部运行(失败的也在,证据点得开)。rendered 是按截至时间渲染好的问法、SQL、口径输入。"""
status: str
as_of: str
reason: str | None = None
code: str | None = None
sets: list[GoldSet] = field(default_factory=list)
run_ids: list[str] = field(default_factory=list)
rendered: dict[str, Any] = field(default_factory=dict)
@property
def ok(self) -> bool:
return self.status == "ok"
def to_dict(self) -> dict[str, Any]:
return {"status": self.status, "as_of": self.as_of, "reason": self.reason, "code": self.code,
"sets": [s.to_dict() for s in self.sets], "run_ids": list(self.run_ids),
"rendered": copy.deepcopy(self.rendered)}
@classmethod
def from_dict(cls, raw: Mapping[str, Any]) -> GoldOutcome:
return cls(status=str(raw.get("status") or "failed"), as_of=str(raw.get("as_of") or ""),
reason=raw.get("reason"), code=raw.get("code"),
sets=[GoldSet.from_dict(s) for s in raw.get("sets") or []],
run_ids=list(raw.get("run_ids") or []), rendered=copy.deepcopy(dict(raw.get("rendered") or {})))
def summary(self) -> dict[str, Any]:
"""记进题目 trial 的那一份:不带期望值(期望每次重算,不冻结数值)。"""
return {"status": self.status, "code": self.code, "reason": self.reason, "as_of": self.as_of,
"run_ids": list(self.run_ids)}
def _outcome(status: str, as_of: datetime, reason: str | None = None, code: str | None = None, **kw: Any) -> GoldOutcome:
return GoldOutcome(status=status, as_of=as_of.isoformat(), reason=reason, code=code, **kw)
@dataclass(frozen=True)
class Context:
"""算期望时题目以外的那几样:截至时间、时区、定时的 cron(手动发起为空)、发起人、记进运行 origin 的来历。"""
as_of: datetime
tz: str = "Asia/Shanghai"
cron: str | None = None
actor: str | None = None
suite_id: str | None = None
suite_name: str = ""
eval_run_id: str | None = None
role: str = "gold"
#: 记进运行 origin 的其余字段(臂、第几次重复、试算……)
extra: Mapping[str, Any] = field(default_factory=dict)
timeout_s: float = TRIAL_TIMEOUT_S
def variables(self) -> dict[str, str]:
return variable_values(self.as_of, self.tz, self.cron)
def _run_name(case: Mapping[str, Any], ctx: Context) -> str:
which = f"第 {case['seq']} 题" if case.get("seq") else "新题"
what = "试算" if ctx.extra.get("trial") else ROLE_LABEL.get(ctx.role, "算期望")
return f"评测:{ctx.suite_name or '题集'}·{which}·{what}"
def _origin(case: Mapping[str, Any], ctx: Context, **more: Any) -> dict[str, Any]:
data = {"suite_id": ctx.suite_id, "eval_run_id": ctx.eval_run_id, "case_id": case.get("id"), "role": ctx.role,
**dict(ctx.extra), **more}
return {k: v for k, v in data.items() if v is not None}
# ==========================================================================
# 发起探针、等它结束
# ==========================================================================
def _probe_options() -> dict[str, Any]:
"""探针运行带的选项:查询一律只读、结果在工具边界就按遮罩遮好(同使用者的问数据运行)。"""
from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION
return {MASK_RESULTS_OPTION: True, READ_ONLY_OPTION: True}
@dataclass
class _Ran:
status: str
run_id: str | None
reason: str | None = None
code: str | None = None
async def _launch_and_wait(graph: dict[str, Any], payload: dict[str, Any], *, name: str, origin: dict[str, Any],
ctx: Context) -> _Ran:
"""发起一次探索运行(来源 eval),等它结束(最多 ctx.timeout_s 秒)。"""
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.launch import LaunchRefused, launch_run
from app.engine.runner import run_manager
try:
async with SessionLocal() as session:
run = await launch_run(session, graph=graph, input_payload=payload, actor=ctx.actor,
options=_probe_options(), origin_kind="eval", origin=origin, workflow_name=name)
run_id = run.id
except LaunchRefused as e:
return _Ran("failed", None, f"算期望的运行没有发起:{e.message}", GOLD_FAILED)
from app.cluster import slots
loop = asyncio.get_running_loop()
deadline = loop.time() + max(0.0, float(ctx.timeout_s))
last = loop.time()
while True:
# 执行任务收完尾(含封存)才回来;任务已经不在了(刚建好还没排上、或者已经结束)就立刻回来,再按库里的状态判
await run_manager.wait_idle(run_id, timeout=max(0.05, min(1.0, deadline - loop.time())))
now = loop.time()
if await slots.waiting(run_id, here=run_manager.is_active(run_id)):
# 排队等名额的那一段不算时限(复核:满载时排队不该算进每题时限)
deadline += now - last
last = now
async with SessionLocal() as session:
row = await session.get(Run, run_id)
status, error = (row.status, row.error) if row is not None else ("failed", "运行记录不见了")
if status in ("succeeded", "failed", "cancelled", "interrupted") or loop.time() >= deadline:
break
await asyncio.sleep(min(0.2, max(0.0, deadline - loop.time())))
if status == "succeeded":
return _Ran("ok", run_id)
if status in ("failed", "cancelled"):
return _Ran("failed", run_id, f"算期望的运行{'失败' if status == 'failed' else '被停止'}:{error or '没有记下原因'}",
GOLD_FAILED)
stopping = status == "interrupted"
await run_manager.cancel(run_id, actor=ctx.actor)
await run_manager.wait_idle(run_id, timeout=CANCEL_GRACE_S)
if stopping:
return _Ran("failed", run_id, "算期望的运行停在人工确认上:评测不代批,已取消这次运行", GOLD_FAILED)
return _Ran("timeout", run_id, f"算期望的运行超过 {ctx.timeout_s:g} 秒还没结束,已停止这次运行", GOLD_TIMEOUT)
async def _artifacts(run_id: str, kind: str) -> list[tuple[str | None, tuple[str, Any]]]:
"""这次运行落下的某一种工件:[(节点 id, (工件 id, 内容))],按写入的先后。内容和哈希对不上的内容为 None。"""
from app.core import artifact_store
from app.db.base import SessionLocal
from app.db.models import Artifact
async with SessionLocal() as session:
rows = (await session.execute(select(Artifact.id, Artifact.node_id).where(
Artifact.run_id == run_id, Artifact.kind == kind).order_by(Artifact.created_at, Artifact.id))).all()
out: list[tuple[str | None, tuple[str, Any]]] = []
for artifact_id, node_id in rows:
try:
out.append((node_id, (artifact_id, artifact_store.load(artifact_id))))
except ValueError:
out.append((node_id, (artifact_id, None)))
return out
def masked_problem(snapshot: Mapping[str, Any]) -> str | None:
"""查询快照里有遮掉的列、格(查询时就按遮罩遮好的,engine/masking.mask_query_result):给人看的原因;没有为 None。"""
if snapshot.get("masked_whole"):
return ("期望 SQL 的写法认不全哪些结果取自遮罩列,整份结果都按遮罩处理:期望答案不能用遮罩列(评测页会显示期望值)。"
"请换一种写法(不要用 *、整行引用),只选不涉及遮罩列的列")
columns = [str(c) for c in snapshot.get("masked_at_query") or []]
if columns:
return (f"期望 SQL 的结果里「{'、'.join(columns[:5])}」取自数据源设了遮罩的列:期望答案不能用遮罩列"
"(评测页会显示期望值)。请去掉这几列")
if snapshot.get("masked_cells"):
return "期望 SQL 的结果里有和遮罩值相同的文字:期望答案不能用遮罩列(评测页会显示期望值)。请换一个不涉及遮罩列的问法"
return None
# ==========================================================================
# 从快照、口径卡里取值
# ==========================================================================
class _NoValue(Exception):
pass
def _column(columns: Sequence[str], name: str) -> int:
if name in columns:
return list(columns).index(name)
lowered = [i for i, c in enumerate(columns) if c.lower() == name.lower()]
if len(lowered) == 1:
return lowered[0]
raise _NoValue(f"期望 SQL 的结果里没有列「{name}」(结果的列:{'、'.join(columns) or '(没有)'})")
def _same(cell: Any, wanted: Any) -> bool:
if cell == wanted:
return True
if isinstance(cell, (int, float)) and isinstance(wanted, (int, float)) and not isinstance(cell, bool):
return float(cell) == float(wanted)
return cell is not None and str(cell) == str(wanted)
def _numeric(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _dims(columns: Sequence[str], row: Sequence[Any], skip: int) -> dict[str, Any]:
"""这一行的维度:非数值的格(评分按它和答案里那一格所在行配对,第 3 波)。"""
return {c: row[i] for i, c in enumerate(columns) if i != skip and i < len(row) and row[i] is not None
and not _numeric(row[i])}
def _sql_value(exp: Mapping[str, Any], snapshot: Mapping[str, Any]) -> dict[str, Any]:
columns = [str(c) for c in snapshot.get("columns") or []]
rows = [list(r) if isinstance(r, (list, tuple)) else [r] for r in snapshot.get("rows") or []]
truncated = bool(snapshot.get("truncated"))
kind = exp["kind"]
if kind == "none":
if rows:
raise _NoValue(f"这道题期望「没有数据」,但期望 SQL 返回了 {len(rows)} 行")
return {"kind": "none", "rows": 0}
if kind == "scalar":
ci = _column(columns, exp["column"])
if exp.get("where"):
keys = {_column(columns, k): v for k, v in exp["where"].items()}
hits = [i for i, r in enumerate(rows) if all(i_ < len(r) and _same(r[i_], v) for i_, v in keys.items())]
cond = "、".join(f"{k}={v}" for k, v in exp["where"].items())
if not hits:
raise _NoValue(f"期望 SQL 的结果里没有 {cond} 的行" + ("(结果被截断,只取回了前几行)" if truncated else ""))
if len(hits) > 1:
raise _NoValue(f"期望 SQL 的结果里 {cond} 的行有 {len(hits)} 行,无法确定是哪一行:请在 where 里多写一列")
ri = hits[0]
else:
ri = int(exp.get("row") or 0)
if ri >= len(rows):
raise _NoValue("期望 SQL 没有返回行,没有可用的值:期望没有数据的题请把期望写成「没有数据」" if not rows else
f"期望 SQL 只返回了 {len(rows)} 行,没有第 {ri + 1} 行")
value = rows[ri][ci] if ci < len(rows[ri]) else None
if value is None:
raise _NoValue(f"期望的那一格(第 {ri + 1} 行「{columns[ci]}」)是空值:期望没有数据的题请把期望写成「没有数据」")
return {"kind": "scalar", "value": value, "column": columns[ci], "row": ri,
"dims": _dims(columns, rows[ri], ci), **({"where": dict(exp["where"])} if exp.get("where") else {})}
if truncated:
raise _NoValue("期望 SQL 的结果被截断(只取回了前一部分行),多行、名单的期望算不准:请缩小范围或在 SQL 里汇总")
if kind == "set":
ci = _column(columns, exp["column"])
values = [r[ci] if ci < len(r) else None for r in rows]
if exp.get("top_n"):
values = values[:exp["top_n"]]
return {"kind": "set", "column": columns[ci], "values": values, "ordered": bool(exp.get("ordered"))}
if kind == "table":
key_idx = [_column(columns, k) for k in exp["keys"]]
wanted = [_column(columns, c) for c in exp.get("columns") or columns]
picked = rows[:exp["top_n"]] if exp.get("top_n") else rows
seen: set[tuple[str, ...]] = set()
out_rows: list[dict[str, Any]] = []
for r in picked:
key = tuple(str(r[i]) if i < len(r) else "" for i in key_idx)
if key in seen:
raise _NoValue(f"期望 SQL 的结果按「{'、'.join(exp['keys'])}」配对不唯一({'、'.join(key)} 出现了不止一次)")
seen.add(key)
out_rows.append({columns[i]: (r[i] if i < len(r) else None) for i in dict.fromkeys(key_idx + wanted)})
return {"kind": "table", "keys": [columns[i] for i in key_idx], "columns": [columns[i] for i in wanted],
"rows": out_rows, "ordered": bool(exp.get("ordered"))}
raise _NoValue(f"「{kind}」的期望不从期望 SQL 取值")
def _metric_value(exp: Mapping[str, Any], card: Mapping[str, Any]) -> dict[str, Any]:
metric = next((m for m in card.get("metrics") or [] if m.get("id") == exp["metric"]), None)
if metric is None:
raise _NoValue(f"口径卡这次没有算出指标「{exp['metric']}」")
if metric.get("value") is None:
raise _NoValue(f"口径卡的「{metric.get('name') or metric['id']}」缺输入,没有值(取数没有返回需要的格)")
if metric.get("incomplete"):
raise _NoValue(f"口径卡的「{metric.get('name') or metric['id']}」{metric.get('incomplete_reason') or '基于被截断的查询结果'}"
",期望算不准")
out = {"kind": "scalar", "value": metric["value"], "metric": metric["id"], "name": metric.get("name") or metric["id"],
"unit": metric.get("unit") or "", "decimals": metric.get("decimals"), "format": metric.get("format"),
"rendered": metric.get("rendered"), "caliber": card.get("caliber"),
"caliber_version": card.get("caliber_version")}
if metric.get("sql_check_failed"):
out["sql_check_failed"] = True
return out
def _values(expectations: Sequence[Mapping[str, Any]], pick: Callable[[Mapping[str, Any]], dict[str, Any]]
) -> dict[str, dict[str, Any]]:
return {e["id"]: pick(e) for e in expectations if e.get("kind") in NEEDS_GOLD}
# ==========================================================================
# SQL 探针(SQL 题、从运行认定)
# ==========================================================================
def sql_probe_graph(source_name: str, sql: str) -> dict[str, Any]:
"""入口 → 调用工具(db_query__数据源,args.sql 是渲染好的字面 SQL)→ 出口。"""
from app.tools.datasource import QUERY_PREFIX
def node(nid: str, ntype: str, label: str, x: int, **config: Any) -> dict[str, Any]:
return {"id": nid, "type": ntype, "position": {"x": x, "y": 0}, "data": {"label": label, "config": config}}
nodes = [node("gold_in", "input", "入口", 0), node(QUERY_NODE, "tool", "期望 SQL", 260,
tool=f"{QUERY_PREFIX}{source_name}", args={"sql": sql}),
node("gold_out", "output", "出口", 520)]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def _sql_gold(case: Mapping[str, Any], ctx: Context) -> GoldOutcome:
from app.data.guard import SqlRejected, check
from app.db.base import SessionLocal
from app.db.models import DataSource
gold = case["gold"]
sql = render(gold["sql"], ctx.variables())
rendered = {"sql": sql}
async with SessionLocal() as session:
source = await session.get(DataSource, gold["datasource_id"])
name, kind, enabled = (source.name, source.kind, source.enabled) if source is not None else (None, None, False)
if name is None:
return _outcome("failed", ctx.as_of, "期望 SQL 查的数据源不存在,可能已被删除", GOLD_FAILED, rendered=rendered)
if not enabled:
return _outcome("failed", ctx.as_of, f"期望 SQL 查的数据源「{name}」已停用", GOLD_FAILED, rendered=rendered)
try:
check(sql, readonly=True, source_name=name, dialect=(kind or "").lower())
except SqlRejected as e:
return _outcome("refused", ctx.as_of, f"期望 SQL 没有通过只读守卫:{e}", GOLD_GUARD, rendered=rendered)
ran = await _launch_and_wait(sql_probe_graph(name, sql), {}, name=_run_name(case, ctx), origin=_origin(case, ctx),
ctx=ctx)
runs = [ran.run_id] if ran.run_id else []
if ran.status != "ok":
return _outcome(ran.status, ctx.as_of, ran.reason, ran.code, run_ids=runs, rendered=rendered)
snapshots = [(nid, item) for nid, item in await _artifacts(ran.run_id, "query_snapshot") if nid == QUERY_NODE]
if not snapshots or snapshots[-1][1][1] is None:
return _outcome("failed", ctx.as_of, "算期望的运行没有留下查询快照,期望无从取值", GOLD_FAILED, run_ids=runs,
rendered=rendered)
artifact_id, snapshot = snapshots[-1][1]
if problem := masked_problem(snapshot):
return _outcome("refused", ctx.as_of, problem, GOLD_MASKED, run_ids=runs, rendered=rendered)
try:
values = _values(case["expectations"], lambda e: _sql_value(e, snapshot))
except _NoValue as e:
return _outcome("no_value", ctx.as_of, str(e), GOLD_NO_VALUE, run_ids=runs, rendered=rendered)
return _outcome("ok", ctx.as_of, sets=[GoldSet(run_id=ran.run_id, snapshot=artifact_id, values=values)],
run_ids=runs, rendered=rendered)
# ==========================================================================
# 口径探针、升版处置
# ==========================================================================
@dataclass(frozen=True)
class UpgradeState:
"""口径题钉的那一版现在怎么样。pending:有新的已发布版本(或钉住的退役了)而没处置到那一版,这道题跳过。
versions:这道题要算的版本(钉住的;dual 时再加新版)。"""
pinned: int
latest: int | None
retired: bool
pending: bool
message: str | None
policy: str | None
versions: tuple[int, ...]
def to_dict(self) -> dict[str, Any]:
return {"pinned": self.pinned, "latest": self.latest, "retired": self.retired, "pending": self.pending,
"message": self.message, "policy": self.policy, "versions": list(self.versions)}
def _covered(upgrade: Mapping[str, Any] | None, latest: int | None) -> str | None:
"""题目上的处置管不管得到最新的那一版:管得到交回处置,否则 None。"""
if not upgrade or upgrade.get("policy") not in UPGRADE_LABEL:
return None
if latest is not None and int(upgrade.get("to_version") or 0) < latest:
return None
return str(upgrade["policy"])
async def upgrade_state(session: AsyncSession, gold: Mapping[str, Any], pinned: Any) -> UpgradeState:
"""按正式运行同一个判断(unresolved_caliber_upgrades)、同一个退役认法(subgraph_pin_problems)看这道口径题要不要处置。"""
from app.apps.versions import CALIBER_RETIRED, latest_published, subgraph_pin_problems
from app.engine.governance import unresolved_caliber_upgrades
from app.engine.schema import GraphSpec
from app.ledger import derive
newest = await latest_published(session, pinned.workflow_id)
latest = newest.version if newest is not None else None
upgrade = gold.get("upgrade") or None
policy = _covered(upgrade, latest)
spec = GraphSpec.model_validate(derive.caliber_probe(pinned, upgrade_policy=policy).graph)
errors, _events = await unresolved_caliber_upgrades(session, spec)
retired = any(p.code == CALIBER_RETIRED for p in await subgraph_pin_problems(session, spec))
pending = bool(errors) or retired
message = None
if errors:
message = f"口径已升版到 v{latest},待处置"
elif retired:
message = f"固定的「{pinned.workflow_name}」v{pinned.version} 已退役,待处置"
versions: tuple[int, ...] = (pinned.version,)
if policy == "dual" and upgrade and int(upgrade.get("to_version") or 0) > pinned.version:
versions = (pinned.version, int(upgrade["to_version"]))
return UpgradeState(pinned=pinned.version, latest=latest, retired=retired, pending=pending, message=message,
policy=policy, versions=versions)
async def _caliber_set(case: Mapping[str, Any], pinned: Any, ctx: Context, payload: dict[str, Any],
policy: str | None) -> tuple[GoldSet | None, GoldOutcome | None]:
from app.ledger import derive
probe = derive.caliber_probe(pinned, upgrade_policy=policy)
ran = await _launch_and_wait(probe.graph, payload, name=_run_name(case, ctx),
origin=_origin(case, ctx, derived=probe.origin), ctx=ctx)
runs = [ran.run_id] if ran.run_id else []
if ran.status != "ok":
return None, _outcome(ran.status, ctx.as_of, ran.reason, ran.code, run_ids=runs)
for _nid, (_aid, snapshot) in await _artifacts(ran.run_id, "query_snapshot"):
if isinstance(snapshot, dict) and (problem := masked_problem(snapshot)):
return None, _outcome("refused", ctx.as_of, problem.replace("期望 SQL", "口径的取数"), GOLD_MASKED,
run_ids=runs)
cards = [(aid, body) for nid, (aid, body) in await _artifacts(ran.run_id, "metric_set") if nid == probe.card_ids[0]]
if not cards or not isinstance(cards[-1][1], dict):
return None, _outcome("failed", ctx.as_of, "算期望的运行没有留下口径卡的指标集,期望无从取值", GOLD_FAILED,
run_ids=runs)
artifact_id, card = cards[-1]
try:
values = _values(case["expectations"], lambda e: _metric_value(e, card))
except _NoValue as e:
return None, _outcome("no_value", ctx.as_of, str(e), GOLD_NO_VALUE, run_ids=runs)
return GoldSet(run_id=ran.run_id, snapshot=artifact_id, values=values, version=pinned.version), None
async def _caliber_gold(case: Mapping[str, Any], ctx: Context) -> GoldOutcome:
from app.db.base import SessionLocal
from app.ledger import derive
gold = case["gold"]
variables = ctx.variables()
payload = {k: render(v, variables) for k, v in (gold.get("inputs") or {}).items()}
rendered = {"inputs": payload}
async with SessionLocal() as session:
try:
pinned = await derive.load_pinned(session, gold["workflow_id"], gold["version"], gold["node_id"])
except derive.NotDerivable as e:
return _outcome("refused", ctx.as_of, f"这张口径卡不能当口径题:{e.message}", CALIBER_NOT_REUSABLE,
rendered=rendered)
state = await upgrade_state(session, gold, pinned)
if state.pending:
return _outcome("skipped", ctx.as_of, state.message, UPGRADE_PENDING, rendered=rendered)
pins = [pinned]
for version in state.versions[1:]:
try:
pins.append(await derive.load_pinned(session, gold["workflow_id"], version, gold["node_id"]))
except derive.NotDerivable as e:
return _outcome("refused", ctx.as_of, f"新版 v{version} 不能当口径题:{e.message}", CALIBER_NOT_REUSABLE,
rendered=rendered)
sets: list[GoldSet] = []
runs: list[str] = []
for pin in pins:
found, failed = await _caliber_set(case, pin, ctx, payload, state.policy)
if failed is not None:
failed.run_ids = runs + failed.run_ids
failed.rendered = rendered
if len(pins) > 1:
failed.reason = f"v{pin.version}:{failed.reason}"
return failed
assert found is not None
sets.append(found)
runs.append(found.run_id)
return _outcome("ok", ctx.as_of, sets=sets, run_ids=runs, rendered=rendered)
# ==========================================================================
# 对外:算一次、作答前后各算一次
# ==========================================================================
async def compute_gold(case: Mapping[str, Any], ctx: Context) -> GoldOutcome:
"""按 case(cases.case_fields 的形状,或者还没保存的 normalize_case 结果)算一次期望。没有期望来源的题(只有反问、
文字要点)不用算,交回 ok、没有值。不抛异常:意外也照实记成 failed。"""
gold = case.get("gold") or None
rendered_question = render(case.get("question") or "", ctx.variables())
try:
if gold is None:
outcome = _outcome("ok", ctx.as_of)
elif gold["kind"] in ("sql", "from_run"):
outcome = await _sql_gold(case, ctx)
else:
outcome = await _caliber_gold(case, ctx)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - 算期望是附加的一步:出了意外照实记下,不让评测整体停下
from app.core.errors import describe_exception
logger.exception("评测:算第 %s 题的期望时出错", case.get("seq"))
outcome = _outcome("failed", ctx.as_of, f"算期望时出错:{describe_exception(e)}", GOLD_FAILED)
outcome.rendered = {"question": rendered_question, **outcome.rendered}
return outcome
@dataclass
class Settled:
"""作答前后两次期望合起来的结论。status:ok 两次一样、可以判;drifted 作答期间数据变了,不判;其余照搬没算出来的
那一次(skipped / refused / no_value / failed / timeout)。expected 是能判时用的那一份(作答前的)。"""
status: str
reason: str | None
code: str | None
before: GoldOutcome
after: GoldOutcome | None
@property
def ok(self) -> bool:
return self.status == "ok"
@property
def expected(self) -> list[GoldSet]:
return self.before.sets if self.ok else []
def to_dict(self) -> dict[str, Any]:
"""EvalResult.gold 的形状。"""
return {"status": self.status, "reason": self.reason, "code": self.code, "before": self.before.to_dict(),
"after": self.after.to_dict() if self.after is not None else None}
def _fingerprint(outcome: GoldOutcome) -> str:
from app.core.artifact_store import canonical_json
return canonical_json([[s.version, s.values] for s in outcome.sets])
def settle(before: GoldOutcome, after: GoldOutcome | None) -> Settled:
"""两次期望一样才判。没算出来的那一次的原因照搬(先看作答前的)。"""
if not before.ok:
return Settled(before.status, before.reason, before.code, before, after)
if after is None:
return Settled("failed", "作答后没有复算期望", GOLD_FAILED, before, after)
if not after.ok:
return Settled(after.status, f"作答后复算期望:{after.reason}", after.code, before, after)
if _fingerprint(before) != _fingerprint(after):
return Settled("drifted", "作答期间数据变了(作答前后算出的期望不一样),这道题不判", GOLD_DRIFTED, before, after)
return Settled("ok", None, None, before, after)
async def bracket(case: Mapping[str, Any], ctx: Context, answer: Callable[[GoldOutcome], Awaitable[T]]
) -> tuple[T | None, Settled]:
"""作答前算一次期望 → answer(作答前的期望) → 作答后再算一次 → settle。作答前就算不出来的(升版待处置、拒收、失败)
不作答,省下一次作答的钱,结论照搬原因(第 3 波记「跳过」或「不能评分」,不算答错)。A/B 两臂共用期望:在 answer
里同时跑两臂。"""
before = await compute_gold(case, ctx)
if not before.ok:
return None, settle(before, None)
result = await answer(before)
after = await compute_gold(case, dataclasses.replace(ctx, role="gold_after"))
return result, settle(before, after)
# ==========================================================================
# 口径升版的处置(题目上三选一)
# ==========================================================================
class DisposeRefused(Exception):
"""处置不了:message 给人看,code 给程序认(接口回 409 / 422)。"""
def __init__(self, message: str, code: str, status: int = 409) -> None:
super().__init__(message)
self.message = message
self.code = code
self.status = status
#: 处置被拒的机器码
DISPOSE_NOT_CALIBER = "eval_dispose_not_caliber"
DISPOSE_NOTHING = "eval_dispose_nothing_pending"
DISPOSE_INVALID = "eval_dispose_invalid"
async def plan_dispose(session: AsyncSession, case: Mapping[str, Any], policy: str) -> tuple[dict[str, Any], int]:
"""按 policy 算出处置后的期望来源(还不写库):(新的 gold, 新版本号)。recompute、incomparable 交回改钉新版本的 gold,
dual 交回带上 upgrade 的旧 gold。新版的卡不能当口径题(不能复用、没有这道题的指标)就拒(DisposeRefused)。"""
from app.db.base import utcnow
from app.ledger import derive
if policy not in UPGRADE_LABEL:
raise DisposeRefused(f"口径升版的处置只能是 {'、'.join(UPGRADE_LABEL)} 之一,没有缺省", DISPOSE_INVALID, 422)
gold = case.get("gold") or {}
if gold.get("kind") != "caliber":
raise DisposeRefused("只有口径题有升版处置", DISPOSE_NOT_CALIBER, 422)
try:
pinned = await derive.load_pinned(session, gold["workflow_id"], gold["version"], gold["node_id"])
except derive.NotDerivable as e:
raise DisposeRefused(f"固定的口径版本无法读取:{e.message}", CALIBER_NOT_REUSABLE) from e
state = await upgrade_state(session, gold, pinned)
if not state.pending or state.latest is None or state.latest <= pinned.version:
raise DisposeRefused("这道题固定的口径没有待处置的新版本" if not state.pending else
f"{state.message}:已经没有更新的已发布版本可以改为固定,只能另建新题或删除这道题",
DISPOSE_NOTHING)
latest = state.latest
if policy == "dual" and state.retired:
raise DisposeRefused(f"固定的 v{pinned.version} 已退役,不能新旧两版并算:请改为固定新版本或另建新题", DISPOSE_INVALID)
try:
newer = await derive.load_pinned(session, gold["workflow_id"], latest, gold["node_id"])
except derive.NotDerivable as e:
raise DisposeRefused(f"新版 v{latest} 不能当口径题:{e.message}。请另建新题", CALIBER_NOT_REUSABLE) from e
have = {m["id"] for m in newer.metrics}
lost = [e["metric"] for e in case.get("expectations") or [] if e.get("kind") == "scalar" and e["metric"] not in have]
if lost:
raise DisposeRefused(f"新版 v{latest} 的口径卡上没有指标「{'、'.join(lost)}」:请另建新题", CALIBER_NOT_REUSABLE)
record = {"policy": policy, "from_version": pinned.version, "to_version": latest, "at": utcnow().isoformat()}
if policy == "dual":
return {**copy.deepcopy(dict(gold)), "upgrade": record}, latest
return {**{k: copy.deepcopy(v) for k, v in gold.items() if k != "upgrade"}, "version": latest,
**({"upgrade": record} if policy == "recompute" else {})}, latest
__all__ = ["CANCEL_GRACE_S", "Context", "DISPOSE_INVALID", "DISPOSE_NOTHING", "DISPOSE_NOT_CALIBER", "DisposeRefused",
"GOLD_DRIFTED", "GOLD_FAILED", "GOLD_MASKED", "GOLD_NO_VALUE", "GOLD_TIMEOUT", "GoldOutcome", "GoldSet",
"QUERY_NODE", "ROLES", "STATUSES", "Settled", "TRIAL_TIMEOUT_S", "UPGRADE_LABEL", "UPGRADE_PENDING",
"UpgradeState", "bracket", "compute_gold", "masked_problem", "plan_dispose", "settle", "sql_probe_graph",
"upgrade_state"]