"""评测的期望答案(app/eval/gold.py;细案 01 做法 8、14,评测-D4)。 - SQL 探针:{{ schedule.* }} 按截至时间渲染;以来源 eval、只读 + 遮罩的探索运行跑(不进使用统计);期望值从封存的查询 快照里取格、列、整张表;没有数据的题要 0 行。只读守卫拒写语句;用到遮罩列的拒收;取不到值、运行失败、超时照实说。 - 口径探针:拷出钉住版本的取数节点 + caliber_from 口径卡,算出的值和原工作流一样;不可复用的卡拒收并给原因。 - 口径升版:有新版本没处置就跳过(「口径已升版到 vN,待处置」),三种处置各自生效;钉住的版本退役同样待处置。 - 作答前后各算一次:一样才判,数据变了不判。 库、表、数字都是虚构的(tests/fixtures/dual_check_shop.py:9/1–9/7 按支付日、不含退款 330)。 """ from __future__ import annotations import sqlite3 from datetime import datetime, timezone from zoneinfo import ZoneInfo import pytest from sqlalchemy import select, update from app.core import artifact_store from app.db.base import SessionLocal, utcnow from app.db.models import Artifact, DataSource, Run, Workflow, WorkflowVersion from app.engine.origin import counted_runs from app.engine.runner import run_manager from app.eval import cases, gold from app.eval.gold import Context from tests.fixtures import dual_check_shop as shop from tests.fixtures.sources import drop_source #: 2026-09-08(周二)早上:上周是 8/31–9/6 AS_OF = datetime(2026, 9, 8, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai")) LAST_WEEK = "BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'" PAID_GMV = f"SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {LAST_WEEK}" @pytest.fixture(autouse=True) async def engine_up(): await run_manager.setup() yield await run_manager.shutdown() @pytest.fixture async def source(tmp_path): name, sid = await shop.add_source(shop.build(tmp_path / "shop.db")) yield {"name": name, "id": sid, "path": tmp_path / "shop.db"} await drop_source(sid) def sql_case(source_id: str, sql: str, *expectations: dict, seq: int = 1) -> dict: fields = cases.normalize_case({"question": "上周的销售额是多少?", "gold": {"kind": "sql", "datasource_id": source_id, "sql": sql}, "expectations": list(expectations)}) return {**fields, "id": None, "seq": seq} def ctx(**kw) -> Context: return Context(as_of=AS_OF, tz="Asia/Shanghai", actor="评测测试", suite_id="s1", suite_name="虚构题集", **kw) async def run_of(run_id: str) -> Run: async with SessionLocal() as session: return await session.get(Run, run_id) # -------------------------------------------------------------------------- # SQL 探针 # -------------------------------------------------------------------------- async def test_the_sql_probe_renders_variables_and_reads_the_sealed_snapshot(source): case = sql_case(source["id"], PAID_GMV, {"kind": "scalar", "column": "gmv"}) outcome = await gold.compute_gold(case, ctx()) assert outcome.ok, outcome.reason [found] = outcome.sets assert found.values == {"e1": {"kind": "scalar", "value": 330.0, "column": "gmv", "row": 0, "dims": {}}} assert "'2026-08-31' AND '2026-09-06'" in outcome.rendered["sql"] # 期望点得开:快照是那次运行封存的查询快照 snapshot = artifact_store.load(found.snapshot) assert snapshot["rows"] == [[330.0]] and "2026-08-31" in snapshot["sql"] row = await run_of(found.run_id) assert row.status == "succeeded" and row.manifest_seq is not None assert row.origin_kind == "eval" and row.origin["role"] == "gold" and row.origin["suite_id"] == "s1" assert row.workflow_name == "评测:虚构题集·第 1 题·算期望" # 只读、遮罩:同使用者的问数据运行 assert row.options["read_only_data"] is True and row.options["mask_results"] is True # 评测运行是系统派生的,不进使用统计 async with SessionLocal() as session: counted = (await session.execute(select(Run.id).where(Run.id == row.id, counted_runs()))).all() assert counted == [] async def test_cells_columns_tables_and_no_data(source): by_region = (f"SELECT region, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {LAST_WEEK} " "GROUP BY region ORDER BY gmv DESC") case = sql_case(source["id"], by_region, {"kind": "scalar", "column": "gmv", "where": {"region": "华东"}}, {"kind": "table", "keys": ["region"], "ordered": True}, {"kind": "set", "column": "region", "top_n": 1}) outcome = await gold.compute_gold(case, ctx()) assert outcome.ok, outcome.reason values = outcome.sets[0].values assert values["e1"]["value"] == 300.0 and values["e1"]["dims"] == {"region": "华东"} assert values["e2"]["rows"] == [{"region": "华东", "gmv": 300.0}, {"region": "华北", "gmv": 30.0}] assert values["e3"]["values"] == ["华东"] none = sql_case(source["id"], f"SELECT region, SUM(amount) AS gmv FROM orders WHERE region = '华中' " f"AND paid_date {LAST_WEEK} GROUP BY region", {"kind": "none"}) outcome = await gold.compute_gold(none, ctx()) assert outcome.ok and outcome.sets[0].values == {"e1": {"kind": "none", "rows": 0}} @pytest.mark.parametrize("sql, expectation, words", [ (PAID_GMV, {"kind": "scalar", "column": "金额"}, "没有列「金额」"), (PAID_GMV, {"kind": "scalar", "column": "gmv", "row": 3}, "只返回了 1 行"), (f"SELECT SUM(amount) AS gmv FROM orders WHERE region = '华中'", {"kind": "scalar", "column": "gmv"}, "是空值"), (f"SELECT region FROM orders WHERE region = '华中'", {"kind": "scalar", "column": "region"}, "没有返回行"), (PAID_GMV, {"kind": "none"}, "返回了 1 行"), ]) async def test_values_that_cannot_be_taken_are_said_plainly(source, sql, expectation, words): outcome = await gold.compute_gold(sql_case(source["id"], sql, expectation), ctx()) assert outcome.status == "no_value" and outcome.code == gold.GOLD_NO_VALUE assert words in outcome.reason and outcome.run_ids async def test_the_read_only_guard_refuses_writes_before_anything_runs(source): async with SessionLocal() as session: await session.execute(update(DataSource).where(DataSource.id == source["id"]).values(readonly=False)) await session.commit() case = sql_case(source["id"], "DELETE FROM orders WHERE id = 1", {"kind": "none"}) outcome = await gold.compute_gold(case, ctx()) assert outcome.status == "refused" and outcome.code == cases.GOLD_GUARD and outcome.run_ids == [] with sqlite3.connect(source["path"]) as db: assert db.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == len(shop.ROWS) @pytest.mark.parametrize("sql, expectation, refused", [ ("SELECT status, COUNT(*) AS n FROM orders GROUP BY status", {"kind": "set", "column": "status"}, True), ("SELECT status AS 状态 FROM orders", {"kind": "set", "column": "状态"}, True), # 改了名字也认得出 ("SELECT * FROM orders", {"kind": "table", "keys": ["id"]}, True), # 结果里没有取自遮罩列的格:条件里用到遮罩列不算 ("SELECT COUNT(*) AS n FROM orders WHERE status = 'paid'", {"kind": "scalar", "column": "n"}, False), ]) async def test_gold_that_uses_a_masked_column_is_refused(tmp_path, sql, expectation, refused): name, sid = await shop.add_source(shop.build(tmp_path / "masked.db"), options={"mask_columns": ["status"]}) try: outcome = await gold.compute_gold(sql_case(sid, sql, expectation), ctx()) if refused: assert outcome.status == "refused" and outcome.code == gold.GOLD_MASKED, outcome.reason assert "遮罩" in outcome.reason else: assert outcome.ok and outcome.sets[0].values["e1"]["value"] == 4 finally: await drop_source(sid) async def test_failures_and_timeouts_are_said_plainly(tmp_path, source): outcome = await gold.compute_gold(sql_case(source["id"], "SELECT nope FROM orders", {"kind": "scalar", "column": "nope"}), ctx()) assert outcome.status == "failed" and "nope" in outcome.reason and outcome.run_ids slow = ("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 500000000) " "SELECT SUM(x) AS s FROM c") name, sid = await shop.add_source(shop.build(tmp_path / "slow.db"), options={"query_timeout_s": 2}) try: # 等不到:停止那次运行,照实说超时 outcome = await gold.compute_gold(sql_case(sid, slow, {"kind": "scalar", "column": "s"}), ctx(timeout_s=0.5)) assert outcome.status == "timeout" and outcome.code == gold.GOLD_TIMEOUT and "0.5 秒" in outcome.reason [run_id] = outcome.run_ids assert (await run_of(run_id)).status == "cancelled" # 数据库按数据源的查询时限停下语句:运行失败,原因照写 outcome = await gold.compute_gold(sql_case(sid, slow, {"kind": "scalar", "column": "s"}), ctx(timeout_s=60)) assert outcome.status == "failed" and "超过" in outcome.reason finally: await drop_source(sid) async with SessionLocal() as session: await session.execute(update(DataSource).where(DataSource.id == source["id"]).values(enabled=False)) await session.commit() outcome = await gold.compute_gold(sql_case(source["id"], PAID_GMV, {"kind": "scalar", "column": "gmv"}), ctx()) assert outcome.status == "failed" and "已停用" in outcome.reason and outcome.run_ids == [] async def test_cases_without_gold_need_no_run(): fields = cases.normalize_case({"question": "最近卖得怎么样?", "expectations": [{"kind": "ask"}]}) outcome = await gold.compute_gold({**fields, "id": None, "seq": 2}, ctx()) assert outcome.ok and outcome.sets == [] and outcome.run_ids == [] # -------------------------------------------------------------------------- # 作答前后各算一次 # -------------------------------------------------------------------------- async def test_bracket_judges_only_when_the_data_did_not_move(source): case = sql_case(source["id"], PAID_GMV, {"kind": "scalar", "column": "gmv"}) async def answer(before): assert before.ok return "作答" result, settled = await gold.bracket(case, ctx(), answer) assert result == "作答" and settled.ok and settled.expected[0].values["e1"]["value"] == 330.0 assert settled.after is not None and (await run_of(settled.after.run_ids[0])).origin["role"] == "gold_after" async def answer_while_data_moves(before): with sqlite3.connect(source["path"]) as db: db.execute("INSERT INTO orders VALUES (9, '华东', 104, 70.0, 'paid', '2026-09-02', '2026-09-02', " "'online', 0)") return "作答" _, settled = await gold.bracket(case, ctx(), answer_while_data_moves) assert settled.status == "drifted" and "数据变了" in settled.reason and settled.expected == [] missing = sql_case(source["id"], PAID_GMV, {"kind": "scalar", "column": "金额"}) called = [] async def never(before): called.append(before) result, settled = await gold.bracket(missing, ctx(), never) assert result is None and called == [] and settled.status == "no_value" # -------------------------------------------------------------------------- # 口径探针 # -------------------------------------------------------------------------- def node(nid, ntype, label=None, **config): return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} def chain(*nodes): return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} CARD_METRICS = [ {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", "expression": "cell(nodes.fetch, 0, 'gmv')"}, {"id": "avg", "name": "客单价", "unit": "元", "decimals": 2, "format": "thousands", "expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'n')"}, ] def weekly(source_name: str, *, refunds: bool = False, metrics=CARD_METRICS, fetch_sql: str | None = None) -> dict: status = "" if refunds else "status <> 'refunded' AND " sql = fetch_sql or (f"SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE {status}" "paid_date BETWEEN '{{ input.start }}' AND '{{ input.end }}'") return chain(node("start", "input", fields=[{"name": "start", "required": True}, {"name": "end", "required": True}]), node("fetch", "tool", "取数", tool=f"db_query__{source_name}", args={"sql": sql}), node("card", "metrics", "周销售口径卡", caliber="周销售", caliber_version="v1", metrics=metrics), node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}])) async def publish(*graphs, name="周销售") -> str: async with SessionLocal() as session: wf = Workflow(name=f"{name}_{len(graphs)}_{id(graphs)}", graph=graphs[-1], status="published", published_version=len(graphs), version=len(graphs)) session.add(wf) await session.flush() for v, graph in enumerate(graphs, start=1): session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=graph, level="published")) await session.commit() return wf.id async def add_version(wf_id: str, graph: dict) -> int: async with SessionLocal() as session: wf = await session.get(Workflow, wf_id) wf.published_version = wf.version = (wf.published_version or 0) + 1 session.add(WorkflowVersion(workflow_id=wf_id, version=wf.published_version, graph=graph, level="published")) await session.commit() return wf.published_version def caliber_case(wf_id: str, version: int = 1, *, upgrade=None, metrics=("gmv", "avg")) -> dict: fields = cases.normalize_case({ "question": "上周的销售额和客单价?", "gold": {"kind": "caliber", "workflow_id": wf_id, "version": version, "node_id": "card", "inputs": {"start": "{{ schedule.last_week_start }}", "end": "{{ schedule.last_week_end }}"}}, "expectations": [{"kind": "scalar", "metric": m} for m in metrics]}) if upgrade: fields["gold"]["upgrade"] = upgrade return {**fields, "id": None, "seq": 3} async def card_of(run_id: str) -> dict: async with SessionLocal() as session: aid = (await session.execute(select(Artifact.id).where( Artifact.run_id == run_id, Artifact.kind == "metric_set", Artifact.node_id == "card"))).scalar_one() return artifact_store.load(aid) async def test_the_caliber_probe_computes_what_the_original_workflow_computes(source): wf_id = await publish(weekly(source["name"])) outcome = await gold.compute_gold(caliber_case(wf_id), ctx()) assert outcome.ok, outcome.reason [found] = outcome.sets assert found.version == 1 and outcome.rendered["inputs"] == {"start": "2026-08-31", "end": "2026-09-06"} # 原工作流按同样的输入跑一次(探索运行):口径卡算出的每个数都一样,取整、单位按口径卡 original = await run_manager.start(graph=weekly(source["name"]), input_payload=outcome.rendered["inputs"]) await run_manager.wait_idle(original.id, timeout=30) card = await card_of(original.id) by_metric = {v["metric"]: v for v in found.values.values()} assert {m["id"]: m["value"] for m in card["metrics"]} == {k: v["value"] for k, v in by_metric.items()} assert by_metric["gmv"]["value"] == 330.0 and by_metric["gmv"]["rendered"] == "330.0元" assert by_metric["avg"]["value"] == 110.0 and by_metric["avg"]["caliber"] == "周销售" row = await run_of(found.run_id) assert row.origin_kind == "eval" and row.origin["derived"]["kind"] == "caliber_probe" assert row.origin["derived"]["from"][0]["workflow_version"] == 1 # 探针图里口径卡固定到那一版,取数节点照抄 nodes = {n["id"]: n for n in row.graph["nodes"]} assert nodes["card"]["data"]["config"]["caliber_from"] == {"workflow_id": wf_id, "workflow_version": 1, "node_id": "card"} assert nodes["fetch"]["data"]["config"] == weekly(source["name"])["nodes"][1]["data"]["config"] @pytest.mark.parametrize("graph_of, words", [ (lambda s: chain(node("start", "input"), node("fetch", "transform", mode="expression", expression="{'gmv': 1}", assign_to="kpi"), node("card", "metrics", caliber="周销售", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]), node("out", "output")), "输入不全是查库结果"), (lambda s: weekly(s, fetch_sql="SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE region = " "'{{ vars.region }}'"), "vars.region"), ]) async def test_cards_that_cannot_be_reused_are_refused_with_the_reason(source, graph_of, words): wf_id = await publish(graph_of(source["name"])) outcome = await gold.compute_gold(caliber_case(wf_id, metrics=("gmv",)), ctx()) assert outcome.status == "refused" and outcome.code == cases.CALIBER_NOT_REUSABLE and words in outcome.reason assert outcome.run_ids == [] async def test_a_card_that_copies_another_card_is_not_a_source(source): upstream = await publish(weekly(source["name"])) chained = chain(node("start", "input"), node("card", "metrics", caliber_from={ "workflow_id": upstream, "workflow_version": 1, "node_id": "card"}), node("out", "output")) wf_id = await publish(chained, name="转抄") outcome = await gold.compute_gold(caliber_case(wf_id, metrics=("gmv",)), ctx()) assert outcome.status == "refused" and "转抄" in outcome.reason # -------------------------------------------------------------------------- # 口径升版的处置 # -------------------------------------------------------------------------- async def test_an_upgraded_caliber_is_skipped_until_disposed(source): wf_id = await publish(weekly(source["name"])) case = caliber_case(wf_id) assert (await gold.compute_gold(case, ctx())).ok await add_version(wf_id, weekly(source["name"], refunds=True)) # v2:改成含退款 outcome = await gold.compute_gold(case, ctx()) assert outcome.status == "skipped" and outcome.code == gold.UPGRADE_PENDING assert outcome.reason == "口径已升版到 v2,待处置" and outcome.run_ids == [] async with SessionLocal() as session: with pytest.raises(gold.DisposeRefused): await gold.plan_dispose(session, case, "keep") # 没有缺省、没有别的选项 recomputed, latest = await gold.plan_dispose(session, case, "recompute") dual, _ = await gold.plan_dispose(session, case, "dual") fresh, _ = await gold.plan_dispose(session, case, "incomparable") assert latest == 2 # recompute:改钉新版本,指纹跟着变(历史在这里断开) assert recomputed["version"] == 2 and recomputed["upgrade"]["policy"] == "recompute" rebased = {**case, "gold": recomputed} assert cases.case_hash(rebased) != cases.case_hash(case) outcome = await gold.compute_gold(rebased, ctx()) assert outcome.ok and [s.version for s in outcome.sets] == [2] assert {v["metric"]: v["value"] for v in outcome.sets[0].values.values()}["gmv"] == 380.0 # dual:新旧两版各算一份,旧的那一份照旧可比(指纹不变) both = {**case, "gold": dual} assert cases.case_hash(both) == cases.case_hash(case) outcome = await gold.compute_gold(both, ctx()) assert outcome.ok and [s.version for s in outcome.sets] == [1, 2] assert [{v["metric"]: v["value"] for v in s.values.values()}["gmv"] for s in outcome.sets] == [330.0, 380.0] # incomparable:按新版本另建新题(这里只给出新题的期望来源,归档旧题在接口里) assert fresh["version"] == 2 and "upgrade" not in fresh # 再来一版:dual 只处置到 v2,v3 又待处置 await add_version(wf_id, weekly(source["name"])) outcome = await gold.compute_gold(both, ctx()) assert outcome.status == "skipped" and outcome.reason == "口径已升版到 v3,待处置" async def test_a_retired_pinned_version_waits_for_disposal(source): wf_id = await publish(weekly(source["name"]), weekly(source["name"], refunds=True)) case = caliber_case(wf_id, upgrade={"policy": "dual", "from_version": 1, "to_version": 2}) assert (await gold.compute_gold(case, ctx())).ok async with SessionLocal() as session: await session.execute(update(WorkflowVersion).where(WorkflowVersion.workflow_id == wf_id, WorkflowVersion.version == 1).values(retired_at=utcnow())) await session.commit() outcome = await gold.compute_gold(case, ctx()) assert outcome.status == "skipped" and "v1 已退役,待处置" in outcome.reason async with SessionLocal() as session: with pytest.raises(gold.DisposeRefused, match="已退役"): await gold.plan_dispose(session, case, "dual") moved, _ = await gold.plan_dispose(session, case, "recompute") assert (await gold.compute_gold({**case, "gold": moved}, ctx())).ok def test_settle_reports_the_first_reason(): now = datetime.now(timezone.utc) bad = gold.GoldOutcome(status="failed", as_of=now.isoformat(), reason="SQL 错了", code=gold.GOLD_FAILED) good = gold.GoldOutcome(status="ok", as_of=now.isoformat(), sets=[gold.GoldSet("r1", "a1", {"e1": {"value": 1}})]) assert gold.settle(bad, good).status == "failed" assert gold.settle(good, bad).reason == "作答后复算期望:SQL 错了" assert gold.settle(good, None).status == "failed" again = gold.GoldOutcome.from_dict(good.to_dict()) assert gold.settle(good, again).ok assert gold.GoldOutcome.from_dict(good.to_dict()).to_dict() == good.to_dict()