From f5204277aa70e1d062e628c3372754ff25194f31 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 17 Oct 2026 11:31:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E6=B5=8B=E5=88=A4=E9=94=99=E6=97=B6?= =?UTF-8?q?=E3=80=8C=E7=AD=94=E6=A1=88=E9=87=8C=E7=9A=84=E6=95=B0=E3=80=8D?= =?UTF-8?q?=E7=BB=99=E9=85=8D=E5=AF=B9=E4=B8=8A=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E6=A0=BC=EF=BC=8C=E9=85=8D=E4=B8=8D=E4=B8=8A=E7=85=A7=E5=AE=9E?= =?UTF-8?q?=E5=86=99=E9=85=8D=E4=B8=8D=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实模型验收里,退款金额那道题判错后「答案里的数」显示成了答案里的订单数 36,还出现过显示一格问题原文的: _score_scalar 判错时取答案引用的第一个值,不看是哪一列,也不看是不是数。现在只在数值格里找和这一项配对上的那一格—— 列名(或口径卡的指标)是期望取的那一列、那个指标,或者和期望的说明同名;名字配不上时,期望写了维度、维度对得上的数 恰好只有一个也算配上。配不上就不给「答案里的数」,原因里写「答案引用的数按列名、维度都配不上这一项(引用了 单数 36)」; 答案里只有文字格、没有数的写「答案里没有对得上的数」。 算第 2 版评分规则的一部分(SCORER_VERSION 已在上一个提交升到 2)。 测试:test_eval_score.py::test_a_wrong_answer_shows_the_paired_cell(按列名、按说明、按维度配上;只有订单数、同一行 几个数都配不上、只有文字格;去掉修复回跑失败)。 --- backend/app/eval/score.py | 50 +++++++++++++++++++++++++++++--- backend/tests/test_eval_score.py | 32 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/backend/app/eval/score.py b/backend/app/eval/score.py index 76e78307..dd0a1f65 100644 --- a/backend/app/eval/score.py +++ b/backend/app/eval/score.py @@ -17,7 +17,8 @@ wrong 错、scale 量纲不同(差 10 的整数次幂,比如元和万元、 - scalar:数值按「允许的相对误差」比(engine/tolerance.agree,和双盲复算同一个函数);期望没写容差时,引用的原值按 DEFAULT_TOLERANCE,裸写的数按它写出的精度(3.5 万 ±500)。维度配对:期望写了 where(或期望那一行本身有维度)时,答案 那一格所在行的非数值列要有这个取值;答案那一格没有维度(按条件筛过、只剩一个数)不算对不上。期望值是 0–10 的小整数 - 又没有维度可配时标「弱匹配」(巧合的数:3 家门店正好等于期望的 3)。 + 又没有维度可配时标「弱匹配」(巧合的数:3 家门店正好等于期望的 3)。答错时「答案里的数」给和这一项配对上的那一格(列名、 + 指标、说明同名,或者维度对得上的只有一个数),配不上就不给、原因里照实写配不上,列出答案引用了哪几个数。 - table:逐行按 keys 的取值配对,每个要比的值同 scalar;ordered 时按答案里各行键值出现的先后核对顺序。 - set:名单里每一项都要在答案里出现(ordered 时按顺序);每一项都出现在答案查过的结果里算可追溯。 - none:期望答「没有数据」:答案里没有数,而且查过的查询里有返回 0 行的(可追溯);没有数、但也没有一条查询返回 0 行的 @@ -67,6 +68,8 @@ DEFAULT_TOLERANCE = 1e-6 SCALE_FACTORS = (10.0, 100.0, 1000.0, 1e4, 1e5, 1e6, 1e8) #: 弱匹配:期望是这么小的整数、又没有维度可配 WEAK_MAX = 10 +#: 判错、又配不上时原因里列出答案引用的前几个数 +PAIR_SHOWN = 3 #: 路径不对时给人看的原因 WRONG_PATH = { @@ -319,9 +322,48 @@ def _score_scalar(exp: Mapping[str, Any], gold: Mapping[str, Any], answer: Answe for token in answer.bare: if _scaled(expected, float(token["value"])): return {**out, "outcome": "scale", "got": token["value"], "raw": token["raw"]} - got = next((v.get("value") for v in clean), None) - return {**out, "outcome": "wrong", **({"got": got} if got is not None else {}), - "reason": "答案里没有对得上的数" if not answer.values and not answer.bare else "答案里的数和期望对不上"} + numeric = [v for v in clean if is_finite_number(v.get("value"))] + if not numeric and not answer.bare: + return {**out, "outcome": "wrong", "reason": "答案里没有对得上的数"} + paired = _paired(numeric, exp, gold, key) + if paired is not None: + return {**out, "outcome": "wrong", "got": paired.get("value"), "cell": _cell_ref(paired), + "reason": "答案里的数和期望对不上"} + if numeric: + shown = "、".join(f"{_value_name(v)} {_plain(v.get('value'))}" for v in numeric[:PAIR_SHOWN]) + more = f" 等 {len(numeric)} 个" if len(numeric) > PAIR_SHOWN else "" + return {**out, "outcome": "wrong", + "reason": f"答案里的数和期望对不上:答案引用的数按列名、维度都配不上这一项(引用了 {shown}{more})"} + return {**out, "outcome": "wrong", "reason": "答案里的数和期望对不上"} + + +def _norm(name: Any) -> str: + return str(name).strip().strip('"`[]').lower() if name is not None else "" + + +def _paired(numeric: Sequence[Mapping[str, Any]], exp: Mapping[str, Any], gold: Mapping[str, Any], + key: Mapping[str, Any]) -> Mapping[str, Any] | None: + """答错时答案里和这一项配对上的那一格(「答案里的数」给它):按名字配——列名(或口径卡的指标)是期望取的那一列、 + 那个指标,或者和期望的说明(label)同名;名字配不上时按维度配——期望写了维度、答案里维度对得上的数恰好只有一个。 + 都配不上为 None:以前取答案引用的第一个值,退款金额那一项显示成了答案里的订单数,甚至显示成一格文字。""" + names = {_norm(n) for n in (exp.get("column"), gold.get("column"), exp.get("metric"), gold.get("metric"), + gold.get("name"), exp.get("label")) if n not in (None, "")} + named = [v for v in numeric if _norm(v.get("column")) in names or _norm(v.get("metric")) in names] + if named: + return named[0] + if key: + on_dims = [v for v in numeric if v.get("dims") and _dims_ok(key, v["dims"])] + if len(on_dims) == 1: + return on_dims[0] + return None + + +def _value_name(value: Mapping[str, Any]) -> str: + return str(value.get("column") or value.get("metric") or value.get("alias") or "(没有列名)") + + +def _plain(value: Any) -> str: + return str(int(value)) if is_finite_number(value) and float(value).is_integer() else str(value) def _cell_ref(value: Mapping[str, Any]) -> dict[str, Any]: diff --git a/backend/tests/test_eval_score.py b/backend/tests/test_eval_score.py index a4946a2d..a97395f2 100644 --- a/backend/tests/test_eval_score.py +++ b/backend/tests/test_eval_score.py @@ -116,6 +116,38 @@ def test_tables_sets_none_and_ask(): 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)的, 答案明说没有数据就按没有数据判对。"""