mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
2371e75f17
为什么:第 2 波只有题集和期望答案的来源,评测本身跑不起来。这一段把细案 01 做法 9–12、16、17 的后端接上, 量的是用户在问数据页拿到的同一轮,而不是另抄一份编排。 - eval/answer.py:无头作答,串起 answer_ops → chat/turn 的决定 → launch_run(origin=eval,带目录视图、只读、遮罩) → 等终态(停在审批的取消、记「评测不代批」;驳回记路径「被驳回」)→ review_of_run → 按同一规则重来一次; 工作流题按钉住版本的图跑探索运行(不发正式运行,免得被 pick_base 和应用视图取到)。用量按角色记 - eval/runner.py:用 core/job_runtime,一次评测一个任务,题 × 重复 × 臂一项;A/B 几臂共用期望、同一题同时开跑; 开评拍目录快照、记上传表格版本和表结构哈希的前后;并发不超过 max_parallel 和全局并发的四分之一;每题时限; 评测单独一本账(开跑前估算、每题预留后找补、触顶不再开新题);止损;能停、只重试没做完的、重启标中断不续跑 - eval/score.py:六种期望、五种结论(量纲不同单列)、维度配对与弱匹配、路径不对直接不通过、口径 dual 两版都报; 逐题 E1 用口径账本的 compare_queries(总方案 X2,不另写 signals);SCORER_VERSION 可重评(eval/rescore.py,只读封存) - eval/judging.py:结论裁判走按需裁判同一条路(evidence._judge_now),文字要点由裁判判、单列不计准确率;花费经 judge.charged_to 记在评测那本账上(engine/judge.py 只加了一个上下文变量和取账本的一处) - eval/stats.py、eval/compare.py:准确率与可追溯准确率 + Wilson 区间、各项分布、P50/P90、按角色成本、引用率、逐题 E1; 选基准、逐题转移与归因、指纹差异、A/B 配对表与精确 McNemar;「目录贡献」预设对照(当前 vs 关,可加只用已核实) - eval/cleanup.py + core/artifact_store.forget:保留最近 N 次 + 钉住的基线 + A/B 另几臂,删结果、明细工件、底下的运行和 引用行,工件文件一条引用都不剩才删;总表永久保留 - engine/tolerance.py:双盲复算的 _agree / _delta_pct 原样搬来共用(dual_check 改 import,行为不变) - 设置加 eval 一组(builder 读、admin 写)和 GET /api/settings/eval/spend;题集预算在全站缺省上覆盖,每日上限只能往下收 - api/evals.py:发起(单臂 / A/B / 目录贡献预设)、估算、进度、逐题、停止、重试、重评、钉基线、和上一次比、配对表、清理 - main.py 的 lifespan:影子起草和评测的启动整理、关停(评测先于运行停,停时取消自己发起的运行) - 测试:test_eval_e2e(dual_check_shop + 剧本模型:按支付日 330 通过且 SQL 等价,按下单日 310 不通过、差在 time; 目录对照 1:0;和上一次比找到退步、归因做法变);通知事件、定时接口形状的老测试按新事件和新字段更新
174 lines
9.8 KiB
Python
174 lines
9.8 KiB
Python
"""评测里的两种模型判断:结论裁判、文字要点(细案 01 做法 10、11;评测-D4)。都只在变体打开了裁判时做,都是概率性的判断,
|
||
单列「模型判断」,不进准确率;花费都记在评测那本账上(engine/spend.eval_spend),不占裁判自己的每日额度。
|
||
|
||
- 结论裁判(judge_claims):走按需裁判同一条路——api/evidence 的 _judge_now(证据面板上点「请模型判断」就是它):只取封存
|
||
范围内的工件、遮罩列不给裁判、判定作为 evidence.judged 追加在封存之后(post_seal)、报告文档一个字不改。送哪几句由裁判
|
||
自己的预筛定(judge.candidates:结论句,挂了依据的在前)。花费经 judge.charged_to 记在评测那本账上、按评测的每日上限。
|
||
结果按 supported / partial / contradicted / insufficient / 未判 数句数。
|
||
- 文字要点(judge_points):问法、答案原文和要点交给裁判模型(设置里的证据裁判模型,和按需裁判同一个取值链),逐条说答到
|
||
了(covered)、答到一部分(partial)、没答到(missing),附一句理由。答案原文只是这次运行的成果(遮过的),要点是人写的。
|
||
不放查询结果、不放 SQL:要点问的是「说没说到」,不是「数对不对」(数由确定性的评分判)。
|
||
|
||
判断出错(模型没配好、超时、格式不对)一律照实记「没有判」和原因,不挡评分。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from collections.abc import Mapping, Sequence
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 结论裁判按句数分(judge.COUNT_KEYS 里评测关心的几种,unsupported 是旧数据,并进 contradicted)
|
||
CLAIM_KEYS = ("supported", "partial", "contradicted", "insufficient", "unjudged")
|
||
#: 文字要点一次最多交给裁判多少字的答案
|
||
ANSWER_CHARS = 6000
|
||
#: 文字要点的输出额度、时限
|
||
POINTS_MAX_TOKENS = 2048
|
||
POINTS_TIMEOUT_S = 90
|
||
|
||
POINTS_SYSTEM_PROMPT = """\
|
||
你是评测的裁判。下面给出一个问题、对它的回答,以及期望回答里应当说到的几条要点。逐条判断回答有没有说到这条要点:
|
||
- covered:说到了(意思一致即可,不要求字面相同)
|
||
- partial:说到了一部分,或者说得含糊
|
||
- missing:没有说到,或者说的和要点相反
|
||
只看回答里写了什么,不要自己补充事实,也不要判断回答里的数对不对(数另有确定性的核对)。
|
||
只输出一个 JSON 数组,每条要点一项:[{"point": 要点序号(从 1 起), "verdict": "covered|partial|missing", "reason": "一句话理由"}]"""
|
||
POINTS_HUMAN_PROMPT = "问题:{question}\n\n回答:\n{answer}\n\n要点:\n{points}"
|
||
|
||
|
||
async def judge_claims(run_id: str, *, daily_max_usd: float | None) -> dict[str, Any]:
|
||
"""按需裁判这次作答的报告(结论句)。{counts, model, cost_usd, calls, reason};判不了的照实写 reason。"""
|
||
from fastapi import HTTPException
|
||
|
||
from app.api import evidence
|
||
from app.engine import judge as judging
|
||
from app.engine.spend import eval_spend
|
||
|
||
empty = {k: 0 for k in CLAIM_KEYS}
|
||
try:
|
||
async with evidence._JUDGING.hold(run_id):
|
||
sealed = await evidence._sealed(run_id)
|
||
if not sealed.sealed or not sealed.trusted or not sealed.ledger_on:
|
||
return {"counts": empty, "reason": "作答的运行没有可信的封存,不判"}
|
||
reports = evidence._reports(sealed)
|
||
if not reports:
|
||
return {"counts": empty, "reason": "答案没有报告文档,没有结论句可判"}
|
||
chosen = evidence._choose(reports, None, sealed)
|
||
if chosen.doc is None or chosen.hash_ok is False:
|
||
return {"counts": empty, "reason": "报告文档读不出来或被改过,不判"}
|
||
units = [c.unit for c in judging.candidates(chosen.doc)][:evidence.JUDGE_MAX_UNITS]
|
||
if not units:
|
||
return {"counts": empty, "reason": "报告里没有要判的结论句"}
|
||
with judging.charged_to(eval_spend, daily_max_usd):
|
||
result = await evidence._judge_now(sealed, chosen, units)
|
||
except HTTPException as e:
|
||
return {"counts": empty, "reason": str(e.detail)}
|
||
except Exception as e: # noqa: BLE001 - 裁判是附加的一步:出错照实记,不挡评分
|
||
from app.core.errors import describe_exception
|
||
|
||
logger.exception("评测:结论裁判出错(运行 %s)", run_id)
|
||
return {"counts": empty, "reason": f"结论裁判出错:{describe_exception(e)}"}
|
||
counts = dict(empty)
|
||
for verdict in (result.get("verdicts") or {}).values():
|
||
status = (verdict or {}).get("status")
|
||
status = "contradicted" if status == judging.UNSUPPORTED else status
|
||
if status in counts:
|
||
counts[status] += 1
|
||
elif status != "not_a_claim":
|
||
counts["unjudged"] += 1
|
||
return {"counts": counts, "units": len(units), "model": result.get("model"), "priced": result.get("priced"),
|
||
"cost_usd": float(result.get("cost_usd") or 0.0), "calls": int(result.get("calls") or 0),
|
||
"limited": bool(result.get("limited")), "reason": result.get("message")}
|
||
|
||
|
||
def _parse_points(text: str, n: int) -> list[dict[str, Any]] | None:
|
||
match = re.search(r"\[.*\]", text or "", re.DOTALL)
|
||
if match is None:
|
||
return None
|
||
try:
|
||
items = json.loads(match.group(0))
|
||
except ValueError:
|
||
return None
|
||
if not isinstance(items, list):
|
||
return None
|
||
out: dict[int, dict[str, Any]] = {}
|
||
for item in items:
|
||
if not isinstance(item, Mapping):
|
||
continue
|
||
index = item.get("point")
|
||
verdict = item.get("verdict")
|
||
if isinstance(index, int) and 1 <= index <= n and verdict in ("covered", "partial", "missing"):
|
||
out[index] = {"point": index, "verdict": verdict, "reason": str(item.get("reason") or "")[:200]}
|
||
if len(out) != n:
|
||
return None
|
||
return [out[i] for i in range(1, n + 1)]
|
||
|
||
|
||
async def judge_points(question: str, answer: str, expectations: Sequence[Mapping[str, Any]], *,
|
||
daily_max_usd: float | None, per_run_left: float | None = None) -> dict[str, Any]:
|
||
"""文字要点:{expectations: {期望 id: {points: [{point, verdict, reason}], reason?}}, model, cost_usd, calls, reason?}。
|
||
先按目录价估一次、在评测那本账上预留,加上会超过每日上限(或这次评测剩下的钱)就不判、照实写。"""
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
|
||
from app.core.errors import describe_exception
|
||
from app.db.base import SessionLocal
|
||
from app.engine.judge import judge_model_spec
|
||
from app.engine.spend import eval_spend
|
||
from app.providers import usage as usage_meter
|
||
from app.providers.catalog import estimate_cost, find_pricing
|
||
from app.providers.factory import get_chat_model
|
||
|
||
texts = [e for e in expectations if e.get("kind") == "text" and e.get("points")]
|
||
if not texts:
|
||
return {"expectations": {}}
|
||
if not (answer or "").strip():
|
||
return {"expectations": {}, "reason": "答案是空的,文字要点没有判"}
|
||
try:
|
||
async with SessionLocal() as session:
|
||
spec = await judge_model_spec(session, None)
|
||
model, model_id = await get_chat_model(session, spec.model_copy(update={
|
||
"max_tokens": POINTS_MAX_TOKENS, "timeout": POINTS_TIMEOUT_S}))
|
||
except Exception as e: # noqa: BLE001 - 裁判模型没配好:照实写没有判
|
||
return {"expectations": {}, "reason": f"裁判模型用不了:{describe_exception(e)}"}
|
||
priced = find_pricing(model_id) is not None
|
||
out: dict[str, Any] = {}
|
||
meter = usage_meter.meter()
|
||
reason = None
|
||
for exp in texts:
|
||
points = [str(p) for p in exp.get("points") or []]
|
||
human = POINTS_HUMAN_PROMPT.format(question=question, answer=answer[:ANSWER_CHARS],
|
||
points="\n".join(f"{i}. {p}" for i, p in enumerate(points, start=1)))
|
||
estimate = estimate_cost(model_id, (len(POINTS_SYSTEM_PROMPT) + len(human)) // 2, 60 * len(points)) if priced \
|
||
else 0.0
|
||
if per_run_left is not None and priced and estimate > per_run_left:
|
||
out[exp["id"]] = {"reason": "这次评测的金额上限不够再判文字要点"}
|
||
continue
|
||
day = await eval_spend.charge(estimate, limit=daily_max_usd if priced else None, priced=priced)
|
||
if day is None:
|
||
out[exp["id"]] = {"reason": "评测今天的金额已到上限,文字要点没有判"}
|
||
continue
|
||
before = meter.summary()["cost_usd"]
|
||
try:
|
||
with usage_meter.metering(meter):
|
||
reply = await model.ainvoke([SystemMessage(POINTS_SYSTEM_PROMPT), HumanMessage(human)])
|
||
text = reply.content if isinstance(reply.content, str) else json.dumps(reply.content, ensure_ascii=False)
|
||
parsed = _parse_points(text, len(points))
|
||
out[exp["id"]] = {"points": parsed} if parsed else {"reason": "裁判的回复格式不对,文字要点没有判"}
|
||
except Exception as e: # noqa: BLE001
|
||
logger.exception("评测:判文字要点出错")
|
||
reason = f"判文字要点出错:{describe_exception(e)}"
|
||
out[exp["id"]] = {"reason": reason}
|
||
spent = meter.summary()["cost_usd"] - before
|
||
await eval_spend.settle(day, (spent if priced else 0.0) - estimate)
|
||
if per_run_left is not None:
|
||
per_run_left -= spent
|
||
summary = meter.summary()
|
||
return {"expectations": out, "model": model_id, "usage": summary, "cost_usd": summary["cost_usd"],
|
||
"calls": summary["calls"], **({"reason": reason} if reason else {})}
|
||
|
||
|
||
__all__ = ["CLAIM_KEYS", "POINTS_HUMAN_PROMPT", "POINTS_SYSTEM_PROMPT", "judge_claims", "judge_points"]
|