评测的两种分析期望(贡献、判定):期望 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)
This commit is contained in:
ThearchyHelios
2026-10-21 18:11:57 +08:00
parent 843be9a556
commit 7794794278
9 changed files with 1181 additions and 37 deletions
+12 -5
View File
@@ -14,7 +14,8 @@ app/chat/job.py),生成过程是 api/copilot.answer_ops,几个「决定」
同一个规矩先带着原因重来一次,还不行才记建图失败。每次重来的原因照实记在 retries。
- 运行:fill_input 填入口字段(单轮,history 为空),来源 eval,名字「评测:题集名·第 n 题」,选项带目录视图(Run.options
的 catalog_view:运行时的 db_schema、冻结快照、自查都按它)、只读和遮罩(同使用者的问数据运行:评测页会显示答案里的数,
遮罩值不能从这里露出来;评测不写业务库)。写作模型按变体写进图的 defaults:节点自己点了模型的不覆盖,照实记在
遮罩值不能从这里露出来;评测不写业务库),有分析期望的题集另带这次评测的分析设置快照(判定用的阈值和算期望同一份)。
写作模型按变体写进图的 defaults:节点自己点了模型的不覆盖,照实记在
own_models。
- 终态:停在审批上(危险工具、人工节点)的直接取消、记「评测不代批」;被驳回的(cancelled + end_reason=rejected,总方案
X9)记路径「被驳回」,不算运行失败;失败、被停止记运行失败。
@@ -68,6 +69,8 @@ class TurnContext:
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]:
@@ -116,14 +119,18 @@ def with_writer(graph: dict[str, Any], writer: Mapping[str, Any]) -> tuple[dict[
return out, own
def _options(view: Mapping[str, Any] | None) -> dict[str, Any]:
"""作答运行带的选项:目录视图、只读、遮罩(见模块说明)。"""
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
@@ -168,8 +175,8 @@ class Driver(TurnDriver):
"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), "origin_kind": "eval", "origin": origin,
"workflow_name": run_name(self.ctx.suite_name, self.ctx.case_seq)}
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
+56 -8
View File
@@ -14,8 +14,14 @@ history 是多轮追问的位置:评测-D12 本轮只做单轮,非空一律
- none 期望答「没有数据」:期望 SQL 返回 0 行。
- ask 期望反问(问法含糊):不能和要答出数的期望写在一起,不要期望答案的来源。
- text 文字要点(points):由裁判判,单列「模型判断」,不计入准确率(评测-D4),所以 required 恒为 false。
- contribution 贡献:在期望 SQL 的结果上做一次变化拆解(mode:additive / ratio / factor;params 同分析工具的参数,
见 app/eval/judgements.py),top_n 是期望点名主要来源的前几段(缺省 1)。期望是算出的主要来源和方向。
- verdict 判定:op 是 baseline / trend / differ / comparable(differ 另写 mode),params 同对应的分析工具。期望是算出的
判定类(常规 / 异常、上升 / 下降 / 无趋势、站得住 / 不足以下结论、可比 / 不可比)。
这两种的参数不写输入(就是期望 SQL 的结果)、不写阈值(按这次评测的分析设置);文字参数可以带 {{ schedule.* }}。
**数值期望不许人填**(评测-D4):期望里写了 value、values、expected、answer、rows 这类键的一律拒收——期望值只能由 SQL、
口径或从运行认定的 SQL 算出来(app/eval/gold.py),人填的数会过期、会填错,也说不清是哪个口径。
口径或从运行认定的 SQL 算出来(app/eval/gold.py),人填的数会过期、会填错,也说不清是哪个口径。分析期望同样:写了
segment、subjects、direction、verdict 这类键(人填的来源、方向、判定)的拒收,判定由平台的算子在期望 SQL 上算。
**期望答案的来源**(gold;scalar、table、set、none 要它,ask、text 不要):
- sql:{datasource_id, sql}。SQL 可以带 {{ schedule.* }},按评测的截至时间渲染;保存前过只读守卫。
@@ -48,14 +54,15 @@ GOLD_GUARD = "eval_gold_guard"
#: 口径题指的卡不能当口径题(422):不存在、不是口径卡、不能复用、取数节点引用了入口字段以外的值
CALIBER_NOT_REUSABLE = "eval_caliber_not_reusable"
EXPECTATION_KINDS = ("scalar", "table", "set", "none", "ask", "text")
#: 要期望答案来源的期望:数都由 SQL / 口径算
NEEDS_GOLD = ("scalar", "table", "set", "none")
EXPECTATION_KINDS = ("scalar", "table", "set", "none", "ask", "text", "contribution", "verdict")
#: 要期望答案来源的期望:数都由 SQL / 口径算,分析的判定由算子在期望 SQL 的结果上算
NEEDS_GOLD = ("scalar", "table", "set", "none", "contribution", "verdict")
#: 不计入准确率的(评测-D4:文字要点由裁判判,单列)
UNCOUNTED = ("text",)
GOLD_KINDS = ("sql", "caliber", "from_run")
#: 期望里出现这些键就是在人填数(评测-D4)
HUMAN_VALUE_KEYS = ("value", "values", "expected", "answer", "rows", "number")
#: 期望里出现这些键就是在人填数(评测-D4);后几个是人填分析的结论(来源的段、方向、判定)
HUMAN_VALUE_KEYS = ("value", "values", "expected", "answer", "rows", "number", "segment", "segments", "subjects",
"direction", "verdict", "judgement", "driver")
#: 每种期望认的键(另有 id、kind、required、label)
_EXPECTATION_KEYS: dict[str, tuple[str, ...]] = {
@@ -65,6 +72,8 @@ _EXPECTATION_KEYS: dict[str, tuple[str, ...]] = {
"none": (),
"ask": (),
"text": ("points",),
"contribution": ("op", "mode", "params", "top_n"),
"verdict": ("op", "mode", "params"),
}
_COMMON_KEYS = ("id", "kind", "required", "label")
#: 期望答案来源的键(客户端能写的);from_run 只能由服务端建
@@ -198,6 +207,9 @@ def _expectation(raw: Any, i: int, *, gold_kind: str | None, taken: set[str]) ->
if kind not in EXPECTATION_KINDS:
raise CaseInvalid(f"{where}的种类只能是 {'、'.join(EXPECTATION_KINDS)}", f"{path}.kind")
human = [k for k in HUMAN_VALUE_KEYS if k in raw]
if human and kind in ("contribution", "verdict"):
raise CaseInvalid(f"{where}写了「{human[0]}」:分析期望的来源、方向、判定不许人填,由平台的分析方法在期望 SQL 的结果上"
"算出来。请写清做哪种分析、按哪几列", f"{path}.{human[0]}")
if human:
raise CaseInvalid(f"{where}写了「{human[0]}」:数值期望不许人填,期望值只能由期望 SQL、口径或从运行认定的 SQL "
"算出来。请写清从期望答案的结果里取哪一格、哪一列或整张表", f"{path}.{human[0]}")
@@ -274,6 +286,42 @@ def _expectation(raw: Any, i: int, *, gold_kind: str | None, taken: set[str]) ->
if len(points) > POINTS_MAX or any(len(p.strip()) > POINT_MAX for p in points):
raise CaseInvalid(f"{where}最多 {POINTS_MAX} 条要点,每条不超过 {POINT_MAX} 个字符", f"{path}.points")
out["points"] = [p.strip() for p in points]
elif kind in ("contribution", "verdict"):
out.update(_analysis_expectation(raw, kind, where, path))
return out
def _analysis_expectation(raw: Mapping[str, Any], kind: str, where: str, path: str) -> dict[str, Any]:
"""贡献、判定两种期望的分析方法、模式、参数(app/eval/judgements.py)。贡献恒为变化拆解,判定是另外四种分析。"""
from app.analysis.contract import MODES
from app.eval import judgements
ops = ("decompose",) if kind == "contribution" else judgements.VERDICT_OPS
op = raw.get("op") if raw.get("op") not in (None, "") else ("decompose" if kind == "contribution" else None)
if op not in ops:
said = "、".join(judgements.op_text(o) for o in ops)
raise CaseInvalid(f"{where}({'贡献' if kind == 'contribution' else '判定'})的分析方法只能是{said}"
+ ("" if op is None else f"(当前为「{op}」)"), f"{path}.op")
out: dict[str, Any] = {"op": op}
modes = MODES.get(op)
mode = raw.get("mode")
if modes:
if mode not in modes:
said = "、".join(judgements.op_text(op, m) for m in modes)
raise CaseInvalid(f"{where}要选一种模式:{said}", f"{path}.mode")
out["mode"] = mode
elif mode not in (None, ""):
raise CaseInvalid(f"{where}是{judgements.op_text(op)},没有模式可选,请去掉模式", f"{path}.mode")
try:
params = judgements.check_params(op, out.get("mode"), raw.get("params"))
except judgements.ParamsInvalid as e:
raise CaseInvalid(f"{where}的{e.message}", f"{path}.params" + (f".{e.key}" if e.key else "")) from None
for key, text in judgements.template_texts(params):
check_variables(f"{where}的参数「{key}」", text, f"{path}.params.{key}")
out["params"] = params
if kind == "contribution":
out["top_n"] = _int(raw.get("top_n", 1), f"{where}点名主要来源的前几段", f"{path}.top_n", low=1,
high=judgements.TOP_N_MAX)
return out
@@ -300,8 +348,8 @@ def normalize_expectations(raw: Any, *, gold_kind: str | None, case_kind: str) -
if "ask" in kinds and case_kind == "workflow":
raise CaseInvalid("工作流题不会反问,期望反问只用于问数据题", "expectations")
if gold_kind is not None and not kinds & set(NEEDS_GOLD):
raise CaseInvalid("有期望答案的来源,却没有要它算的期望:请加一个「一个数」「多行」「名单」或「没有数据」的期望,"
"或者去掉期望答案的来源", "gold")
raise CaseInvalid("有期望答案的来源,却没有要它算的期望:请加一个「一个数」「多行」「名单」「没有数据」「贡献」"
"或「判定」的期望,或者去掉期望答案的来源", "gold")
return out
+4 -2
View File
@@ -11,7 +11,8 @@
「只参数变」(多半是时间范围理解变了);作答路径变了是「路径变」;其余「说不清」。SQL 的 same / params_only / changed 和运行
对比(engine/compare)是同一条规则:data/sql_semantics.normalize_sql 的规范文本和字面量。
**指纹差异**:助手模型、写作模型、目录快照、提示词指纹、表结构、题目、评分规则,哪几项变了。
**指纹差异**:助手模型、写作模型、目录快照、提示词指纹、表结构、题目、评分规则、分析设置(有分析期望的题集开评时拍的
那一份判定阈值),哪几项变了。
**A/B**:同一次发起的几臂(共用 pair_id、共用期望)逐题配对,给两臂各自的准确率、配对表(都对 / 都错 / 只 A 对 / 只 B 对)和
精确 McNemar 双侧 p 值;p ≥ 0.05 时照实写「这批题上看不出差别」。不能判的题不进配对表(单列个数)。
@@ -30,7 +31,8 @@ CAUSES = ("data", "method", "params", "path", "unknown")
CAUSE_LABEL = {"data": "数据变", "method": "做法变", "params": "只参数变", "path": "路径变", "unknown": "说不清"}
#: 指纹里比的几项和叫法
FINGERPRINT_LABEL = {"assistant_model": "助手模型", "writer_model": "写作模型", "catalog": "目录快照",
"prompt": "提示词", "schemas": "表结构", "cases": "题目", "scorer_version": "评分规则"}
"prompt": "提示词", "schemas": "表结构", "cases": "题目", "scorer_version": "评分规则",
"analysis": "分析设置"}
NO_DIFFERENCE = "这批题上看不出差别"
SIGNIFICANT = 0.05
#: 基准能是哪几种状态
+103 -5
View File
@@ -3,8 +3,13 @@
**期望不靠人填数,也不冻结数值。** 每次要期望(保存题目时试算一次、评测时作答前后各一次)都在业务库上重算一遍:
- SQL 题、从运行认定:建一张「入口 → 调用工具(db_query__数据源,args.sql = 按截至时间渲染好的 SQL)→ 出口」的裸图,
以来源 eval 跑探索运行。期望值取这次运行封存的查询快照里指定的格、列或整张表(cases.py 的 expectations)。从运行认定
以来源 eval 发起探索运行。期望值取这次运行封存的查询快照里指定的格、列或整张表(cases.py 的 expectations)。从运行认定
的题和 SQL 题算法一样,只是 SQL 出自某次运行里的一条查询、记着谁在什么时候认定的。
- 分析期望(贡献、判定):在同一份查询快照上用平台的算子算一次分析(app.analysis.run,和作答里的分析工具、分析卡同一个
函数;随数据变化的调用错误记成算不了,refuse_data_errors),判定就是期望(app/eval/judgements.py)。阈值用这次评测的
分析设置快照(Context.analysis,和作答的运行同一份;试算时现读设置),遮罩列、目录事实、现查表的日期范围和分析工具
同一套取法(app/tools/analysis.py)。算不了的这一项照实记(评分时不能评分),参数和期望 SQL 的结果对不上(列不在、期次
不在)的整题没有可用的值。
- 口径题:app/ledger/derive.caliber_probe 从钉住版本拷出口径卡读的取数节点、接一张 caliber_from 固定到那一版的口径卡,
跑探索运行,期望值就是口径卡的指标(取整、单位、比率都由口径卡自己定)。不可复用的卡拒收并给原因。
@@ -31,7 +36,8 @@ unresolved_caliber_upgrades(和正式运行用的是同一个判断),再
- incomparable:旧题归档,按新版本另建一道新题、重开基线。
**作答前后各算一次**(bracket / settle,第 3 波的执行调它):两次一样才判;不一样就记「作答期间数据变了,不判」。A/B 两臂
共用同一次算出的期望:在 answer 里同时跑两臂即可。
共用同一次算出的期望:在 answer 里同时跑两臂即可。两次比的是期望值本身:分析结论里随运行当天、现查时刻、输入快照的
工件变的几项不比(judgements.steady)。
"""
from __future__ import annotations
@@ -157,6 +163,8 @@ class Context:
#: 记进运行 origin 的其余字段(臂、第几次重复、试算……)
extra: Mapping[str, Any] = field(default_factory=dict)
timeout_s: float = TRIAL_TIMEOUT_S
#: 这次评测的分析设置快照(runner 开评时拍一份,作答的运行带同一份);没有(试算)时现读设置
analysis: Mapping[str, Any] | None = None
def variables(self) -> dict[str, str]:
return variable_values(self.as_of, self.tz, self.cron)
@@ -387,6 +395,83 @@ def _values(expectations: Sequence[Mapping[str, Any]], pick: Callable[[Mapping[s
return {e["id"]: pick(e) for e in expectations if e.get("kind") in NEEDS_GOLD}
# ==========================================================================
# 分析期望:在期望 SQL 的快照上算一次分析(app/eval/judgements.py)
# ==========================================================================
@dataclass
class _Analyses:
"""一道题的几项分析期望共用的那几样:输入快照(含遮罩列)、阈值、算子的运行期依赖。用到时才备(_prepared)。"""
snapshot: dict[str, Any]
artifact: str
run_id: str
ctx: Context
ready: tuple[Any, dict[str, Any], Any] | None = None
async def _prepared(found: _Analyses) -> tuple[Any, dict[str, Any], Any]:
"""(算子的输入快照, 阈值, 运行期依赖):遮罩列、目录事实、现查表的日期范围同分析工具(app/tools/analysis.py),按这次
探针运行固定的数据版本解析数据源;阈值是这次评测的分析设置快照,没有(试算)现读设置。"""
if found.ready is not None:
return found.ready
from app.db.base import SessionLocal
from app.db.models import Run
from app.eval.judgements import GOLD_REF
from app.tools.analysis import context_of, input_snapshot, masked_of, settings_of
from app.tools.registry import ToolContext
inp = input_snapshot(found.snapshot, ref=GOLD_REF, artifact=found.artifact, node_id=QUERY_NODE,
masked=await masked_of(found.snapshot, found.artifact))
async with SessionLocal() as session:
row = await session.get(Run, found.run_id)
versions = dict(row.data_versions) if row is not None and row.data_versions is not None else None
tool_ctx = ToolContext(run_id=found.run_id, node_id=QUERY_NODE, data_versions=versions, mask_results=True,
force_readonly=True, fixed_scope=True)
context = await context_of([inp], tool_ctx=tool_ctx)
found.ready = (inp, await settings_of(found.ctx.analysis), context)
return found.ready
async def _analysis_value(exp: Mapping[str, Any], at: int, found: _Analyses) -> dict[str, Any]:
"""一项分析期望的期望值(judgements.expected)。算不了(历史不够、本期不完整……)照实记成这一项的结论;参数和期望 SQL
的结果对不上(列不在、期次不在、不是乘积关系……)抛 _NoValue(整题没有可用的值,试算时照实交回去改)。"""
import app.analysis as analysis_ops # 按模块取 run:测试换替身时只换模块上的这一个函数
from app.analysis.contract import CallError
from app.eval import judgements
where = f"第 {at + 1} 个期望({'贡献' if exp.get('kind') == 'contribution' else '判定'})"
if found.snapshot.get("truncated"):
raise _NoValue("期望 SQL 的结果被截断(只取回了前一部分行),分析期望算不准:请缩小范围或在 SQL 里汇总")
inp, settings, context = await _prepared(found)
try:
request = judgements.request_of(exp, found.ctx.variables(), found.snapshot)
except judgements.ParamsInvalid as e:
raise _NoValue(f"{where}的{e.message}") from None
try:
result = await analysis_ops.run(request, lambda name: inp if name == judgements.GOLD_REF else None,
settings=settings, context=context, refuse_data_errors=True)
except CallError as e:
raise _NoValue(f"{where}的分析参数和期望 SQL 的结果对不上:{e.message}") from None
return judgements.expected(exp, result)
async def _sql_values(case: Mapping[str, Any], snapshot: dict[str, Any], artifact: str, run_id: str,
ctx: Context) -> dict[str, dict[str, Any]]:
"""SQL 题每个期望的值(按期望的先后):取格、列、整张表的照快照取,分析期望在同一份快照上算。取不到抛 _NoValue。"""
from app.eval.judgements import ANALYSIS_KINDS
found = _Analyses(snapshot=snapshot, artifact=artifact, run_id=run_id, ctx=ctx)
out: dict[str, dict[str, Any]] = {}
for at, exp in enumerate(case["expectations"]):
if exp.get("kind") not in NEEDS_GOLD:
continue
out[exp["id"]] = await _analysis_value(exp, at, found) if exp.get("kind") in ANALYSIS_KINDS \
else _sql_value(exp, snapshot)
return out
# ==========================================================================
# SQL 探针(SQL 题、从运行认定)
# ==========================================================================
@@ -437,7 +522,7 @@ async def _sql_gold(case: Mapping[str, Any], ctx: Context) -> GoldOutcome:
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))
values = await _sql_values(case, snapshot, artifact_id, ran.run_id, ctx)
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)],
@@ -622,10 +707,23 @@ class Settled:
"after": self.after.to_dict() if self.after is not None else None}
def stable_sets(sets: Sequence[Any]) -> list[list[Any]]:
"""比较用的几份期望值:[[版本, {期望 id: 值}]],分析结论里随运行当天、现查时刻、输入快照的工件变的几项去掉
(judgements.steady)。sets 是 GoldSet 或它的 to_dict。作答前后两次(_fingerprint)、和上一次比的「数据变了」
(runner.gold_key)都按它。"""
from app.eval.judgements import steady
out = []
for s in sets:
version, values = (s.version, s.values) if isinstance(s, GoldSet) else (s.get("version"), s.get("values"))
out.append([version, {k: steady(v) for k, v in values.items()} if isinstance(values, Mapping) else values])
return out
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])
return canonical_json(stable_sets(outcome.sets))
def settle(before: GoldOutcome, after: GoldOutcome | None) -> Settled:
@@ -717,4 +815,4 @@ __all__ = ["CANCEL_GRACE_S", "Context", "DISPOSE_INVALID", "DISPOSE_NOTHING", "D
"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"]
"stable_sets", "upgrade_state"]
+520
View File
@@ -0,0 +1,520 @@
"""分析期望:贡献(contribution)、判定(verdict)的算法和评分(docs/design/analysis-round/00-总方案.md §3.8)。
**期望怎么算。** 期望 SQL 在业务库上查一次(gold.py 的 SQL 探针,和别的期望同一份快照),在这份快照上用平台的算子
(app.analysis.run,和作答里的分析工具、分析卡同一个函数)算一次分析,判定就是期望:
- 贡献:变化拆解(可加、比率、乘积),参数同分析工具(按维度 by、期次 period、基期 base、本期 target、度量 measure、分子
numerator、分母 denominator、乘积 product、因子 factors、叫法 label),另写 top_n(缺省 1):期望点名主要来源的前几段。
- 判定:波动基线、趋势判断、差异检验、可比性检查,参数同对应的分析工具。
算子要的输入只有这一份快照(编号 GOLD_REF),不写 query、两份快照、总数、下钻。参数里的文字可以带 {{ schedule.* }},按评测
的截至时间渲染(同期望 SQL);期望 SQL 只返回两期时可以不写基期、本期:先的一期是基期、后的一期是本期。阈值用这次评测的
设置快照(和作答的运行同一份,runner.py),不写在期望里;算不了的(历史不够、本期不完整……)这一项不能评分,原因照实写。
**判定类**(JUDGEMENTS):期望和答案里的分析结论都按判定码归到同一套类再比——贡献:source 主要来源(可加的 driver、
concentrated,乘积的 driver)、dispersed 分散、flat 基本持平、mix 主要来自结构变化、rate 主要来自各段自身的变化、mixed
两方面都有影响(比率、乘积的 both);判定:normal 常规波动(within_band)、anomaly 超出常规波动(outside_band、
far_outside_band,看方向)、rising / falling / no_trend、differs 差异站得住(看哪两组、方向)、no_evidence 不足以下结论、
comparable 可比(comparable、conditional)/ incomparable 不可比。
**评分**(score;沿用五种结论):
- 对且可追溯:答案引用了([[a:]]、[[v:A…]]、[[table:A…]]、[[see:A…]])判定对得上的分析结论(同一个算子,变化拆解还要同一种
模式;主要来源要点名期望的那几段,方向一致;差异检验比的是同两组);引用它的那句话里的判断词却和期望对不上的不算。
- 对但没引用:没引用对得上的结论,但正文按说法表(engine/analysis_claims,同一套词表、同一套绑定规则)写对了:说法表认出的
这一类判断词,拿期望的那份分析结论核对得上(点名的段、方向、连续几期都核)。可比性没有对应的判断词,只能引用。
- 错:引用的分析判定对不上、正文写的和期望相反(把常规波动说成异常、把上升说成下降、主要来源说成别的段),或者根本没做也
没说。
- 不能评分:期望算不了。
"""
from __future__ import annotations
import copy
import re
import unicodedata
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import replace
from typing import Any
from app.analysis.contract import OP_LABELS, method_label
#: 两种分析期望
ANALYSIS_KINDS = ("contribution", "verdict")
#: 判定期望能用的分析(贡献恒为变化拆解)
VERDICT_OPS = ("baseline", "trend", "differ", "comparable")
#: 判定类,见模块说明。前六个是贡献的,后九个是判定的
JUDGEMENTS = ("source", "dispersed", "flat", "mix", "rate", "mixed",
"normal", "anomaly", "rising", "falling", "no_trend", "differs", "no_evidence", "comparable",
"incomparable")
#: 分析的输入在算子里的名字(报错里照它说:「期望 SQL 里没有『地区』这一列」)
GOLD_REF = "期望 SQL"
#: 贡献期望最多点名前几段
TOP_N_MAX = 10
#: 期望里不写的参数:输入就是期望 SQL 这一份快照,没有两份快照、总数、下钻
_NOT_IN_EXPECTATION = frozenset({"query", "base_query", "target_query", "headline", "parent"})
#: 期望 SQL 只返回两期时,这几种分析可以不写基期、本期
_TWO_PERIODS = frozenset({"decompose", "differ", "comparable"})
#: 判定码 → 判定类(按算子、模式)
_JUDGEMENT_OF: dict[tuple[str, str | None], dict[str, str]] = {
("decompose", "additive"): {"driver": "source", "concentrated": "source", "dispersed": "dispersed", "flat": "flat"},
("decompose", "ratio"): {"mix_driven": "mix", "rate_driven": "rate", "both": "mixed", "flat": "flat"},
("decompose", "factor"): {"driver": "source", "both": "mixed", "flat": "flat"},
("baseline", None): {"within_band": "normal", "outside_band": "anomaly", "far_outside_band": "anomaly"},
("trend", None): {"rising": "rising", "falling": "falling", "no_trend": "no_trend"},
("differ", None): {"differs": "differs", "no_evidence": "no_evidence"},
("comparable", None): {"comparable": "comparable", "conditional": "comparable", "incomparable": "incomparable"},
}
#: 判定类要看方向的:主要来源、结构或自身为主、分散(总变化的涨跌)、超出常规波动(本期变化的涨跌)、差异站得住(A 相对 B)
_DIRECTED = frozenset({"source", "mix", "rate", "mixed", "dispersed", "anomaly", "differs"})
#: 说法表的类(engine/analysis_claims 的 Claim.key)→ 它说出的判定类;「连续…」「趋势」看方向,见 _said_judgement
_CLAIM_JUDGEMENT = {"source": "source", "dispersed": "dispersed", "mix": "mix", "anomaly": "anomaly", "normal": "normal",
"no_trend": "no_trend", "differ": "differs", "no_diff": "no_evidence"}
#: 期望是这一类时,正文里哪几类判断词算说出了它(别的类只看对不对得上:对不上算说反了)
_AFFIRMS: dict[str, frozenset[str]] = {
"source": frozenset({"source"}), "dispersed": frozenset({"dispersed"}), "mix": frozenset({"source", "mix"}),
"rate": frozenset({"source"}), "mixed": frozenset({"mix"}), "flat": frozenset(), "normal": frozenset({"normal"}),
"anomaly": frozenset({"anomaly"}), "rising": frozenset({"trend"}), "falling": frozenset({"trend"}),
"no_trend": frozenset({"no_trend"}), "differs": frozenset({"differ"}), "no_evidence": frozenset({"no_diff"}),
"comparable": frozenset(), "incomparable": frozenset(),
}
#: 判断词所在的句子最多留多少字给人看
_SNIPPET_MAX = 40
class ParamsInvalid(ValueError):
"""期望的分析参数写得不对(保存题目时、算期望时都可能)。message 给人看,key 是出错的那个参数。"""
def __init__(self, message: str, key: str | None = None) -> None:
super().__init__(message)
self.message = message
self.key = key
# ==========================================================================
# 参数:校验(cases.py 保存题目时)、组成分析请求(gold.py 算期望时)
# ==========================================================================
def param_names(op: str) -> list[str]:
"""期望里这种分析可以写的参数(分析工具的参数去掉输入、总数、下钻),按工具里的先后。"""
from app.tools.builtin.analysis import param_names as tool_params
return [k for k in tool_params(op) if k not in _NOT_IN_EXPECTATION]
def op_text(op: str, mode: str | None = None) -> str:
"""「变化拆解(可加)」「波动基线」:叫法只取契约那一份。"""
return method_label(op, mode) if mode else OP_LABELS.get(op, op)
def template_texts(params: Mapping[str, Any]) -> Iterable[tuple[str, str]]:
"""参数里的文字(含列表、成对列表里的):(参数名, 文字)。{{ schedule.* }} 的校验、渲染按它走。"""
for key, value in params.items():
if isinstance(value, str):
yield key, value
elif isinstance(value, list):
for item in value:
if isinstance(item, str):
yield key, item
elif isinstance(item, list):
yield from ((key, x) for x in item if isinstance(x, str))
def check_params(op: str, mode: str | None, params: Any) -> dict[str, Any]:
"""参数的形状:只能写这种分析的参数,不能写阈值,类型照分析工具的参数定义。交回规范化的参数(去掉空值)。不对抛
ParamsInvalid。列在不在期望 SQL 的结果里要等试算才知道(算子按快照核)。"""
from pydantic import ValidationError
from app.analysis.contract import DEFAULTS, LABELS
from app.tools.builtin.analysis import ARGS
if params is None:
params = {}
if not isinstance(params, Mapping):
raise ParamsInvalid("分析参数需要是一个对象")
allowed = param_names(op)
out: dict[str, Any] = {}
for key, value in params.items():
if key in DEFAULTS:
label = LABELS[key] if LABELS[key].startswith("「") else f"「{LABELS[key]}」"
raise ParamsInvalid(f"参数里写了阈值{label}:阈值不写在期望里,评测按这次评测的分析设置算(和作答用同一份)",
key)
if key not in allowed:
raise ParamsInvalid(f"参数里没有「{key}」,{op_text(op)}可以写:{'、'.join(allowed)}", key)
if value is None or value == "" or value == []:
continue
out[key] = copy.deepcopy(value)
probe = {**out, "query": GOLD_REF, **({"mode": mode} if mode else {})}
try:
ARGS[op].model_validate(probe)
except ValidationError as e:
error = e.errors()[0]
key = str((error.get("loc") or ("",))[0])
field = ARGS[op].model_fields.get(key)
said = (field.description or "") if field is not None else ""
verb = "没写" if error.get("type") == "missing" else "写得不对"
raise ParamsInvalid(f"参数「{key}」{verb}" + (f":{said}" if said else ""), key) from None
return out
def _render(value: Any, variables: Mapping[str, str]) -> Any:
from app.eval.cases import render
if isinstance(value, str):
return render(value, variables)
if isinstance(value, list):
return [_render(v, variables) for v in value]
return value
def _two_periods(snapshot: Mapping[str, Any], period: str) -> tuple[Any, Any]:
"""期望 SQL 只返回两期时的基期、本期:按期次先后(periods.parse_compared 认得出的按它,否则按写法)。"""
from app.analysis import periods
columns = [str(c) for c in snapshot.get("columns") or []]
hits = [i for i, c in enumerate(columns) if c == period] or \
[i for i, c in enumerate(columns) if c.lower() == period.lower()]
if not hits:
raise ParamsInvalid(f"期望 SQL 的结果里没有期次列「{period}」(结果的列:{'、'.join(columns) or '(没有)'})",
"period")
i = hits[0]
values = list(dict.fromkeys(row[i] for row in snapshot.get("rows") or []
if isinstance(row, (list, tuple)) and i < len(row) and row[i] is not None))
if len(values) != 2:
raise ParamsInvalid(f"期望 SQL 的期次列「{period}」里有 {len(values)} 期:不写基期、本期时,期望 SQL 只能返回两期"
"(先的一期是基期、后的一期是本期)", "base")
parsed = periods.parse_compared(values, str(snapshot.get("sql") or ""), columns[i], snapshot.get("dialect"))
if parsed is not None:
items, _ = parsed
order = sorted(range(2), key=lambda k: items[k].ordinal)
else:
order = sorted(range(2), key=lambda k: str(values[k]))
return values[order[0]], values[order[1]]
def request_of(exp: Mapping[str, Any], variables: Mapping[str, str], snapshot: Mapping[str, Any]) -> dict[str, Any]:
"""一项分析期望 → 一次分析的请求(contract.AnalysisRequest):参数按截至时间渲染,输入是期望 SQL 这一份快照;只返回两期
又没写基期、本期的按两期补上。不对抛 ParamsInvalid。"""
from pydantic import ValidationError
from app.tools.builtin.analysis import ARGS, request_of as tool_request
op, mode = str(exp.get("op")), exp.get("mode")
params = {k: _render(v, variables) for k, v in (exp.get("params") or {}).items()}
if op in _TWO_PERIODS and isinstance(params.get("period"), str) and params.get("base") in (None, "") \
and params.get("target") in (None, ""):
params["base"], params["target"] = _two_periods(snapshot, params["period"])
params["query"] = GOLD_REF
if mode:
params["mode"] = mode
try:
args = ARGS[op].model_validate(params)
except ValidationError as e:
key = str((e.errors()[0].get("loc") or ("",))[0])
raise ParamsInvalid(f"参数「{key}」写得不对", key) from None
return tool_request(op, args) # type: ignore[return-value]
# ==========================================================================
# 判定类
# ==========================================================================
def judgement_code(op: Any, mode: Any, code: Any) -> str | None:
"""一份分析结论的判定码 → 判定类;没有对应的(拆解对不上总数)为 None。"""
key = (str(op), str(mode) if op == "decompose" else None)
return _JUDGEMENT_OF.get(key, {}).get(str(code)) if code else None
def _segment(subject: Any) -> str:
"""判定点名的一段(维度键是一个列表,两列用「·」连,同判定语的写法)。"""
from app.analysis.phrases import key_text
return key_text(list(subject)) if isinstance(subject, (list, tuple)) else str(subject)
def norm(name: Any) -> str:
"""比段名、因子名用的写法:全半角、大小写归一,去掉首尾空白。"""
return unicodedata.normalize("NFKC", str(name or "")).casefold().strip()
def expected(exp: Mapping[str, Any], result: Mapping[str, Any]) -> dict[str, Any]:
"""期望 SQL 上算出的那份分析结论 → 这一项的期望值(GoldSet.values 里的一项):判定类、点名的段、方向、判定语,连同整份
结论(评分时说法表按它核对正文;比较作答前后两次期望时去掉随运行当天、现查时刻变的几项,见 steady)。算不了的照实记。"""
op, mode = result.get("op"), result.get("mode")
out: dict[str, Any] = {"kind": exp.get("kind"), "status": result.get("status"), "op": op, "mode": mode,
"method": op_text(str(op), mode), "phrase": result.get("phrase") or ""}
if result.get("status") != "ok":
out.update(reason_code=result.get("reason_code"), reason=result.get("reason") or "原因未知")
out["result"] = copy.deepcopy(dict(result))
return out
verdict = result.get("verdict") or {}
judgement = judgement_code(op, mode, verdict.get("code"))
subjects = [_segment(s) for s in verdict.get("subjects") or []]
if judgement == "source":
subjects = subjects[:int(exp.get("top_n") or 1)]
elif judgement not in ("differs", "no_evidence"):
subjects = []
out.update(verdict=verdict.get("code"), judgement=judgement, subjects=subjects,
direction=verdict.get("direction") if judgement in _DIRECTED else None,
criterion=result.get("criterion") or "")
out["result"] = copy.deepcopy(dict(result))
return out
def steady(value: Any) -> Any:
"""比较用的期望值:分析结论里随运行当天(params.today)、现查时刻(probes[*].at)、输入快照的工件(inputs[*].artifact,
快照里记着查询用时,同一份数据每次查出来工件也不同)变的几项去掉。别的期望原样。作答前后两次期望(gold._fingerprint)、
和上一次比的「数据变了」(runner.gold_key)都按它比。"""
if not (isinstance(value, Mapping) and value.get("kind") in ANALYSIS_KINDS and isinstance(value.get("result"), Mapping)):
return value
result = copy.deepcopy(dict(value["result"]))
if isinstance(result.get("params"), dict):
result["params"].pop("today", None)
for probe in result.get("probes") or []:
if isinstance(probe, dict):
probe.pop("at", None)
for item in result.get("inputs") or []:
if isinstance(item, dict):
item.pop("artifact", None)
return {**value, "result": result}
def view(value: Mapping[str, Any]) -> dict[str, Any]:
"""评分结果里给人看的那一份(期望、答案各一份):判定类、点名的段、方向、判定语、哪一份结论(A 编号)。界面照原样写,
叫法在前端 termsEval。"""
keys = ("judgement", "subjects", "direction", "phrase", "method", "alias", "status", "text")
out = {k: copy.deepcopy(value[k]) for k in keys if value.get(k) not in (None, "", [])}
if value.get("status") == "refused" and value.get("reason"):
out["reason"] = value["reason"]
return out
# ==========================================================================
# 答案里的分析结论
# ==========================================================================
def record_of(result: Mapping[str, Any], *, artifact: str, info: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""答案运行台账里的一份分析结论 → 评分用的那一份:算子、模式、判定类、点名的段、方向、判定语;输入(查询快照、上一层
结论的工件)给逐题 E1 找产出答案的查询。alias、cited 由读答案的地方填。"""
verdict = result.get("verdict") or {}
op, mode = result.get("op"), result.get("mode")
out: dict[str, Any] = {
"artifact": artifact, "op": op, "mode": mode, "method": op_text(str(op), mode), "status": result.get("status"),
"verdict": verdict.get("code"), "judgement": judgement_code(op, mode, verdict.get("code")),
"subjects": [_segment(s) for s in verdict.get("subjects") or []],
"phrase": result.get("phrase") or "", "cited": [],
"inputs": list((info or {}).get("inputs") or [i.get("artifact") for i in result.get("inputs") or []
if isinstance(i, Mapping) and i.get("artifact")]),
}
judgement = out["judgement"]
out["direction"] = verdict.get("direction") if judgement in _DIRECTED else None
if result.get("status") != "ok":
out["reason"] = result.get("reason") or "原因未知"
if (info or {}).get("node_id"):
out["node_id"] = info["node_id"]
return out
def _same_family(want: Mapping[str, Any], record: Mapping[str, Any]) -> bool:
"""答案里的这份结论是不是这一项期望的同类分析:同一个算子;变化拆解还要同一种模式(可加、比率、乘积说的不是一回事)。"""
if record.get("op") != want.get("op"):
return False
return want.get("op") != "decompose" or record.get("mode") == want.get("mode")
def _names(items: Iterable[Any]) -> set[str]:
return {norm(x) for x in items if norm(x)}
def mismatch(want: Mapping[str, Any], got: Mapping[str, Any]) -> str | None:
"""答案里一份同类的分析结论和期望对不对得上:对得上为 None,否则是对不上的那一句(给人看)。"""
if got.get("status") != "ok":
return f"这份分析没有做成({got.get('reason') or '原因未知'})"
judgement = want.get("judgement")
if got.get("judgement") != judgement:
return "判定和期望不是一类"
if judgement in ("differs", "no_evidence"):
mine, theirs = [norm(s) for s in got.get("subjects") or []], [norm(s) for s in want.get("subjects") or []]
if len(theirs) == 2 and (len(mine) != 2 or set(mine) != set(theirs)):
return f"比的是{'和'.join(got.get('subjects') or []) or '别的组'},期望比的是{'和'.join(want.get('subjects') or [])}"
if judgement == "differs" and len(theirs) == 2 and want.get("direction") and got.get("direction"):
same_order = mine == theirs
if (got["direction"] == want["direction"]) != same_order:
return "高低写反了"
return None
if judgement == "source":
missing = [s for s in want.get("subjects") or [] if norm(s) not in _names(got.get("subjects") or [])]
if missing:
return f"主要来源是{'、'.join(got.get('subjects') or []) or '别的段'},不是{'、'.join(missing)}"
if judgement in _DIRECTED and want.get("direction") and got.get("direction") \
and want["direction"] != got["direction"]:
return "方向相反"
return None
# ==========================================================================
# 正文里的判断词:按说法表认,拿期望的那份分析结论核对(engine/analysis_claims 同一套词表和绑定规则)
# ==========================================================================
_SENTENCE_END = re.compile(r"(?<=[。!?!?\n])")
def sentences_of_doc(doc: Mapping[str, Any], aliases: Iterable[str]) -> list[dict[str, Any]]:
"""报告文档里有判断词的句子:{text: 写作者自己写的字(引用渲染出来的值、判定语、引文涂成 \\x00), names: 点名用的样子
(引用渲染出来的值留着), analyses: 这句挂的分析结论编号}。标题、代码块、表格不算(同说法核对)。aliases 是文档目录里
分析结论的编号。"""
from app.engine import analysis_claims as said
from app.engine.evidence import iter_units
known = {a: {} for a in aliases}
out: list[dict[str, Any]] = []
for block, unit in iter_units(dict(doc)):
if block.get("type") in ("heading", "code", "table") or unit.get("kind") in ("heading", "code") \
or unit.get("loc") is not None:
continue
sentence = said._sentence(unit, unit.get("cites") or [], known)
if sentence is not None and said.scan(sentence.text):
out.append({"text": sentence.text, "names": sentence.names, "analyses": list(sentence.analyses)})
return out
def sentences_of_text(text: str) -> list[dict[str, Any]]:
"""没有报告文档时(成果里的文字):按句末标点切开,有判断词的句子。"""
from app.engine import analysis_claims as said
out = []
for piece in _SENTENCE_END.split(text or ""):
piece = piece.strip()
if piece and said.scan(piece):
out.append({"text": piece, "names": piece, "analyses": []})
return out
def _snippet(names: str, start: int, end: int) -> str:
"""判断词连同它所在的分句(给人看)。"""
from app.engine import analysis_claims as said
left, right = said._clause(names, start, end)
text = names[left:right].replace("\x00", "").strip()
return text[:_SNIPPET_MAX] + ("…" if len(text) > _SNIPPET_MAX else "")
def _said_judgement(claim: str, direction: str | None) -> str | None:
if claim == "trend":
return {"up": "rising", "down": "falling"}.get(direction or "")
return _CLAIM_JUDGEMENT.get(claim)
def said_in(want: Mapping[str, Any], sentences: Sequence[Mapping[str, Any]], *,
only: Iterable[str] | None = None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""正文里和这项期望同一类的判断词,拿期望的那份分析结论(want.result)按说法表的绑定规则核对:(说出了期望的那几处,
和期望对不上的那几处)。每处 {text: 所在分句, judgement: 它说出的判定类, subjects: 点名的段, reason: 对不上的原因}。
only 给了时只看挂了这几份结论的句子。
规则同 engine/analysis_claims.check_sentence(同一个 _check):因果词点名的是期望拆解里的段的按来源核;点名的段要在期望
点名的段里,拖累、拉动按那一段的涨跌核;「连续 N 期」按末尾连续同向的期数核。另外按期望的方向核主要来源那句话的涨跌
(「下降主要来自华东」)。"""
from app.engine import analysis_claims as said
gold = want.get("result") or {}
op = want.get("op")
affirms = _AFFIRMS.get(str(want.get("judgement")), frozenset())
lex = said.lexicon()
wanted = set(only) if only is not None else None
good: list[dict[str, Any]] = []
bad: list[dict[str, Any]] = []
for raw in sentences:
if wanted is not None and not wanted & set(raw.get("analyses") or []):
continue
sentence = said.Sentence(text=str(raw.get("text") or ""), names=str(raw.get("names") or ""))
for hit in said.scan(sentence.text):
claim = lex.claim(hit.claim)
left, right = said._clause(sentence.text, hit.start, hit.end)
names = sentence.names[left:right]
spans = said._object(sentence, hit, left, right)
if claim.key == "causal":
if op != "decompose":
continue
cause = said._cause(sentence, hit, left, right)
if not said._named(gold, cause):
continue
hit, claim, spans = replace(hit, claim="source", side="cause"), lex.claim("source"), (cause,)
if claim.op != op:
continue
miss = said._check(lex, hit, gold, names, spans=spans)
reason = miss.human if miss is not None else None
named = next((found for span in spans if (found := said._named(gold, span))), [])
if reason is None and hit.claim == "source" and hit.side != "pull" and want.get("direction") \
and hit.direction and hit.direction != want["direction"]:
reason = "涨跌写反了"
item = {"text": _snippet(sentence.names, hit.start, hit.end),
"judgement": _said_judgement(hit.claim, hit.direction), "subjects": named}
if reason is not None:
bad.append({**item, "reason": reason})
elif hit.claim in affirms:
good.append(item)
return good, bad
def _covered(want: Mapping[str, Any], good: Sequence[Mapping[str, Any]]) -> list[str]:
"""主要来源期望点名的段里,正文说对的那几处一处都没点到的(top_n 要点名的前几段都要说到)。"""
if want.get("judgement") != "source":
return []
named = _names(s for item in good for s in item.get("subjects") or [])
return [s for s in want.get("subjects") or [] if norm(s) not in named]
# ==========================================================================
# 评分
# ==========================================================================
def score(exp: Mapping[str, Any], value: Mapping[str, Any], answer: Any) -> dict[str, Any]:
"""一项分析期望的结论(score.OUTCOMES 之一)。value 是期望值(expected 的形状),answer 是 score.Answer(analyses:
答案运行台账里的分析结论,cited 记着报告怎么引用了它;sentences:有判断词的句子)。纯计算。"""
want = view(value)
if value.get("status") != "ok":
return {"outcome": "ungradable", "want": want, "reason": f"期望算不了:{value.get('reason') or '原因未知'}"}
if value.get("judgement") is None:
return {"outcome": "ungradable", "want": want, "reason": "期望的分析没有给出判定"}
records = [r for r in getattr(answer, "analyses", None) or [] if _same_family(value, r)]
cited = [r for r in records if r.get("cited") and r.get("alias")]
sentences = list(getattr(answer, "sentences", None) or [])
matched = [r for r in cited if mismatch(value, r) is None]
if matched:
found = matched[0]
_, bad = said_in(value, sentences, only=[r["alias"] for r in matched])
if bad:
return {"outcome": "wrong", "want": want, "found": view({**found, "text": bad[0]["text"]}),
"reason": f"引用了 {found['alias']},同一句却写成「{bad[0]['text']}」:{bad[0]['reason']}"}
return {"outcome": "traced", "want": want, "found": view(found)}
good, bad = said_in(value, sentences)
if good:
missing = _covered(value, good)
if not missing:
return {"outcome": "untraced", "want": want, "found": view(good[0])}
return {"outcome": "wrong", "want": want, "found": view(good[0]),
"reason": f"正文只点名了{'、'.join(s for g in good for s in g.get('subjects') or []) or '别的段'},"
f"期望的主要来源还有{'、'.join(missing)}"}
if cited:
first = cited[0]
return {"outcome": "wrong", "want": want, "found": view(first),
"reason": f"答案引用的 {first['alias']}({first.get('phrase') or first.get('method')})"
f"和期望对不上:{mismatch(value, first)}"}
if bad:
return {"outcome": "wrong", "want": want, "found": view(bad[0]),
"reason": f"正文写的「{bad[0]['text']}」和期望对不上:{bad[0]['reason']}"}
method = op_text(str(value.get("op")), value.get("mode"))
if records:
first = records[0]
shown = f"({first['alias']})" if first.get("alias") else ""
return {"outcome": "wrong", "want": want, "found": view(first),
"reason": f"答案做了{method}{shown},但没有引用,正文里也没有写出这个判断"}
return {"outcome": "wrong", "want": want, "reason": f"答案里没有做{method},正文里也没有写出这个判断"}
__all__ = ["ANALYSIS_KINDS", "GOLD_REF", "JUDGEMENTS", "ParamsInvalid", "TOP_N_MAX", "VERDICT_OPS", "check_params",
"expected", "judgement_code", "mismatch", "norm", "op_text", "param_names", "record_of", "request_of",
"said_in", "score", "sentences_of_doc", "sentences_of_text", "steady", "template_texts", "view"]
+34 -8
View File
@@ -95,17 +95,22 @@ def leader_of(run: Any) -> str:
def prompt_fingerprint() -> str:
"""助手提示词的指纹:节点手册、输出协议、问数据的请求包法、重来的两种措辞、复核的规则。改了任何一段,和上一次比时
就标「提示词变了」。"""
"""助手提示词的指纹:节点手册、输出协议、问数据的请求包法、重来的两种措辞、复核的规则;平台的分析方法(分析工具的
guide:id 带版本,连同全文)、写作者的分析规则(写作目录里分析结论那一段、判断词的写法、重写时补的那句)和说法表的
版本。改了任何一段,和上一次比时就标「提示词变了」。"""
from app.api import copilot
from app.chat import turn
from app.core.artifact_store import canonical_json, content_hash
from app.engine import review
from app.engine import analysis_claims, evidence, review
from app.tools.builtin.analysis import METHOD_GUIDE
parts = [copilot.NODE_REFERENCE, copilot._STREAM_PROTOCOL,
copilot._user_message(copilot.GenerateIn(instruction="{question}", intent="answer"), patch=False),
turn.RETRY_WITH_BASE, turn.RETRY_FROM_SCRATCH,
str(getattr(review, "REVIEW_SYSTEM", "") or getattr(review, "SYSTEM_PROMPT", ""))]
str(getattr(review, "REVIEW_SYSTEM", "") or getattr(review, "SYSTEM_PROMPT", "")),
METHOD_GUIDE.id, METHOD_GUIDE.text, evidence.ANALYSIS_PROMPT, evidence.ANALYSIS_SEE_ONLY_PROMPT,
analysis_claims.ANALYSIS_RULES_PROMPT, analysis_claims.ANALYSIS_NONE_PROMPT,
analysis_claims.ANALYSIS_REPAIR_PROMPT, analysis_claims.LEXICON_VERSION]
return content_hash(canonical_json(parts))[:16]
@@ -300,6 +305,8 @@ class EvalExecution(runtime.JobRun):
self.copilot_reason = ""
self.timeouts: list[bool] = []
self.tasks: set[asyncio.Task[Any]] = set()
#: 这次评测的分析设置快照(有分析期望的题集才拍,_prepare):算期望、作答的运行都按它
self.analysis: dict[str, Any] | None = None
def lane_of(self, job: Any) -> str | None:
return None
@@ -354,6 +361,13 @@ class EvalExecution(runtime.JobRun):
raise runtime.JobFailed(f"拍目录快照没有成功:{e.message}") from e
snapshot = snap.artifact_id
versions = await source_versions(session, sources)
# 有分析期望的题集拍一份分析设置(判定用的阈值):算期望、各臂作答的运行都按这一份(重试沿用拍过的那一份),
# 评测期间有人改了设置也不会一边按旧阈值判、一边按新阈值判;记进指纹,和上一次比时阈值变了照实标出
self.analysis = (leader.fingerprint or {}).get("analysis")
if self.analysis is None and self._analyzes():
from app.api.settings import analysis_settings
self.analysis = await analysis_settings(session)
for run in runs:
fp = dict(run.fingerprint or {})
if "sources_before" not in fp:
@@ -366,6 +380,8 @@ class EvalExecution(runtime.JobRun):
sources_before=versions,
schemas={sid: v.get("schema") for sid, v in versions.items()},
cases=_cases_key(results, run.id))
if self.analysis is not None:
fp["analysis"] = dict(self.analysis)
run.fingerprint = fp
run.catalog_snapshot = snapshot if (run.variant.get("catalog") or {}).get("mode") in (
"live", "reviewed") else fp.get("snapshot_artifact")
@@ -384,6 +400,13 @@ class EvalExecution(runtime.JobRun):
units.setdefault(key, _Unit(key[0], key[1], {})).results[r.eval_run_id] = r.id
return list(units.values())
def _analyzes(self) -> bool:
"""这次评测的题里有没有分析期望(贡献、判定)。"""
from app.eval.judgements import ANALYSIS_KINDS
return any(e.get("kind") in ANALYSIS_KINDS for case in self.cases.values()
for e in case.get("expectations") or [])
async def _sources(self, session: AsyncSession) -> list[str]:
"""这次评测用得到的数据源:题目限定的、期望查的;有不限数据源的问数据题时,全部启用的源(助手会在它们里面挑)。"""
from app.db.models import DataSource
@@ -490,7 +513,8 @@ class EvalExecution(runtime.JobRun):
timeout = float(self.budget.get("per_case_timeout_s") or 900)
ctx = gold_rules.Context(as_of=self.as_of, tz=self.suite["timezone"], cron=self.cron, actor=self.actor,
suite_id=self.suite["id"], suite_name=self.suite["name"], eval_run_id=self.job_id,
role="gold", extra={"repeat": unit.repeat}, timeout_s=timeout)
role="gold", extra={"repeat": unit.repeat}, timeout_s=timeout,
analysis=self.analysis)
async def answer(_before: Any) -> list[Any]:
return await asyncio.gather(*(self._answer(arm, unit, case, question, values, timeout) for arm in arms))
@@ -515,7 +539,7 @@ class EvalExecution(runtime.JobRun):
case_id=unit.case_id, case_seq=case.get("seq"), actor=self.actor,
arm=arm.name, repeat=unit.repeat,
assistant=arm.variant.get("assistant") or {}, writer=arm.variant.get("writer") or {},
view=arm.view)
view=arm.view, analysis=self.analysis)
driver = Driver(question, datasource_ids=list(case.get("datasource_ids") or []), ctx=ctx, on_phase=on_phase)
try:
if case.get("kind") == "workflow":
@@ -806,13 +830,15 @@ def _cases_key(results: Sequence[EvalResult], run_id: str) -> str:
def gold_key(gold: Mapping[str, Any] | None) -> str | None:
"""期望值的指纹(作答前那一份):和上一次比时认「数据变了」。"""
"""期望值的指纹(作答前那一份):和上一次比时认「数据变了」。分析结论里随运行当天、现查时刻、输入快照的工件变的几项
不算(gold.stable_sets:不然分析题每次评测都成了「数据变」)。"""
from app.core.artifact_store import canonical_json, content_hash
from app.eval.gold import stable_sets
before = (gold or {}).get("before") or {}
if (gold or {}).get("status") not in ("ok", "drifted"):
return None
return content_hash(canonical_json([[s.get("version"), s.get("values")] for s in before.get("sets") or []]))[:16]
return content_hash(canonical_json(stable_sets(before.get("sets") or [])))[:16]
def _sql_shapes(answer: Any) -> list[dict[str, Any]]:
+127 -9
View File
@@ -26,6 +26,10 @@ wrong 错、scale 量纲不同(差 10 的整数次幂,比如元和万元、
没有数据」引用了地区那一格,说的仍是没有数据。
- ask:期望反问:作答路径是反问才对。
- text:文字要点,由裁判判(covered / partial / missing / unjudged),单列「模型判断」,不进准确率(评测-D4)。
- contribution、verdict(分析期望,规则在 app/eval/judgements.py):答案引用了判定对得上的分析结论([[a:]]、[[v:A…]]、
[[table:A…]]、[[see:A…]])是可追溯;没引用、但正文按说法表写对了是对但没引用;判定、来源、方向对不上,或者答成相反、
根本没做也没说是错;期望算不了的这一项不能评分。答案的分析结论从作答运行的台账(kind analysis)读,报告怎么引用了它们
从报告文档读;正文里有判断词的句子一并读出来(说法表按句子认)。
**题目结论**(verdict):路径不对(要答出数的题直接回答了、反问了、只建不跑、建图或运行失败)直接不通过、写明原因;
必需的期望全是 traced / untraced 就通过,另报「可追溯通过」(全是 traced);有 wrong / scale 的不通过;剩下的(有不能评分的、
@@ -34,8 +38,8 @@ wrong 错、scale 量纲不同(差 10 的整数次幂,比如元和万元、
**逐题的 E1**(sql_match):产出答案的那条查询和期望的取数(SQL 题是期望 SQL,口径题是口径卡的取数节点)用口径账本同一个
比对函数(ledger/match.compare_queries:先绑参数、再由 explain_sql_diff 判等价)比一次,给出等价与否和差在哪个方面(总方案
X2:E1 只有一份实现)。哪条是产出答案的:答案引用了就是被引用的;没引用、只查了一条就是那一条(写明依据);没引用、查了
几条的不硬比(照实写没法对上是哪条,不计入 E1)。
X2:E1 只有一份实现)。哪条是产出答案的:答案引用了就是被引用的(引用的是分析结论的,按它的输入找到查询,下钻的顺着上一层
找下去);没引用、只查了一条就是那一条(写明依据);没引用、查了几条的不硬比(照实写没法对上是哪条,不计入 E1)。
"""
from __future__ import annotations
@@ -52,7 +56,9 @@ logger = logging.getLogger(__name__)
#: 评分规则的版本:规则改了就加一,结果里记着按哪一版评的,可以按新规则重评。
#: 2:期望「没有数据」时引用文字格、空值格不算给了数;判错时「答案里的数」给配对上的那一格;答案没引用查询时逐题 E1
#: 不硬比第一条查询
SCORER_VERSION = 2
#: 3:两种分析期望(贡献、判定):引用了判定对得上的分析结论是可追溯,正文按说法表写对了是对但没引用;答案引用分析结论
#: 时逐题 E1 按它的输入查询比(以前把结论的工件当查询,比不上);分析结论的判定语不当成答案里的数
SCORER_VERSION = 3
OUTCOMES = ("traced", "untraced", "wrong", "scale", "ungradable")
OUTCOME_LABEL = {"traced": "对且可追溯", "untraced": "对但没引用", "wrong": "错", "scale": "量纲不同",
@@ -115,15 +121,38 @@ def _masked_columns(snapshot: Mapping[str, Any]) -> set[str]:
return {str(c) for c in snapshot.get("masked_at_query") or []}
def _result_dims(result: Mapping[str, Any], locator: Mapping[str, Any]) -> dict[str, Any]:
"""分析结论结果表的一格所在行的维度:记成文字的列(地区、期次、因子的叫法……)。结果表里的数一律是十进制字符串,
按列类型分,不按值的样子分;遮罩列不会出现在结果表里。"""
table = result.get("table") if isinstance(result.get("table"), Mapping) else {}
columns = [str(c) for c in table.get("columns") or []]
types = table.get("column_types") if isinstance(table.get("column_types"), Mapping) else {}
rows = table.get("rows") or []
row_index = locator.get("row")
dims: dict[str, Any] = {}
if isinstance(row_index, int) and 0 <= row_index < len(rows) and isinstance(rows[row_index], (list, tuple)):
row = rows[row_index]
dims = {c: row[i] for i, c in enumerate(columns) if i < len(row) and c != locator.get("column")
and row[i] is not None and types.get(c) != "number"}
return {"dims": dims, "masked": False}
def _cell_facts(cite: Mapping[str, Any], catalog: Mapping[str, Any], cache: dict[str, Any]) -> dict[str, Any]:
"""引用的一格:哪份快照、第几行哪一列、这一行的维度(非数值列)、是不是遮罩格。"""
"""引用的一格:哪份快照、第几行哪一列、这一行的维度(非数值列)、是不是遮罩格。分析结论的结果表、具名的数也是这样的
一格(工件是那份结论)。"""
from app.analysis.contract import LEDGER_KIND, is_result
entry = catalog.get(cite.get("alias") or "") or {}
locator = cite.get("locator") or {}
artifact = entry.get("artifact")
out: dict[str, Any] = {"artifact": artifact, "row": locator.get("row"), "column": locator.get("column"), "dims": {}}
if locator.get("key"):
out["column"] = locator["key"]
snapshot = _snapshot(artifact, cache)
if snapshot is None:
return out
if entry.get("kind") == LEDGER_KIND or is_result(snapshot):
return {**out, **_result_dims(snapshot, locator)}
columns = [str(c) for c in snapshot.get("columns") or []]
masked = _masked_columns(snapshot)
out["masked"] = bool(snapshot.get("masked_whole")) or str(locator.get("column")) in masked
@@ -139,7 +168,11 @@ def _cell_facts(cite: Mapping[str, Any], catalog: Mapping[str, Any], cache: dict
@dataclass
class Answer:
"""从封存的作答运行里读出来的答案(评分只看这些)。problem 不为空时整份答案读不了(封存核对不上、没有封存)。"""
"""从封存的作答运行里读出来的答案(评分只看这些)。problem 不为空时整份答案读不了(封存核对不上、没有封存)。
analyses:作答运行台账里的分析结论(judgements.record_of 的形状:算子、判定类、点名的段、方向、判定语、输入;alias 是
报告文档目录里的编号,cited 记报告怎么引用了它:phrase 判定语、value 具名的数、cell 结果表的格、see 句末依据)。
sentences:正文里有判断词的句子(judgements.sentences_of_doc)。老结果的明细里没有这两项,按空读。"""
run_id: str | None
text: str = ""
@@ -151,18 +184,22 @@ class Answer:
tier: str | None = None
report: str | None = None
problem: str | None = None
analyses: list[dict[str, Any]] = field(default_factory=list)
sentences: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {"run_id": self.run_id, "text": self.text, "values": self.values, "bare": self.bare,
"queries": self.queries, "cited": self.cited, "stats": self.stats, "tier": self.tier,
"report": self.report, "problem": self.problem}
"report": self.report, "problem": self.problem, "analyses": self.analyses,
"sentences": self.sentences}
@classmethod
def from_dict(cls, raw: Mapping[str, Any]) -> Answer:
return cls(run_id=raw.get("run_id"), text=str(raw.get("text") or ""), values=list(raw.get("values") or []),
bare=list(raw.get("bare") or []), queries=list(raw.get("queries") or []),
cited=list(raw.get("cited") or []), stats=dict(raw.get("stats") or {}), tier=raw.get("tier"),
report=raw.get("report"), problem=raw.get("problem"))
report=raw.get("report"), problem=raw.get("problem"), analyses=list(raw.get("analyses") or []),
sentences=list(raw.get("sentences") or []))
def _output_text(output: Any) -> str:
@@ -180,11 +217,66 @@ def _output_text(output: Any) -> str:
return "\n".join(parts)
def _ledger_analyses(sealed: Any, cache: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""作答运行台账里的分析结论:{结论工件: judgements.record_of}。取不回、不是分析结论的跳过(证据面板照实说取不回)。"""
from app.analysis.contract import is_result
from app.eval.judgements import record_of
out: dict[str, dict[str, Any]] = {}
for artifact, info in (getattr(sealed, "analyses", None) or {}).items():
content = _snapshot(artifact, cache)
if is_result(content):
out[artifact] = record_of(content, artifact=artifact, info=info)
return out
def _named_analyses(catalog: Mapping[str, Any], found: dict[str, dict[str, Any]],
cache: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""报告文档目录里的分析结论编号 → 那一份(found 里的同一个对象,记上 alias)。目录里有、台账里没收的照工件补上。"""
from app.analysis.contract import LEDGER_KIND, is_result
from app.eval.judgements import record_of
out: dict[str, dict[str, Any]] = {}
for alias, entry in catalog.items():
if not isinstance(entry, Mapping) or entry.get("kind") != LEDGER_KIND or not entry.get("artifact"):
continue
artifact = str(entry["artifact"])
if artifact not in found:
content = _snapshot(artifact, cache)
if not is_result(content):
continue
found[artifact] = record_of(content, artifact=artifact, info={"inputs": entry.get("inputs")})
record = found[artifact]
record.setdefault("alias", str(alias))
out[str(alias)] = record
return out
def _query_inputs(record: Mapping[str, Any], found: Mapping[str, Mapping[str, Any]], queries: Mapping[str, Any],
seen: set[str] | None = None) -> list[str]:
"""一份分析结论的输入里的查询快照(逐题 E1 拿它们当产出答案的查询):下钻的顺着上一层的结论找下去。"""
seen = seen if seen is not None else set()
out: list[str] = []
for artifact in record.get("inputs") or []:
if artifact in queries:
out.append(artifact)
elif artifact in found and artifact not in seen:
seen.add(artifact)
out += _query_inputs(found[artifact], found, queries, seen)
return out
def _cited_as(record: dict[str, Any], how: str) -> None:
if how not in record["cited"]:
record["cited"].append(how)
async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer:
"""读一次作答运行的答案。没有运行(直接回答、反问)的只有原文:reply。"""
from fastapi import HTTPException
from app.api.evidence import _choose, _reports, _sealed
from app.eval import judgements
if not run_id:
text = reply or ""
@@ -209,6 +301,7 @@ async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer
"columns": [str(c) for c in snap.get("columns") or []],
"row_count": snap.get("row_count", len(rows)), "truncated": bool(snap.get("truncated")),
"role": snap.get("role")})
found = _ledger_analyses(sealed, cache)
from app.db.base import SessionLocal
from app.db.models import Run
@@ -222,6 +315,8 @@ async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer
if chosen is None or chosen.doc is None or chosen.hash_ok is False:
answer.text = _output_text(final)
answer.bare = _numbers(answer.text)
answer.analyses = list(found.values())
answer.sentences = judgements.sentences_of_text(answer.text)
if chosen is not None and chosen.hash_ok is False:
answer.problem = "报告文档与哈希不一致,疑似被修改,答案不能当证据"
return answer
@@ -230,11 +325,22 @@ async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer
answer.report = chosen.node_id
answer.text = str(doc.get("markdown") or "")
answer.stats = dict(doc.get("stats") or {})
from app.engine.evidence import iter_segments
from app.engine.evidence import iter_segments, iter_units
named = _named_analyses(catalog, found, cache)
cited: list[str] = []
for seg in iter_segments(doc):
cite = seg.get("cite") or {}
record = named.get(str(cite.get("alias") or "")) if cite.get("status") == "resolved" else None
if record is not None:
# 引用了分析结论:判定语([[a:A1]])、具名的数([[a:A1.键]])、结果表的格([[v:A1.r0.列]]、[[table:A1 …]])。
# 产出答案的查询是它的输入(逐题 E1);判定语是一句话,不是答案里的数
how = "cell" if cite.get("kind") == "cell" else "value" if (cite.get("locator") or {}).get("key") \
else "phrase"
_cited_as(record, how)
cited += _query_inputs(record, found, sealed.queries)
if how == "phrase":
continue
if cite.get("role") == "value" and cite.get("status") == "resolved":
item: dict[str, Any] = {"value": cite.get("value"), "kind": cite.get("kind"), "text": seg.get("text"),
"segment": seg.get("id"), "alias": cite.get("alias")}
@@ -244,11 +350,19 @@ async def read_answer(run_id: str | None, *, reply: str | None = None) -> Answer
else:
item.update(_cell_facts(cite, catalog, cache))
answer.values.append(item)
if item.get("artifact"):
if item.get("artifact") and record is None:
cited.append(item["artifact"])
elif seg.get("issue") == "uncited_number" or (seg.get("kind") == "number" and not cite):
answer.bare += _numbers(str(seg.get("text") or ""))
for _, unit in iter_units(doc):
for support in unit.get("see") or []:
record = named.get(str(support.get("alias") or "")) if support.get("status") == "resolved" else None
if record is not None:
_cited_as(record, "see")
cited += _query_inputs(record, found, sealed.queries)
answer.cited = list(dict.fromkeys(cited))
answer.analyses = list(found.values())
answer.sentences = judgements.sentences_of_doc(doc, named)
return answer
@@ -513,6 +627,10 @@ def _expectation_scores(expectations: Sequence[Mapping[str, Any]], values: Mappi
out.append({**base, **_score_set(exp, gold, answer, cache)})
elif kind == "none":
out.append({**base, **_score_none(answer)})
elif kind in ("contribution", "verdict"):
from app.eval import judgements
out.append({**base, **judgements.score(exp, gold, answer)})
return out
+8
View File
@@ -3,6 +3,8 @@
- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没跑的(停止、中断、
到了上限、止损没开评)另列 not_run,出错的另列 errors。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。
- 各期望的结论(必需的、文字要点除外)合起来一份,另按期望的种类各一份(outcomes_by_kind:贡献、判定这些分析题和
一个数、多行分开看)。
- 作答路径、出具档位(声明了出具的按 formal / degraded / withheld,没声明的单列「探索问答」)、复核结论(verdict × severity)
和自动重来的次数、复核用时超过问数据页等待(25 秒)的题数。
- 结论裁判(打开了才有):supported / partial / contradicted / insufficient / 未判的句数;文字要点的判断单列。
@@ -77,6 +79,11 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) -
traced = [r for r in passed if r.get("traced")]
outcomes = Counter(s.get("outcome") for r in done for s in r.get("scores") or []
if s.get("required") and s.get("kind") != "text")
by_kind: dict[str, Counter] = {}
for r in done:
for s in r.get("scores") or []:
if s.get("required") and s.get("kind") != "text":
by_kind.setdefault(str(s.get("kind")), Counter())[s.get("outcome")] += 1
text = Counter(s.get("outcome") for r in done for s in r.get("scores") or [] if s.get("kind") == "text")
answered = [r for r in done if r.get("path")]
tiers = Counter((r.get("tier") or "explore") for r in answered if r.get("path") == "ran")
@@ -113,6 +120,7 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) -
"accuracy": rate(len(passed), len(graded)),
"traced_accuracy": rate(len(traced), len(graded)),
"outcomes": dict(outcomes),
"outcomes_by_kind": {kind: dict(found) for kind, found in by_kind.items()},
"text": dict(text),
"paths": dict(Counter(r.get("path") for r in answered)),
"tiers": {k: tiers.get(k, 0) for k in TIERS},
+317
View File
@@ -0,0 +1,317 @@
"""分析期望(贡献、判定)的规矩和评分(app/eval/judgements.py、cases.py、score.py;docs/design/analysis-round/00-总方案.md
§3.8)。纯计算:期望的分析结论用 app/analysis/testing 的假结论(形状同真算子),答案直接构造。走真运行的在
tests/test_eval_analysis_runs.py。地区、门店、数字都是虚构的。
- 校验:贡献恒为变化拆解、要选模式;判定是另外四种分析;参数只能写分析工具的那几个、不能写阈值、不能写输入;来源、方向、
判定不许人填;文字参数里的变量只认定时任务那份;口径题配不了分析期望。
- 期望值:判定码归到判定类,主要来源按 top_n 取前几段;比较作答前后两次期望时不看运行当天、现查时刻、输入快照的工件。
- 评分五种结论:引用了判定对得上的结论 → 对且可追溯;正文按说法表写对了、没引用 → 对但没引用;引用的判定对不上、正文
写反了(常规波动说成异常、方向反了、来源说成别的段)、根本没做也没说 → 错;期望算不了 → 不能评分。
"""
from __future__ import annotations
import pytest
import app.db.models # noqa: F401 - conftest 按已导入的模型建表:只跑这一个文件时也要有表
from app.analysis.testing import fake_result
from app.eval import cases, judgements, stats
from app.eval.cases import CaseInvalid, normalize_case
from app.eval.score import Answer, score_case
SQL = {"kind": "sql", "datasource_id": "ds1", "sql": "SELECT region AS 地区, week AS 周, SUM(amount) AS 销售额 FROM orders"}
SOURCE = {"id": "e1", "kind": "contribution", "required": True, "op": "decompose", "mode": "additive",
"params": {"by": ["地区"], "period": "周", "measure": "销售额"}, "top_n": 1}
BAND = {"id": "e1", "kind": "verdict", "required": True, "op": "baseline",
"params": {"period": "周", "measure": "订单数"}}
def invalid(expectation: dict, **gold) -> CaseInvalid:
with pytest.raises(CaseInvalid) as caught:
normalize_case({"question": "上周销售额为什么下降?", "gold": gold or SQL, "expectations": [expectation]})
return caught.value
# --------------------------------------------------------------------------
# 校验
# --------------------------------------------------------------------------
def test_the_two_kinds_are_normalized():
fields = normalize_case({"question": "上周销售额为什么下降?", "gold": SQL, "expectations": [
{"kind": "contribution", "mode": "additive", "params": {"by": ["地区"], "period": "周", "measure": "销售额",
"label": "", "base": None}},
{"kind": "verdict", "op": "comparable", "params": {
"period": "周", "base": "{{ schedule.last_week_start }}", "target": "{{ schedule.this_week_start }}"}}]})
contribution, verdict = fields["expectations"]
assert contribution == {"id": "e1", "kind": "contribution", "required": True, "op": "decompose", "mode": "additive",
"params": {"by": ["地区"], "period": "周", "measure": "销售额"}, "top_n": 1}
assert verdict["op"] == "comparable" and "mode" not in verdict and verdict["params"]["base"].startswith("{{")
assert cases.case_hash(fields) == cases.case_hash(dict(fields)), "指纹照常按内容算"
@pytest.mark.parametrize("expectation, field, words", [
({"kind": "contribution", "op": "baseline", "params": {}}, "expectations.0.op", "只能是变化拆解"),
({"kind": "contribution", "params": {"by": ["地区"]}}, "expectations.0.mode", "要选一种模式"),
({"kind": "verdict", "params": {"period": "周", "measure": "订单数"}}, "expectations.0.op", "波动基线、趋势判断"),
({"kind": "verdict", "op": "decompose", "mode": "additive"}, "expectations.0.op", "当前为「decompose」"),
({"kind": "verdict", "op": "trend", "mode": "additive", "params": {"period": "周", "measure": "m"}},
"expectations.0.mode", "没有模式可选"),
({"kind": "verdict", "op": "differ", "params": {}}, "expectations.0.mode", "差异检验(比率)"),
({"kind": "verdict", "op": "baseline", "params": {"period": "周"}}, "expectations.0.params.measure", "没写"),
({**SOURCE, "params": {**SOURCE["params"], "main_share": 0.3}}, "expectations.0.params.main_share",
"阈值不写在期望里"),
({**SOURCE, "params": {**SOURCE["params"], "query": "Q1"}}, "expectations.0.params.query", "没有「query」"),
({**SOURCE, "params": {**SOURCE["params"], "headline": {"base": "Q1.r0.x", "target": "Q1.r1.x"}}},
"expectations.0.params.headline", "没有「headline」"),
({**SOURCE, "params": {**SOURCE["params"], "base": "{{ schedule.nope }}"}}, "expectations.0.params.base",
"不是可用的变量"),
({**SOURCE, "top_n": 0}, "expectations.0.top_n", "1 到 10"),
({**SOURCE, "params": "地区"}, "expectations.0.params", "需要是一个对象"),
])
def test_wrong_analysis_expectations_are_refused(expectation, field, words):
e = invalid(expectation)
assert e.field == field and words in e.message, (e.field, e.message)
@pytest.mark.parametrize("key", ["segment", "subjects", "direction", "verdict", "judgement", "driver", "value"])
def test_conclusions_cannot_be_filled_in_by_hand(key):
e = invalid({**SOURCE, key: "华东"})
assert e.field == f"expectations.0.{key}" and "不许人填" in e.message and "算出来" in e.message
def test_a_caliber_gold_cannot_carry_an_analysis_expectation():
caliber = {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "c", "inputs": {}}
assert invalid(BAND, **caliber).field == "expectations.0.kind"
# 期望反问的题同样不能带分析期望:问法含糊就不该下判断
with pytest.raises(CaseInvalid):
normalize_case({"question": "最近怎么样?", "gold": SQL, "expectations": [{"kind": "ask"}, BAND]})
def test_the_request_renders_variables_and_fills_two_periods():
snapshot = {"columns": ["地区", "周", "销售额"], "sql": SQL["sql"],
"rows": [["华东", "2026-10-12", 900], ["华东", "2026-10-05", 1000], ["华南", "2026-10-05", 800]]}
values = {"last_week_start": "2026-10-12", "this_week_start": "2026-10-19"}
request = judgements.request_of(SOURCE, values, snapshot)
assert request["op"] == "decompose" and request["mode"] == "additive"
assert request["params"] == {"query": judgements.GOLD_REF, "by": ["地区"], "period": "周",
"base": "2026-10-05", "target": "2026-10-12", "measure": "销售额"}
explicit = {**SOURCE, "params": {**SOURCE["params"], "target": "{{ schedule.last_week_start }}",
"base": "2026-10-05"}}
assert judgements.request_of(explicit, values, snapshot)["params"]["target"] == "2026-10-12"
three = {**snapshot, "rows": [*snapshot["rows"], ["华南", "2026-09-28", 700]]}
with pytest.raises(judgements.ParamsInvalid) as caught:
judgements.request_of(SOURCE, values, three)
assert "有 3 期" in caught.value.message and "只能返回两期" in caught.value.message
# --------------------------------------------------------------------------
# 期望值、比较用的那一份
# --------------------------------------------------------------------------
def _gold(*pairs: tuple[dict, dict]) -> dict:
values = {exp["id"]: judgements.expected(exp, result) for exp, result in pairs}
sets = [{"run_id": "g", "snapshot": "x", "version": None, "values": values}]
return {"status": "ok", "reason": None, "code": None, "before": {"status": "ok", "sets": sets},
"after": {"status": "ok", "sets": sets}}
def _case(*expectations: dict) -> dict:
return {"kind": "ask", "question": "虚构问题", "expectations": list(expectations), "gold": SQL}
def test_expected_values_follow_the_judgement_classes():
driver = judgements.expected(SOURCE, fake_result())
assert (driver["judgement"], driver["subjects"], driver["direction"]) == ("source", ["华东"], "down")
assert driver["phrase"].startswith("销售额下降") and driver["result"]["op"] == "decompose"
two = judgements.expected({**SOURCE, "top_n": 2}, fake_result(verdict="concentrated", subjects=[["华东"], ["华北"]]))
assert two["judgement"] == "source" and two["subjects"] == ["华东", "华北"]
assert judgements.expected(SOURCE, fake_result(verdict="concentrated", subjects=[["华东"], ["华北"]])
)["subjects"] == ["华东"], "top_n 缺省 1:只要点名第一段"
for code, judgement in (("within_band", "normal"), ("outside_band", "anomaly"), ("far_outside_band", "anomaly")):
assert judgements.expected(BAND, fake_result("baseline", None, code))["judgement"] == judgement
for code, judgement in (("comparable", "comparable"), ("conditional", "comparable"),
("incomparable", "incomparable")):
exp = {**BAND, "op": "comparable"}
assert judgements.expected(exp, fake_result("comparable", None, code))["judgement"] == judgement
differ = judgements.expected({**BAND, "op": "differ", "mode": "rate"},
fake_result("differ", "rate", "no_evidence", subjects=[["西南"], ["华东"]],
direction=None))
assert differ["judgement"] == "no_evidence" and differ["subjects"] == ["西南", "华东"]
refused = judgements.expected(BAND, fake_result("baseline", None, None, status="refused",
reason_code="history_short"))
assert refused["status"] == "refused" and refused["reason"] == "历史只有 5 期,至少要 8 期"
assert set(judgements.JUDGEMENTS) >= {v for m in judgements._JUDGEMENT_OF.values() for v in m.values()}
def test_comparing_two_golds_ignores_the_day_the_probe_time_and_the_snapshot_artifact():
from app.eval import gold as gold_rules
def outcome(day: str, at: str, artifact: str) -> gold_rules.GoldOutcome:
result = fake_result("baseline", None, "within_band")
result["params"]["today"] = day
result["probes"] = [{"source": "shop", "table": "orders", "column": "order_date", "min": "2026-04-01",
"max": "2026-10-20", "basis": "live", "at": at}]
result["inputs"] = [{"ref": judgements.GOLD_REF, "artifact": artifact, "role": "data", "columns": ["周"],
"rows": "all"}]
values = {"e1": judgements.expected(BAND, result), "e2": {"kind": "scalar", "value": 330, "column": "gmv"}}
return gold_rules.GoldOutcome(status="ok", as_of="", sets=[gold_rules.GoldSet("r", "s", values)])
before = outcome("2026-10-21", "2026-10-21T09:00:01", "a" * 64)
after = outcome("2026-10-22", "2026-10-22T00:00:03", "b" * 64)
assert gold_rules.settle(before, after).status == "ok"
from app.eval.runner import gold_key
assert gold_key({"status": "ok", "before": before.to_dict()}) == gold_key({"status": "ok", "before": after.to_dict()})
changed = outcome("2026-10-21", "x", "a" * 64)
changed.sets[0].values["e1"]["result"]["probes"][0]["max"] = "2026-10-19"
assert gold_rules.settle(before, changed).status == "drifted", "表的范围变了才算数据变了"
# 别的期望的指纹和以前一样(老结果和新结果比「数据变了」不受影响)
from app.core.artifact_store import canonical_json
plain = gold_rules.GoldOutcome(status="ok", as_of="", sets=[gold_rules.GoldSet("r", "s", {"e2": {"value": 1}})])
assert gold_rules._fingerprint(plain) == canonical_json([[None, {"e2": {"value": 1}}]])
# --------------------------------------------------------------------------
# 评分
# --------------------------------------------------------------------------
def _record(result: dict, alias: str | None = "A1", cited: tuple[str, ...] = ("phrase",)) -> dict:
record = judgements.record_of(result, artifact="c" * 64)
record.update(alias=alias, cited=list(cited))
return record
def _answer(*records: dict, text: str = "", cited_in: tuple[str, ...] = ()) -> Answer:
sentences = judgements.sentences_of_text(text)
for s in sentences:
s["analyses"] = list(cited_in)
return Answer(run_id="r", text=text, analyses=list(records), sentences=sentences)
def _score(exp: dict, result: dict, answer: Answer) -> dict:
graded = score_case(_case(exp), _gold((exp, result)), answer, path="ran")
[item] = graded.scores
return item
def test_a_cited_conclusion_that_agrees_is_traced():
item = _score(SOURCE, fake_result(), _answer(_record(fake_result())))
assert item["outcome"] == "traced" and item["found"]["alias"] == "A1", item
assert item["want"] == {"judgement": "source", "subjects": ["华东"], "direction": "down", "status": "ok",
"phrase": fake_result()["phrase"], "method": "变化拆解(可加)"}
# 主要来源点名了期望的那一段就行:答案的拆解判成集中在华东、华北也对得上
wider = fake_result(verdict="concentrated", subjects=[["华东"], ["华北"]])
assert _score(SOURCE, fake_result(), _answer(_record(wider)))["outcome"] == "traced"
def test_the_text_right_without_a_citation_is_untraced():
item = _score(SOURCE, fake_result(), _answer(text="上周销售额下降主要来自华东。"))
assert item["outcome"] == "untraced" and item["found"]["text"] == "上周销售额下降主要来自华东", item
normal = _score(BAND, fake_result("baseline", None, "within_band"),
_answer(text="订单数的下降在常规波动范围内,不算异常。"))
assert normal["outcome"] == "untraced", normal
rising = _score({**BAND, "op": "trend"}, fake_result("trend", None, "rising", direction="up"),
_answer(text="线上销售额持续上升。"))
assert rising["outcome"] == "untraced", rising
@pytest.mark.parametrize("text, words", [
("订单数的下降属于异常。", "判定是"), # 常规波动说成异常
("上周销售额下降主要来自华南。", "不是华南"), # 主要来源说成别的段
("上周销售额上升主要来自华东。", "涨跌写反了"), # 方向反了
("线上销售额呈下降趋势。", "相反"), # 趋势方向反了
])
def test_saying_the_opposite_is_wrong(text, words):
exp, result = {
"订单数": (BAND, fake_result("baseline", None, "within_band")),
"销售额": (SOURCE, fake_result()),
"线上": ({**BAND, "op": "trend"}, fake_result("trend", None, "rising", direction="up")),
}[next(k for k in ("订单数", "线上", "销售额") if k in text)]
item = _score(exp, result, _answer(text=text))
assert item["outcome"] == "wrong" and words in item["reason"], item
def test_a_cited_conclusion_that_disagrees_is_wrong():
anomaly = fake_result("baseline", None, "outside_band", direction="down")
item = _score(BAND, fake_result("baseline", None, "within_band"), _answer(_record(anomaly)))
assert item["outcome"] == "wrong" and "A1" in item["reason"] and "不是一类" in item["reason"], item
assert item["found"]["judgement"] == "anomaly"
# 差异检验比的是别的两组:对不上
other = fake_result("differ", "rate", "no_evidence", subjects=[["华北"], ["华东"]], direction=None)
want = fake_result("differ", "rate", "no_evidence", subjects=[["西南"], ["华东"]], direction=None)
item = _score({**BAND, "op": "differ", "mode": "rate"}, want, _answer(_record(other)))
assert item["outcome"] == "wrong" and "期望比的是西南和华东" in item["reason"], item
def test_citing_the_right_conclusion_but_writing_the_opposite_is_wrong():
normal = fake_result("baseline", None, "within_band")
item = _score(BAND, normal, _answer(_record(normal, cited=("see",)), text="订单数下降属于异常。",
cited_in=("A1",)))
assert item["outcome"] == "wrong" and "引用了 A1" in item["reason"], item
def test_doing_nothing_and_saying_nothing_is_wrong():
item = _score(BAND, fake_result("baseline", None, "within_band"), _answer(text="上周订单数是 1,203 单。"))
assert item["outcome"] == "wrong" and "没有做波动基线" in item["reason"], item
uncited = _score(BAND, fake_result("baseline", None, "within_band"),
_answer(_record(fake_result("baseline", None, "within_band"), cited=()), text="上周订单数是 1,203 单。"))
assert uncited["outcome"] == "wrong" and "但没有引用" in uncited["reason"], uncited
# 可比性没有对应的判断词:只能引用
exp = {**BAND, "op": "comparable"}
words = _score(exp, fake_result("comparable", None, "incomparable"), _answer(text="本周还没过完,两周不能直接比。"))
assert words["outcome"] == "wrong"
cited = _score(exp, fake_result("comparable", None, "incomparable"),
_answer(_record(fake_result("comparable", None, "incomparable"))))
assert cited["outcome"] == "traced"
def test_an_analysis_that_cannot_be_computed_cannot_be_graded():
refused = fake_result("baseline", None, None, status="refused", reason_code="history_short")
graded = score_case(_case(BAND, {"id": "e2", "kind": "scalar", "column": "gmv", "required": True}),
{**_gold((BAND, refused)), "before": {"status": "ok", "sets": [{"values": {
"e1": judgements.expected(BAND, refused),
"e2": {"kind": "scalar", "value": 330.0, "column": "gmv", "row": 0, "dims": {}}}}]}},
Answer(run_id="r", values=[{"value": 330.0, "kind": "cell", "artifact": "a", "column": "gmv",
"dims": {}}]), path="ran")
band, sales = graded.scores
assert band["outcome"] == "ungradable" and band["reason"] == "期望算不了:历史只有 5 期,至少要 8 期"
assert sales["outcome"] == "traced", "同一题别的期望照常评"
assert graded.verdict == "ungradable"
# 答案里同类的分析做成了算不了:不算答对
item = _score(BAND, fake_result("baseline", None, "within_band"), _answer(_record(refused)))
assert item["outcome"] == "wrong" and "没有做成" in item["reason"], item
def test_top_n_wants_every_named_source():
want = fake_result(verdict="concentrated", subjects=[["华东"], ["华北"]])
exp = {**SOURCE, "top_n": 2}
only_one = _score(exp, want, _answer(text="上周销售额下降集中在华东。"))
assert only_one["outcome"] == "wrong" and "还有华北" in only_one["reason"], only_one
both = _score(exp, want, _answer(text="上周销售额下降集中在华东、华北。"))
assert both["outcome"] == "untraced", both
def test_old_answers_without_analyses_read_as_empty():
old = Answer.from_dict({"run_id": "r", "text": "上周销售额 330。", "values": [], "bare": []})
assert old.analyses == [] and old.sentences == []
item = _score(SOURCE, fake_result(), old)
assert item["outcome"] == "wrong", item
again = Answer.from_dict(_answer(_record(fake_result())).to_dict())
assert again.analyses[0]["alias"] == "A1"
def test_the_summary_counts_outcomes_by_kind():
rows = [{"status": "ok", "verdict": "pass", "case_id": "c1", "scores": [
{"kind": "contribution", "required": True, "outcome": "traced"},
{"kind": "scalar", "required": False, "outcome": "wrong"}]},
{"status": "ok", "verdict": "fail", "case_id": "c2", "scores": [
{"kind": "verdict", "required": True, "outcome": "wrong"},
{"kind": "scalar", "required": True, "outcome": "untraced"},
{"kind": "text", "required": False, "outcome": "covered"}]}]
summary = stats.summarize(rows)
assert summary["outcomes_by_kind"] == {"contribution": {"traced": 1}, "verdict": {"wrong": 1},
"scalar": {"untraced": 1}}
assert summary["outcomes"] == {"traced": 1, "wrong": 1, "untraced": 1}