Files
AgentLab/backend/app/eval/answer.py
T
ThearchyHelios 7794794278 评测的两种分析期望(贡献、判定):期望 SQL 的探针快照上用平台的算子(app.analysis.run,随数据变化的调用错误记成算不了)算出期望的判定,评分沿用五种结论
- 题目(cases.py):contribution 贡献恒为变化拆解(可加、比率、乘积)、另写 top_n(期望点名主要来源的前几段,缺省 1);verdict 判定是波动基线、趋势判断、差异检验、可比性检查。参数同分析工具(不写输入、总数、下钻,不写阈值),文字参数可以带 {{ schedule.* }};来源、方向、判定不许人填(HUMAN_VALUE_KEYS 加 segment、subjects、direction、verdict 等);口径题、期望反问的题配不了
- 算期望(gold.py、judgements.py):遮罩列、目录事实、现查表的日期范围和分析工具同一套取法;阈值用这次评测的分析设置快照(Context.analysis),试算时现读设置;期望 SQL 只返回两期时可以不写基期、本期;算不了的这一项照实记(评分时不能评分),参数和结果对不上的整题没有可用的值;截断的快照照旧拒收。作答前后两次期望、和上一次比的「数据变了」都不比运行当天、现查时刻、输入快照的工件(stable_sets)
- 评分(score.py、judgements.py,SCORER_VERSION 3):判定码归到同一套判定类再比;答案引用了(判定语、具名的数、结果表的格、句末依据)判定对得上的分析结论是对且可追溯,引用它的那句却写反了不算;没引用但正文按说法表(engine/analysis_claims 同一个 scan、同一套绑定规则)写对了是对但没引用;判定、来源、方向对不上或写成相反、根本没做也没说是错。答案的分析结论从作答运行的台账读、引用从报告文档读,有判断词的句子一并读出;逐题 E1 引用分析结论时按它的输入查询比(下钻顺着上一层找);判定语不再当成答案里的数;老结果的明细里没有这两项按空读
- 执行(runner.py、answer.py):有分析期望的题集开评时拍一份分析设置,算期望、各臂作答的运行(Run.options 的 analysis)都按它,记进指纹(compare 的「分析设置」一项),重试沿用;别的题集的指纹、作答运行的选项不变。助手提示词的指纹加上分析方法(guide 的 id 和全文)、写作者的分析规则和说法表版本
- 总表(stats.py):各期望的结论另按期望的种类各一份(outcomes_by_kind)
2026-10-21 18:11:57 +08:00

