Files

358 lines
22 KiB
Python

"""评分(app/eval/score.py;细案 01 做法 10,评测-D4)。纯计算的部分直接喂构造好的答案;读封存、逐题 E1、重评走一次真的
运行(tests/ledger_samples 的店铺库)。数字、问法、地区都是虚构的。
- scalar:对且可追溯(引用的格对上)、对但没引用(只有正文裸写的数对上,含叙述里自己算的比率)、错、量纲不同(差 10 的
整数次幂)、答案引用遮罩格的不能评分;维度配对(华东的那一格才算)、小整数又没维度的标弱匹配;
- table:按键配对、要求顺序时核对顺序;set:名单齐不齐、顺序;none:没给数 + 有查询返回 0 行;ask:反问才对;text:单列不计;
- 题目结论:路径不对直接不通过;期望没算出来不能评分 / 升版待处置跳过;只有文字要点的不能评分;
- dual:新旧两版各给一份结论;
- 允许的相对误差和双盲复算是同一个函数(engine/tolerance);
- 重评只读封存、不调模型。
"""
from __future__ import annotations
import pytest
import app.db.models # noqa: F401 - conftest 按已导入的模型建表:只跑这一个文件时也要有表
from app.engine import tolerance
from app.eval.score import Answer, score_case
def _case(*expectations: dict, gold_kind: str | None = "sql") -> dict:
return {"kind": "ask", "question": "虚构问题", "expectations": list(expectations),
"gold": {"kind": gold_kind, "datasource_id": "s", "sql": "SELECT 1"} if gold_kind else None}
def _gold(values: dict, *more: dict) -> dict:
sets = [{"run_id": "g", "snapshot": "x", "version": None, "values": values}]
sets += [{"run_id": "g2", "snapshot": "y", "version": m.pop("_version", 2), "values": m} for m in more]
return {"status": "ok", "reason": None, "code": None, "before": {"status": "ok", "sets": sets},
"after": {"status": "ok", "sets": sets}}
def _scalar(value, **extra) -> dict:
return {"kind": "scalar", "value": value, "column": "gmv", "row": 0, "dims": {}, **extra}
def _cell(value, **dims) -> dict:
return {"value": value, "kind": "cell", "artifact": "a1", "row": 0, "column": "gmv", "dims": dims}
def _bare(text: str) -> list[dict]:
from app.eval.score import _numbers
return _numbers(text)
EXP = {"id": "e1", "kind": "scalar", "column": "gmv", "required": True}
def test_scalar_outcomes():
case = _case(EXP)
gold = _gold({"e1": _scalar(330.0)})
traced = score_case(case, gold, Answer(run_id="r", values=[_cell(330.0)]), path="ran")
assert traced.verdict == "pass" and traced.traced and traced.scores[0]["outcome"] == "traced"
untraced = score_case(case, gold, Answer(run_id="r", text="销售额 330 元", bare=_bare("销售额 330 元")), path="ran")
assert untraced.verdict == "pass" and not untraced.traced and untraced.scores[0]["outcome"] == "untraced"
wrong = score_case(case, gold, Answer(run_id="r", values=[_cell(310.0)]), path="ran")
assert wrong.verdict == "fail" and wrong.scores[0]["outcome"] == "wrong" and wrong.scores[0]["got"] == 310.0
# 差 100 倍:量纲不同,单列,也不算对
scale = score_case(case, gold, Answer(run_id="r", values=[_cell(3.3)]), path="ran")
assert scale.verdict == "fail" and scale.scores[0]["outcome"] == "scale"
masked = score_case(case, gold, Answer(run_id="r", values=[{**_cell(330.0), "masked": True}]), path="ran")
assert masked.verdict == "ungradable" and "遮罩" in masked.scores[0]["reason"]
def test_bare_numbers_follow_their_written_precision_and_percent():
case = _case(EXP)
# 3.3 万写到千位:33,000 ± 500 都算对
gold = _gold({"e1": _scalar(33280.0)})
assert score_case(case, gold, Answer(run_id="r", bare=_bare("约 3.3 万")), path="ran").verdict == "pass"
# 比率写成百分数
ratio = _gold({"e1": _scalar(0.125)})
got = score_case(case, ratio, Answer(run_id="r", bare=_bare("占比 12.5%")), path="ran")
assert got.verdict == "pass" and got.scores[0]["outcome"] == "untraced", "叙述里写的比率只算对但没引用"
def test_dimensions_pair_and_small_integers_are_weak():
case = _case({**EXP, "where": {"region": "华东"}})
gold = _gold({"e1": _scalar(300.0, where={"region": "华东"}, dims={"region": "华东"})})
other = score_case(case, gold, Answer(run_id="r", values=[_cell(300.0, region="华南")]), path="ran")
assert other.verdict == "fail", "华南那一格碰巧也是 300,不算"
same = score_case(case, gold, Answer(run_id="r", values=[_cell(300.0, 地区="华东")]), path="ran")
assert same.verdict == "pass" and same.scores[0]["key"] == {"region": "华东"}
# 按条件筛过、只剩一个数(没有维度列)不算对不上
assert score_case(case, gold, Answer(run_id="r", values=[_cell(300.0)]), path="ran").verdict == "pass"
weak = score_case(_case(EXP), _gold({"e1": _scalar(3)}), Answer(run_id="r", values=[_cell(3)]), path="ran")
assert weak.verdict == "pass" and weak.scores[0]["weak"] is True
def test_tables_sets_none_and_ask():
table = {"id": "t", "kind": "table", "keys": ["region"], "ordered": True, "required": True}
gold = _gold({"t": {"kind": "table", "keys": ["region"], "columns": ["region", "gmv"], "ordered": True,
"rows": [{"region": "华东", "gmv": 300.0}, {"region": "华北", "gmv": 30.0}]}})
cells = [_cell(300.0, region="华东"), _cell(30.0, region="华北")]
ok = score_case(_case(table), gold, Answer(run_id="r", text="华东 300,华北 30", values=cells), path="ran")
assert ok.verdict == "pass" and ok.traced
flipped = score_case(_case(table), gold, Answer(run_id="r", text="华北 30,华东 300", values=cells), path="ran")
assert flipped.verdict == "fail" and "先后" in flipped.scores[0]["reason"]
names = {"id": "s", "kind": "set", "column": "region", "required": True}
gold = _gold({"s": {"kind": "set", "column": "region", "values": ["华东", "华北"], "ordered": False}})
assert score_case(_case(names), gold, Answer(run_id="r", text="有华东、华北两个"), path="ran").verdict == "pass"
assert score_case(_case(names), gold, Answer(run_id="r", text="只有华东"), path="ran").verdict == "fail"
none = {"id": "n", "kind": "none", "required": True}
gold = _gold({"n": {"kind": "none", "rows": 0}})
empty = [{"artifact": "q", "row_count": 0, "sql": "SELECT 1"}]
traced = score_case(_case(none), gold, Answer(run_id="r", text="华中区没有数据", queries=empty), path="ran")
assert traced.verdict == "pass" and traced.traced
with_number = score_case(_case(none), gold, Answer(run_id="r", text="华中区 12 元", bare=_bare("华中区 12 元"),
queries=empty), path="ran")
assert with_number.verdict == "fail"
ask = _case({"id": "a", "kind": "ask", "required": True}, gold_kind=None)
assert score_case(ask, {"status": "ok"}, Answer(run_id=None), path="asked").verdict == "pass"
assert score_case(ask, {"status": "ok"}, Answer(run_id="r"), path="ran").verdict == "fail"
def test_a_wrong_answer_shows_the_paired_cell():
"""判错时「答案里的数」给评分时配对上的那一格(按列名、指标、维度配),不是答案引用的第一个值;配不上照实写配不上。"""
case = _case({**EXP, "column": "refund", "label": "退款金额"})
gold = _gold({"e1": _scalar(1234.5, column="refund")})
def cell(value, column, **dims):
return {"value": value, "kind": "cell", "artifact": "a1", "row": 0, "column": column, "dims": dims}
question = cell("上周退款金额是多少", "问题")
both = score_case(case, gold, Answer(run_id="r", values=[question, cell(36, "单数"), cell(1200.0, "refund")]),
path="ran")
assert both.verdict == "fail" and both.scores[0]["got"] == 1200.0, both.scores
assert both.scores[0]["cell"]["column"] == "refund"
labelled = score_case(case, gold, Answer(run_id="r", values=[cell(36, "单数"), cell(1200.0, "退款金额")]), path="ran")
assert labelled.scores[0]["got"] == 1200.0, "期望的说明(label)和答案的列名一样也算配上"
lone = score_case(case, gold, Answer(run_id="r", values=[question, cell(36, "单数")]), path="ran")
[s] = lone.scores
assert s["outcome"] == "wrong" and "got" not in s, "单数不是退款金额:配不上就不给「答案里的数」"
assert "配不上" in s["reason"] and "单数 36" in s["reason"] and "上周退款" not in s["reason"], s["reason"]
# 按维度配:华东那一行只有一个数时就是它;同一行有几个数、列名又都配不上时不猜
by_dims = _case({**EXP, "column": "refund", "where": {"region": "华东"}})
gold = _gold({"e1": _scalar(1234.5, column="refund", where={"region": "华东"}, dims={"region": "华东"})})
one = score_case(by_dims, gold, Answer(run_id="r", values=[cell(1200.0, "total", region="华东")]), path="ran")
assert one.scores[0]["got"] == 1200.0, one.scores
two = score_case(by_dims, gold, Answer(run_id="r", values=[cell(36, "单数", region="华东"),
cell(1200.0, "total", region="华东")]), path="ran")
assert "got" not in two.scores[0] and "配不上" in two.scores[0]["reason"], two.scores
text_only = score_case(case, _gold({"e1": _scalar(1234.5, column="refund")}), Answer(run_id="r", values=[question]),
path="ran")
assert "got" not in text_only.scores[0] and text_only.scores[0]["reason"] == "答案里没有对得上的数"
def test_none_counts_only_numbers_as_giving_data():
"""期望「没有数据」:只有引用了数值格、或叙述里写了数,才算答案给了数。引用的是文字格(地区名)、空值格(NULL)的,
答案明说没有数据就按没有数据判对。"""
none = _case({"id": "n", "kind": "none", "required": True})
gold = _gold({"n": {"kind": "none", "rows": 0}})
empty = [{"artifact": "q", "row_count": 0, "sql": "SELECT 1"}]
text_cell = {"value": "华中", "kind": "cell", "artifact": "q", "row": 0, "column": "region", "dims": {}}
named = score_case(none, gold, Answer(run_id="r", text="华中区没有数据", values=[text_cell], queries=empty), path="ran")
assert named.verdict == "pass" and named.scores[0]["outcome"] == "traced", named.scores
null_cell = {"value": None, "kind": "cell", "artifact": "q", "row": 0, "column": "gmv", "dims": {}}
null = score_case(none, gold, Answer(run_id="r", text="华中区的销售额为空,没有数据", values=[null_cell],
queries=empty), path="ran")
assert null.verdict == "pass", null.scores
number = score_case(none, gold, Answer(run_id="r", text="华中区 12 元", values=[_cell(12.0)], queries=empty),
path="ran")
assert number.verdict == "fail" and number.scores[0]["got"] == 12.0, number.scores
said = score_case(none, gold, Answer(run_id="r", text="华中区 12 元", values=[text_cell], bare=_bare("12 元"),
queries=empty), path="ran")
assert said.verdict == "fail" and said.scores[0]["raw"] == "12", said.scores
def test_the_path_and_the_gold_decide_before_numbers():
case = _case(EXP)
gold = _gold({"e1": _scalar(330.0)})
replied = score_case(case, gold, Answer(run_id=None, text="330", bare=_bare("330")), path="reply")
assert replied.verdict == "fail" and "该查不查" in replied.reason, "直接回答的数是没查过的"
skipped = score_case(case, {"status": "skipped", "reason": "口径已升版到 v2,待处置"}, Answer(run_id=None), path=None)
assert skipped.verdict == "skipped" and "待处置" in skipped.reason
drifted = score_case(case, {"status": "drifted", "reason": "作答期间数据变了"}, Answer(run_id="r"), path="ran")
assert drifted.verdict == "ungradable", "数据变了不判,不算答错"
rejected = score_case(case, gold, Answer(run_id="r"), path="rejected")
assert rejected.verdict == "ungradable", "驳回是人的决定,不算失败"
text_only = _case({"id": "p", "kind": "text", "points": ["说明口径"], "required": False}, gold_kind=None)
judged = {"expectations": {"p": {"points": [{"point": 1, "verdict": "covered", "reason": ""}]}}}
scored = score_case(text_only, {"status": "ok"}, Answer(run_id="r", text="按支付日"), path="ran", judgement=judged)
assert scored.verdict == "ungradable" and scored.scores[0]["outcome"] == "covered", "文字要点单列、不计准确率"
unjudged = score_case(text_only, {"status": "ok"}, Answer(run_id="r"), path="ran")
assert unjudged.scores[0]["outcome"] == "unjudged"
def test_dual_reports_both_versions():
case = _case(EXP, gold_kind="caliber")
case["gold"] = {"kind": "caliber", "workflow_id": "w", "version": 1, "node_id": "card", "inputs": {}}
case["expectations"] = [{"id": "e1", "kind": "scalar", "metric": "gmv", "required": True}]
gold = _gold({"e1": _scalar(330.0)}, {"e1": _scalar(380.0), "_version": 2})
got = score_case(case, gold, Answer(run_id="r", values=[_cell(330.0)]), path="ran")
assert got.verdict == "pass" and got.by_version[0]["version"] == 2 and got.by_version[0]["verdict"] == "fail"
def test_the_tolerance_is_the_dual_check_function():
from app.engine.nodes import dual_check
assert dual_check._agree is tolerance.agree and dual_check._delta_pct is tolerance.delta_pct
assert tolerance.agree(100.0, 100.001, 1e-6) is False and tolerance.agree(100.0, 100.001, 1e-3)
assert tolerance.agree(0, 0.0000001, 1e-6) and tolerance.delta_pct(200, 180) == -10.0
assert tolerance.delta_pct(0, 1) is None and tolerance.agree("华东", "华东", 0)
# --------------------------------------------------------------------------
# 读封存的答案、逐题 E1、重评(走一次真的运行)
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_on():
from app.engine.runner import run_manager
await run_manager.setup()
yield
await run_manager.shutdown()
async def test_reading_the_sealed_answer_and_the_per_case_sql_match(engine_on, monkeypatch, tmp_path):
from app.eval import score
from tests import ledger_samples as ls
await ls.fresh_source(tmp_path / "shop.db")
run = await ls.explore(monkeypatch, ls.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]],同比增长 12%。")
answer = await score.read_answer(run.id)
assert answer.problem is None and answer.report == "write"
[value] = answer.values
assert value["value"] == 170.5 and value["kind"] == "cell" and value["column"] == "销售额"
assert [b["raw"] for b in answer.bare] == ["12%"] and answer.stats["numbers_cited"] == 1
assert len(answer.queries) == 1 and answer.cited == [answer.queries[0]["artifact"]]
from app.db.base import SessionLocal
from app.db.models import DataSource
from sqlalchemy import select
async with SessionLocal() as session:
sid = (await session.execute(select(DataSource.id).where(DataSource.name == ls.CURRENT["source"]))).scalar()
case = {"gold": {"kind": "sql", "datasource_id": sid, "sql": ls.WITH_REFUNDS}, "expectations": []}
gold = {"status": "ok", "before": {"rendered": {"sql": ls.WITH_REFUNDS}, "sets": []}}
match = await score.sql_match(case, gold, answer)
assert match["status"] == "related" and match["cited"] and "filter" in match["aspects"], match
gold["before"]["rendered"]["sql"] = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'")
assert (await score.sql_match(case, gold, answer))["status"] == "proven"
async def test_the_per_case_sql_match_compares_the_query_behind_the_answer(engine_on, monkeypatch, tmp_path):
"""逐题 E1 比的是产出答案的那条查询:答案引用了就比被引用的;没引用、这次运行又查了几条时不硬比第一条(往往是探索
查询),照实写没法对上是哪条;没引用、只查了一条时它就是唯一可能的那条,按它比、写明依据。"""
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.engine.runner import run_manager
from app.eval import score
from app.ledger import match as ledger_match
from sqlalchemy import select
from tests import ledger_samples as ls
await ls.fresh_source(tmp_path / "shop.db")
async with SessionLocal() as session:
sid = (await session.execute(select(DataSource.id).where(DataSource.name == ls.CURRENT["source"]))).scalar()
case = {"gold": {"kind": "sql", "datasource_id": sid, "sql": "x"}, "expectations": []}
gold = {"status": "ok", "before": {"rendered": {"sql": (
"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01' "
"AND paid_at < '2026-09-08'")}, "sets": []}}
# 先探了一下有哪些地区,再查销售额;答案没引用查询(数写在叙述里)
graph = ls.chain(ls.node("start", "input"),
ls.node("peek", "tool", tool=ls.tool(), args={"sql": "SELECT DISTINCT region FROM orders"}),
ls.node("q", "tool", tool=ls.tool(), args={"sql": ls.WITH_REFUNDS}),
ls.node("write", "report", instructions="写答案"),
ls.node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]))
ls.script(monkeypatch, "本周销售额约 200 元。")
started = await run_manager.start(graph=graph, input_payload={}, run_class="exploratory", origin_kind="canvas")
await ls.wait(started.id)
await ledger_match.drain()
answer = await score.read_answer(started.id)
assert answer.cited == [] and len(answer.queries) == 2, answer.queries
match = await score.sql_match(case, gold, answer)
assert match["status"] == "skipped" and match["cited"] is False and "没有引用查询" in match["reason"], match
assert match.get("query") is None and match.get("aspects") in (None, []), "没法对上是哪条,就不给差在哪"
# 只查了一条、答案没引用:唯一的那条就是产出答案的,按它比,依据写明
run = await ls.explore(monkeypatch, ls.EQUIVALENT, text="本周销售额 170.5 元。")
only = await score.read_answer(run.id)
assert only.cited == [] and len(only.queries) == 1
match = await score.sql_match(case, gold, only)
assert match["status"] == "proven" and match["basis"] == "only_query" and not match["cited"], match
async def test_the_per_case_sql_match_projects_to_the_cited_columns(engine_on, monkeypatch, tmp_path):
"""逐题 E1 和账本同一个口径:答案的 SQL 多查了一列订单数、报告只引用销售额那一列时,按引用的列投影后和期望 SQL
证明等价;多出来的那列不算差在哪个方面。"""
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.eval import score
from sqlalchemy import select
from tests import ledger_samples as ls
await ls.fresh_source(tmp_path / "shop.db")
async with SessionLocal() as session:
sid = (await session.execute(select(DataSource.id).where(DataSource.name == ls.CURRENT["source"]))).scalar()
case = {"gold": {"kind": "sql", "datasource_id": sid, "sql": "x"}, "expectations": []}
gold = {"status": "ok", "before": {"rendered": {"sql": (
"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01' "
"AND paid_at < '2026-09-08'")}, "sets": []}}
wider = ("select count(*) as 单数, sum(o.amount) as 销售额 from orders o where o.paid_at >= '2026-09-01' "
"and o.status = 'paid' and o.paid_at < '2026-09-08'")
run = await ls.explore(monkeypatch, wider, text="本周销售额 [[v:Q1.r0.销售额]] 元。")
answer = await score.read_answer(run.id)
assert answer.cited, answer
match = await score.sql_match(case, gold, answer)
assert match["status"] == "proven" and match["cited"] is True, match
assert "projection" not in (match.get("aspects") or []), match
async def test_rescoring_reads_the_seal_and_never_calls_a_model(engine_on, monkeypatch, tmp_path):
from app.db.base import SessionLocal
from app.db.models_eval import EvalResult, EvalRun
from app.eval import rescore, score
from app.providers import mock_model
from tests import eval_samples as es
from tests import ledger_samples as ls
await ls.fresh_source(tmp_path / "shop.db")
run = await ls.explore(monkeypatch, ls.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
suite_id = await es.make_suite("none", cases=[{"question": "本周销售额?", "expectations": [
{"kind": "scalar", "column": "gmv"}], "gold": {"kind": "sql", "datasource_id": "none", "sql": "SELECT 1"}}])
def boom(*_a, **_k):
raise AssertionError("重评不该调模型")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", boom)
async with SessionLocal() as session:
from app.db.models_eval import EvalCase
from sqlalchemy import select
case = (await session.execute(select(EvalCase).where(EvalCase.suite_id == suite_id))).scalar_one()
er = EvalRun(suite_id=suite_id, status="succeeded")
session.add(er)
await session.flush()
session.add(EvalResult(eval_run_id=er.id, case_id=case.id, case_hash=case.case_hash, status="ok", path="ran",
run_id=run.id, verdict="fail", scores=[],
gold=_gold({"e1": _scalar(170.5)})))
await session.commit()
er_id = er.id
assert await rescore.rescore(er_id) == 1
async with SessionLocal() as session:
row = (await session.execute(select(EvalResult).where(EvalResult.eval_run_id == er_id))).scalar_one()
assert row.verdict == "pass" and row.scores[0]["outcome"] == "traced"
rescored = await session.get(EvalRun, er_id)
assert rescored.summary["accuracy"]["passed"] == 1 and rescored.summary["rescored_at"]
assert rescored.fingerprint["scorer_version"] == score.SCORER_VERSION
await es.drop_suites()