Files
ThearchyHelios 2371e75f17 评测执行的主干:后台评测、无头作答、评分、统计、对比与 A/B、清理(下一轮第 3 波 W3-c 第一段)
为什么:第 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;和上一次比找到退步、归因做法变);通知事件、定时接口形状的老测试按新事件和新字段更新
2026-10-16 16:38:09 +08:00

174 lines
9.8 KiB
Python
Raw Permalink 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 做法 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"]