mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:11:58 +00:00
7794794278
- 题目(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)
207 lines
12 KiB
Python
207 lines
12 KiB
Python
"""评测里的一轮作答:无头地走一遍问数据页走的那一轮(细案 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"]
|