207 lines
12 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.
"""评测里的一轮作答:无头地走一遍问数据页走的那一轮(细案 01 做法 1、9;总方案 2.1)。
**量的就是用户拿到的那一轮。** 不另写一份编排:一轮的流程是 app/chat/drive.TurnDriver(问数据的一轮在服务端跑也是它,
app/chat/job.py),生成过程是 api/copilot.answer_ops,几个「决定」是 chat/turn 的纯函数,复核是 api/copilot.review_of_run。这里
只写评测和问数据不一样的那几项(Driver 是 TurnDriver 的子类):
answer_ops → final.next(chat/turn.next_step)→ launch_run(origin=eval) → 等终态 → review_of_run → 要的话带原图重来一次
- 生成:问法就是 instruction,intent=answer,限定的数据源同题目;助手模型按变体覆盖(GenerateIn.provider / model,挑表、
复核跟着换);读哪份目录按变体(answer_ops 的 catalog_view,查看的表详情、自查的 SQL 检查器都按它)。
- 结局:reply 直接回答、asked 反问、fail 判失败(建图失败)、ready 只建不跑、retry(老服务端那条路:没自查过的图带着原因
重来一次)、run 运行。生成过程交出 error(助手本轮未完成)、开流前就拒绝(模型没配好、数据源不在)都是建图失败,另记
「助手调用失败」——连续几题都这样就止损(runner.py);只有生成过程一个操作都没给(只写了思考,error.next=retry)时按
同一个规矩先带着原因重来一次,还不行才记建图失败。每次重来的原因照实记在 retries。
- 运行:fill_input 填入口字段(单轮,history 为空),来源 eval,名字「评测:题集名·第 n 题」,选项带目录视图(Run.options
的 catalog_view:运行时的 db_schema、冻结快照、自查都按它)、只读和遮罩(同使用者的问数据运行:评测页会显示答案里的数,
遮罩值不能从这里露出来;评测不写业务库),有分析期望的题集另带这次评测的分析设置快照(判定用的阈值和算期望同一份)。
写作模型按变体写进图的 defaults:节点自己点了模型的不覆盖,照实记在
own_models。
- 终态:停在审批上(危险工具、人工节点)的直接取消、记「评测不代批」;被驳回的(cancelled + end_reason=rejected,总方案
X9)记路径「被驳回」,不算运行失败;失败、被停止记运行失败。
- 复核:总是等完(问数据页超过 25 秒不等,评测里照等,复核用时另记,超过 25 秒的单列,见 stats.py)。复核给了
retry_instruction 就带着这次运行的图重来一次(chat/turn 的规则,最多试 MAX_ATTEMPTS 次)。
用量按角色记(做法 3、11):助手 = 生成(answer_ops 末尾的 usage)+ 复核(review_of_run 的 usage);写作 = 答案运行里的
llm.end(按模型分)。重来的话两次都算——那都是这一题花的钱。
时限、停止由调用方管(runner.py 用 within 包住 drive,排队等名额的那一段不算):这里随时把正在跑的那次运行记在 current_run,调用方
超时、停止时据此取消它(stop_run)。
"""
from __future__ import annotations
import copy
import logging
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
# 一轮的流程和结果抽到了 chat/drive.py(问数据、评测共用),这里留着老名字:runner、stats 和测试照旧从这里取
from app.chat.drive import CANCEL_GRACE_S, Turn, TurnDriver, add_usage # noqa: F401
logger = logging.getLogger(__name__)
#: 问数据页最多等复核这么久(chat/job.REVIEW_WAIT_S,和以前页面上的 REVIEW_TIMEOUT_MS 同一个数):评测照等,超过的单列
REVIEW_PAGE_WAIT_S = 25.0
#: 运行停在审批上:评测不代批
APPROVAL_REASON = "运行停在审批上(危险工具、人工节点):评测不代批,已取消这次运行"
#: 每一步在做什么(EvalResult.current)
PHASE_TEXT = {"gold": "正在算期望", "build": "正在建图", "run": "运行中", "review": "正在复核", "score": "正在评分",
"gold_after": "作答后复算期望", "judge": "正在请裁判判断"}
@dataclass
class TurnContext:
"""这一轮作答的来历(记进运行 origin)和要用的东西。"""
suite_id: str
suite_name: str
eval_run_id: str
case_id: str | None
case_seq: int | None
actor: str | None
arm: str | None = None
repeat: int = 0
#: 变体:助手模型 {provider, model}、写作模型 {provider, model}
assistant: Mapping[str, Any] = field(default_factory=dict)
writer: Mapping[str, Any] = field(default_factory=dict)
#: 目录视图(Run.options['catalog_view'] 的形状)
view: Mapping[str, Any] | None = None
#: 这次评测的分析设置快照(有分析期望的题集才有):作答的运行按它判(Run.options['analysis']),和算期望同一份
analysis: Mapping[str, Any] | None = None
async def writer_usage(run_ids: list[str]) -> dict[str, Any]:
"""答案运行里的模型调用(llm.end,按模型分):写作这一角色的用量,形状同 providers/usage 的汇总。封存之后追加的
(按需裁判、质询)不算:那是事后另记的账(裁判记在评测的账上另算,见 judging.py)。"""
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import RunEvent
from app.providers.catalog import unpriced_call
out = add_usage(None, None)
if not run_ids:
return out
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent.data).where(
RunEvent.run_id.in_(run_ids), RunEvent.type == EventType.LLM_END))).scalars().all()
for data in rows:
data = data if isinstance(data, dict) else {}
if data.get("post_seal"):
continue
model = str(data.get("model") or "unknown")
piece = {"input_tokens": int(data.get("input_tokens") or 0), "output_tokens": int(data.get("output_tokens") or 0),
"calls": int(data.get("calls") or 1), "cost_usd": float(data.get("cost_usd") or 0.0),
"unpriced_calls": 1 if unpriced_call(data) else 0}
out = add_usage(out, {**piece, "by_model": {model: piece}})
return out
def with_writer(graph: dict[str, Any], writer: Mapping[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""写作模型按变体写进图的 defaults(只写给了的那几项)。返回 (图, 自己点了模型、不会被覆盖的节点 id)。"""
provider, model = writer.get("provider"), writer.get("model")
if not (provider or model):
return graph, []
out = copy.deepcopy(graph)
defaults = dict(out.get("defaults") or {})
if provider:
defaults["provider"] = provider
if model:
defaults["model"] = model
out["defaults"] = defaults
own = []
for node in out.get("nodes") or []:
config = ((node or {}).get("data") or {}).get("config") or {}
if isinstance(config, Mapping) and (config.get("provider") or config.get("model")):
own.append(str(node.get("id")))
return out, own
def _options(view: Mapping[str, Any] | None, analysis: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""作答运行带的选项:目录视图、只读、遮罩(见模块说明);有分析期望的题集另带这次评测的分析设置快照(发起时
engine/launch 见调用方给了就照它的,不再现读设置):判「主要来自」「超出常规波动」的阈值和算期望的是同一份。"""
from app.analysis.contract import SETTINGS_KEY
from app.data.catalog_view import OPTION_KEY
from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION
out: dict[str, Any] = {MASK_RESULTS_OPTION: True, READ_ONLY_OPTION: True}
if view:
out[OPTION_KEY] = dict(view)
if analysis is not None:
out[SETTINGS_KEY] = dict(analysis)
return out
def run_name(suite_name: str, case_seq: int | None) -> str:
return f"评测:{suite_name or '题集'}·第 {case_seq or '?'} 题"
async def stop_run(run_id: str | None, actor: str | None = None) -> None:
"""取消一次还没结束的运行、等它收尾(超时、停止评测、停在审批上时)。已经结束的什么都不做。"""
from app.engine.runner import run_manager
if not run_id:
return
try:
await run_manager.cancel(run_id, actor=actor)
await run_manager.wait_idle(run_id, timeout=CANCEL_GRACE_S)
except Exception: # noqa: BLE001 - 取消是收尾,出错只记日志
logger.exception("评测:取消运行 %s 时出错", run_id)
class Driver(TurnDriver):
"""一轮作答(流程在 TurnDriver)。drive() 跑完一轮,结果在 turn 上;on_phase(阶段) 在每一步开始时调(runner 据此写
「正在做什么」)。和问数据不一样的:助手模型、目录视图按变体;写作模型按变体写进图;来源 eval、只读和遮罩;停在审批上
直接取消(评测不代批);复核一直等。"""
def __init__(self, question: str, *, datasource_ids: list[str], ctx: TurnContext,
on_phase: Callable[[str], Awaitable[None]] | None = None) -> None:
super().__init__(question, datasource_ids=datasource_ids, on_phase=on_phase,
provider=ctx.assistant.get("provider"), model=ctx.assistant.get("model"))
self.ctx = ctx
def catalog_view(self) -> Any:
return self.ctx.view
def prepare_graph(self, graph: dict[str, Any]) -> dict[str, Any]:
graph, own = with_writer(graph, self.ctx.writer)
self.turn.own_models = sorted(set(self.turn.own_models) | set(own))
return graph
async def launch_args(self, session: Any, graph: dict[str, Any]) -> dict[str, Any]:
origin = {k: v for k, v in {
"suite_id": self.ctx.suite_id, "eval_run_id": self.ctx.eval_run_id, "case_id": self.ctx.case_id,
"role": "answer", "arm": self.ctx.arm, "repeat": self.ctx.repeat, "attempt": self.turn.attempts}.items()
if v is not None}
return {"actor": self.ctx.actor, "options": _options(self.ctx.view, self.ctx.analysis), "origin_kind": "eval",
"origin": origin, "workflow_name": run_name(self.ctx.suite_name, self.ctx.case_seq)}
async def on_interrupted(self, run_id: str, row: Any) -> Any | None:
t = self.turn
waiting = await self._pending_approvals(run_id)
await stop_run(run_id, self.ctx.actor)
t.path = "run_failed"
t.reason = APPROVAL_REASON if waiting else "运行停在断点上(服务可能重启过):评测不继续这次运行,已取消"
return None
async def drive_workflow(self, graph: dict[str, Any], inputs: dict[str, Any]) -> Turn:
"""工作流题:钉住那一版的图按输入跑一次探索运行(来源 eval)。不建图、不复核:考的是这一版本身。不发正式运行——
正式运行会被 pick_base 和应用视图的「最近一次封存」取到(同期望探针不发正式运行的理由)。"""
import time
t = self.turn
started = time.monotonic()
try:
await self._phase("run")
if await self._run(graph, inputs) is not None:
t.path = "ran"
return t
finally:
t.timings["total_ms"] = int((time.monotonic() - started) * 1000)
__all__ = ["APPROVAL_REASON", "Driver", "PHASE_TEXT", "REVIEW_PAGE_WAIT_S", "Turn", "TurnContext", "add_usage",
"run_name", "stop_run", "with_writer", "writer_usage"]