Files
AgentLab/backend/tests/test_dual_check_node.py

1619 lines
99 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""双盲复算节点(engine/nodes/dual_check.py):真跑一遍图。虚构的 SQLite 订单库埋了退款行、下单日和支付日两个陷阱
(tests/fixtures/dual_check_shop.py),盲算模型是 MockChatModel 剧本。
- 一致:不挂起,产出 agreed,日志 dual_check_agreed;盲算的查询发 tool.end(role: verify),台账只有一条 dual_check,
用量计入运行;盲算 agent 的任务里没有原 SQL,也没有原流程结果的列名
- 一个都没核成是 unchecked、只核成一部分是 partial,日志 dual_check_unchecked 说清哪几个、为什么
- 不一致:挂起,审批 payload 和对照工件按约定,payload 另带实际生效的 on_reject、摘要里每个指标带选 a、选 b 各会往
数据目录里写什么的预告;a / b / neither 三种选择的产出和缺口,没选的照实记,驳回、on_reject 为 fail 时失败
- 差异说明只比这一格所在的那一列(一条查询算好几列、分组后取一行的,收窄了再比,HAVING 一并去掉);分组的列设了
遮罩时(改了名再分组的、在子查询或 WITH 里改了名的也算)不收窄、照整条比,遮罩列的取值不进审批卡、对照工件、产出、
事件和盲算的任务
- 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖,指标自己的
筛选范围(取值在运行输入里、用到分组的列)不写,两条 SQL 规范化后一致的不写;人工驳回过的条件不会借着和别的
指标并成一项写回去,真写的和审批卡上的预告一致;时间列在表结构里声明成文本的不记成业务日期,预告照实说(上传表格
建的数据源说清日期是按文字保存的)
- 全局关闭直通、节点覆盖;设置按发起时的快照;重放时盲算不再执行、事件不重复;报告的写作目录不含盲算的查询
- 触顶、分组结果的维度(只给取值、取自遮罩列的不给)、带着取舍的列名不交给盲算、两个指标用到同一格只盲算一次、遮罩列
上的盲算结果不采用;按位置取自另一份快照遮罩列的输入(cell() 取 A、vars.res.rows[0][0] 取 B)按无法复算记、原因点名
是哪个输入,不盲算;认不出取自哪一列的按位置取的格不写进计算式;按位置取的格不改变容器输入的判法
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
from app.core import artifact_store
from app.data import catalog
from app.db.base import SessionLocal
from app.db.models import Approval, DataSource, Run, RunEvent, Setting
from app.engine import spend
from app.engine.launch import launch_run
from app.engine.nodes.dual_check import GAP_UNCHECKED
from app.engine.runner import run_manager
from app.main import app
from tests.fixtures.dual_check_shop import (
BOTH_WRONG,
BUYERS,
BY_ORDER_DATE,
BY_REGION,
DEFINITION,
RIGHT,
WEEK,
WITH_REFUNDS,
Blind,
add_source,
build,
)
from tests.fixtures.sources import drop_source
#: 和 RIGHT 等价、写法不同(别名、列名都换了),值也是 330.0:报告的写作目录里若混进了盲算的查询,认得出来
BLIND_TOTAL = f"SELECT SUM(o.amount) AS blind_total FROM orders o WHERE o.status <> 'refunded' AND o.paid_date {WEEK}"
GMV = {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'gmv')"}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture(autouse=True)
async def clean_settings():
async def clear() -> None:
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_(["verify", spend.SPEND_KEY])))
await session.commit()
await clear()
yield
await clear()
@pytest.fixture
async def shop(tmp_path):
name, source_id = await add_source(build(tmp_path / "shop.db"))
yield name, source_id
await drop_source(source_id)
@pytest.fixture
async def masked_shop(tmp_path):
"""遮了 region、secret_gmv 两列的同一个库。"""
name, source_id = await add_source(build(tmp_path / "masked.db"), options={"mask_columns": "region, secret_gmv"})
yield name, source_id
await drop_source(source_id)
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
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 graph(source: str, sql: str, *, dual: dict | None = None, metrics: list | None = None, report: bool = False):
"""取数 → 口径卡 → 双盲复算 →(报告)→ 成果。成果的叙述里没有数字:不加双盲复算时是完整出具。"""
nodes = [
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql}),
node("card", "metrics", caliber="销售口径", caliber_version="v1", metrics=metrics or [GMV]),
node("dual", "dual_check", "复核销售额", **{"definitions": {"gmv": DEFINITION}, **(dual or {})}),
]
if report:
nodes.append(node("write", "report", instructions="写一句周报", on_violation="flag"))
nodes.append(node("out", "output", fields=[{"name": "结论", "value": "本周销售额见口径卡"}],
contract={"metrics_from": "card", "narrative": "本周销售额见口径卡"}))
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def wait(run_id: str, statuses=("succeeded", "failed", "interrupted")) -> Run:
for _ in range(600):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
await run_manager.wait_idle(run_id)
return row
raise AssertionError(f"运行 {run_id} 停在 {row.status}:{row.error}")
async def start(spec: dict, inputs: dict | None = None, **kwargs) -> Run:
run = await run_manager.start(graph=spec, input_payload=inputs or {}, **kwargs)
return await wait(run.id)
async def events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq))).scalars())
def logs(evs: list[RunEvent], code: str) -> list[dict]:
return [e.data for e in evs if e.type == "log" and e.data.get("code") == code]
async def pending(run_id: str) -> Approval:
async with SessionLocal() as session:
return (await session.execute(select(Approval).where(
Approval.run_id == run_id, Approval.status == "pending"))).scalar_one()
async def decide(client, run_id: str, choices: dict, note: str = "") -> Run:
approval = await pending(run_id)
res = await client.post(f"/api/approvals/{approval.id}/decide",
json={"approved": True, "value": {"choices": choices, "note": note}})
assert res.status_code == 200, res.text
return await wait(run_id, ("succeeded", "failed"))
def dual_output(run: Run, evs: list[RunEvent]) -> dict:
finished = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
assert finished, "双盲复算节点没有跑完"
return artifact_store.load(finished[-1].data["artifact"])
async def notes_of(source_id: str, table: str = "orders"):
async with SessionLocal() as session:
return await catalog.read_entry(session, source_id, table)
# --------------------------------------------------------------------------
# 一致:不挂起
# --------------------------------------------------------------------------
async def test_agreeing_check_passes_without_asking(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")})
run = await start(graph(name, RIGHT))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
assert out["status"] == "agreed" and "issuance_gaps" not in out
assert out["counts"] == {"agreed": 1, "disagreed": 0, "unchecked": 0}
[metric] = out["metrics"]
assert metric["metric_id"] == "gmv" and metric["status"] == "agreed" and metric["delta_pct"] == 0.0
assert metric["original_rendered"] == metric["blind_rendered"]
assert [d["metrics"] for d in logs(evs, "dual_check_agreed")] == [1]
assert not logs(evs, "dual_check_disagreed") and not [e for e in evs if e.type == "human.requested"]
# 盲算的查询发 tool.end(role: verify),带查询快照,随运行封存
ends = [e.data for e in evs if e.type == "tool.end" and e.node_id == "dual"]
assert ends and all(d.get("role") == "verify" for d in ends)
assert any(d.get("query_artifact") for d in ends)
# 台账只记一条 dual_check,绝不写 query:报告的写作目录按台账里的 query 编号
[finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
kinds = [entry["kind"] for entry in finished.data.get("evidence") or []]
assert kinds == ["dual_check"]
entry = finished.data["evidence"][0]
assert entry["card"] == "card" and entry["status"] == "agreed"
assert entry["metrics"] == [{"id": "gmv", "status": "agreed"}] and entry["counts"] == out["counts"]
assert not logs(evs, "dual_check_unchecked")
# 用量计入运行:图里只有盲算调用了模型
assert blind.calls and run.usage["calls"] == len(blind.calls)
assert run.output["_issuance"]["tier"] == "formal" and run.output["_issuance"]["gaps"] == []
# 盲算 agent 只看得到指标名、口径说明、计算式里是哪一项:看不到原 SQL、取数节点、原流程结果的列名
[task] = blind.tasks
assert "指标:销售额" in task and DEFINITION in task and "输入1" in task
assert "status <> 'refunded'" not in task and "nodes.fetch" not in task and "SUM(" not in task
assert "gmv" not in task
async def test_grouped_rows_tell_the_blind_agent_which_group(monkeypatch, shop):
"""结果分了组:盲算 agent 要知道算的是哪一组(同一行维度列的取值),但看不到同一行别的数、原流程是按哪一列分的组;
统计期这类参数在运行输入里。"""
name, _ = shop
metric = {**GMV, "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
blind = Blind(monkeypatch, name, {"华东销售额": (BY_REGION, "gmv")})
spec = graph(name, BY_REGION, metrics=[metric], dual={"definitions": {}})
spec["nodes"][0]["data"]["config"]["fields"] = [{"name": "week_start"}, {"name": "week_end"}]
run = await start(spec, {"week_start": "2026-09-01", "week_end": "2026-09-07"})
assert run.status == "succeeded", run.error
[task] = blind.tasks
assert "「华东」" in task and "(未提供" in task and "300" not in task
assert "region" not in task and "gmv" not in task
assert "week_start = 2026-09-01" in task and "week_end = 2026-09-07" in task
out = dual_output(run, await events(run.id))
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed"
async def test_the_blind_agent_is_not_told_which_date_column_the_original_grouped_by(monkeypatch, shop):
"""原流程按下单日分组、口径卡取其中一天:盲算只知道是哪一天,按哪一列归属要它自己按口径说明判断。告诉它
「order_date 为 2026-09-02」,它照着算出同一个数,下单日和支付日的陷阱就漏过去了。"""
name, _ = shop
by_day = (f"SELECT order_date, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND order_date {WEEK} "
"GROUP BY order_date ORDER BY order_date")
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, by_day))
assert run.status in ("succeeded", "interrupted"), run.error
[task] = blind.tasks
assert "2026-09-02 这一天" in task
assert "order_date" not in task and "gmv" not in task
async def test_column_names_carrying_a_choice_are_not_given_as_hints(monkeypatch, shop):
"""几项输入都来自查询、又没写口径说明时,列名才交给盲算区分计算式里的几项;列名里带着原流程的取舍的(按下单日、
已支付)不给:「gmv_by_order_date」等于告诉它按下单日归属。看不出取舍的(total)照给。"""
name, _ = shop
wide = (f"SELECT SUM(amount) AS gmv_by_order_date, COUNT(*) AS paid_orders, SUM(amount) AS total FROM orders "
f"WHERE status <> 'refunded' AND order_date {WEEK}")
aov = {"id": "aov", "name": "客单价", "unit": "元", "decimals": 2, "format": "plain",
"expression": "(cell(nodes.fetch, 0, 'gmv_by_order_date') + cell(nodes.fetch, 0, 'total')) "
"/ cell(nodes.fetch, 0, 'paid_orders')"}
blind = Blind(monkeypatch, name, {}) # 盲算都交不出数:这里只看任务原文
await start(graph(name, wide, metrics=[aov], dual={"definitions": {}}))
assert len(blind.tasks) == 3
said = "\n".join(blind.tasks)
assert "gmv_by_order_date" not in said and "paid_orders" not in said
assert "「total」" in said
async def test_two_metrics_using_one_cell_are_checked_once(monkeypatch, shop):
name, _ = shop
share = {"id": "gmv_k", "name": "销售额(千元)", "unit": "千元", "decimals": 2, "format": "thousands",
"expression": "round(cell(nodes.fetch, 0, 'gmv') / 1000, 2)"}
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, RIGHT, metrics=[GMV, share]))
assert run.status == "succeeded", run.error
assert blind.runs == 1, "同一格盲算了不止一次"
out = dual_output(run, await events(run.id))
assert [m["status"] for m in out["metrics"]] == ["agreed", "agreed"]
async def test_hitting_a_limit_counts_as_unchecked(monkeypatch, shop):
"""查询次数用完了还没查到要交的那一格:按无法复算计入缺口;一个指标都没核成,产出是 unchecked,不是 agreed。
盲算交回的理由截一段带进原因:审批卡、缺口说明上看得到它查到了什么。"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": ([BLIND_TOTAL, RIGHT], "gmv")},
rationale="只查到了不含退款的合计,还没来得及按支付日核对。")
run = await start(graph(name, RIGHT), options={"verify": {"dual_check": {"max_queries": 1}}})
assert run.status == "succeeded", run.error
evs = await events(run.id)
assert [(d["limit"], d["metric"]) for d in logs(evs, "dual_check_limit")] == [("max_queries", "gmv")]
out = dual_output(run, evs)
assert out["status"] == "unchecked" and out["metrics"][0]["status"] == "unchecked"
assert out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 1}
reason = "盲算已达每项最多查询次数(1 次);盲算交回的理由:只查到了不含退款的合计,还没来得及按支付日核对。"
assert out["issuance_gaps"] == [f"指标「销售额」无法复算:{reason}"]
[said] = logs(evs, "dual_check_unchecked")
assert said["metrics"] == [{"id": "gmv", "name": "销售额", "reason": reason}]
assert not logs(evs, "dual_check_agreed")
assert not [e for e in evs if e.type == "human.requested"]
[finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
assert finished.data["evidence"][0]["status"] == "unchecked"
assert run.output["_issuance"]["tier"] == "degraded"
assert run.output["_issuance"]["gaps"] == [f"双盲复算「复核销售额」:指标「销售额」无法复算:{reason}"]
async def test_an_answer_on_the_last_allowed_query_still_counts(monkeypatch, shop):
"""查询次数刚好用完,交回的那一格是真查出来的:照常比对,不按触顶算无法复算。"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, RIGHT), options={"verify": {"dual_check": {"max_queries": 1}}})
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed" and "issuance_gaps" not in out
assert not logs(evs, "dual_check_limit") and not logs(evs, "dual_check_unchecked")
assert run.output["_issuance"]["tier"] == "formal"
async def test_every_metric_sharing_a_cell_reports_the_limit(monkeypatch, shop):
name, _ = shop
share = {"id": "gmv_k", "name": "销售额(千元)", "unit": "千元", "decimals": 2, "format": "thousands",
"expression": "round(cell(nodes.fetch, 0, 'gmv') / 1000, 2)"}
blind = Blind(monkeypatch, name, {"销售额": ([BLIND_TOTAL, RIGHT], "gmv")})
run = await start(graph(name, RIGHT, metrics=[GMV, share]), options={"verify": {"dual_check": {"max_queries": 1}}})
assert run.status == "succeeded", run.error
assert blind.runs == 1
evs = await events(run.id)
assert [(d["limit"], d["metric"]) for d in logs(evs, "dual_check_limit")] == [
("max_queries", "gmv"), ("max_queries", "gmv_k")]
[said] = logs(evs, "dual_check_unchecked")
assert [m["id"] for m in said["metrics"]] == ["gmv", "gmv_k"]
async def test_some_unchecked_metrics_make_a_partial_result(monkeypatch, shop):
name, _ = shop
east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
north = {**GMV, "id": "north", "name": "华北销售额", "expression": "cell(nodes.fetch, 1, 'gmv')"}
Blind(monkeypatch, name, {"华东销售额": (BY_REGION, "gmv")})
run = await start(graph(name, BY_REGION, metrics=[east, north], dual={"definitions": {}}))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
assert out["status"] == "partial" and [m["status"] for m in out["metrics"]] == ["agreed", "unchecked"]
assert out["counts"] == {"agreed": 1, "disagreed": 0, "unchecked": 1}
assert [d["metrics"] for d in logs(evs, "dual_check_agreed")] == [1]
[said] = logs(evs, "dual_check_unchecked")
assert [(m["id"], m["name"]) for m in said["metrics"]] == [("north", "华北销售额")] and said["metrics"][0]["reason"]
assert out["issuance_gaps"] == [f"指标「华北销售额」无法复算:{said['metrics'][0]['reason']}"]
async def test_an_unusable_verify_model_is_tried_once(monkeypatch, shop):
"""核查模型没配好:每个指标都记无法复算、原因照实,后面的盲算不再一个个去试。"""
from app.engine import verify_agent
from app.providers.factory import ProviderNotConfigured
name, _ = shop
tried: list[str] = []
async def broken(session, spec):
tried.append(spec.model or "")
raise ProviderNotConfigured("核查用的模型接入已停用", "请到「设置 → 模型接入」中启用")
monkeypatch.setattr(verify_agent, "get_chat_model", broken)
east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
north = {**GMV, "id": "north", "name": "华北销售额", "expression": "cell(nodes.fetch, 1, 'gmv')"}
run = await start(graph(name, BY_REGION, metrics=[east, north], dual={"definitions": {}}))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
assert out["status"] == "unchecked" and [m["status"] for m in out["metrics"]] == ["unchecked", "unchecked"]
assert all("核查模型不可用" in m["reason"] and "已停用" in m["reason"] for m in out["metrics"])
assert len(out["issuance_gaps"]) == 2 and len(tried) == 1
[said] = logs(evs, "dual_check_unchecked")
assert [m["id"] for m in said["metrics"]] == ["east", "north"] and "核查模型不可用" in said["metrics"][0]["reason"]
# --------------------------------------------------------------------------
# 不一致:挂起,按约定的 payload 和工件
# --------------------------------------------------------------------------
async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, shop):
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted", run.error
approval = await pending(run.id)
assert approval.mode == "input" and approval.node_id == "dual"
payload = approval.payload
# 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败;reject_route
# (驳回之后去哪,engine/replay.reject_route):没接驳回出口,驳回后运行就此结束
assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject", "reject_route"}
assert payload["reject_route"] == {"ends": "run"}
assert payload["kind"] == "dual_check" and payload["node_id"] == "dual" and payload["mode"] == "input"
assert payload["on_reject"] == "degrade"
[item] = payload["summary"]
assert set(item) == {"metric_id", "name", "unit", "original_rendered", "blind_rendered", "delta_pct", "diff",
"fallback", "catalog"}
assert (item["metric_id"], item["name"], item["unit"]) == ("gmv", "销售额", "元")
assert item["original_rendered"].startswith("310") and item["blind_rendered"].startswith("330")
assert item["delta_pct"] == round((330 - 310) / 310 * 100, 2)
assert item["fallback"] is False and len(item["diff"]) == 1
assert "order_date" in item["diff"][0] and "paid_date" in item["diff"][0] and "原流程" in item["diff"][0]
# 选哪一边会往数据目录里写什么,按发起审批时的目录预告:只差时间列一处,选哪边就记哪一列
assert item["catalog"] == {
"a": {"write": True, "source": name, "table": "orders", "item": "business_date",
"value": {"column": "order_date"}},
"b": {"write": True, "source": name, "table": "orders", "item": "business_date",
"value": {"column": "paid_date"}},
}
card = artifact_store.load(payload["card_artifact"])
assert set(card) == {"metrics", "model", "same_model"} and card["same_model"] is False
[metric] = card["metrics"]
assert set(metric) == {"metric_id", "name", "unit", "decimals", "original", "blind", "diff"}
assert metric["decimals"] == 1
[orig] = metric["original"]["inputs"]
assert set(orig) == {"path", "value", "sql", "source", "alias"}
assert (orig["sql"], orig["value"], orig["source"], orig["alias"]) == (BY_ORDER_DATE, 310.0, name, "Q1")
[mine] = metric["blind"]["inputs"]
assert set(mine) == {"path", "value", "sql", "source", "alias", "rationale", "rationale_doc"}
assert (mine["sql"], mine["value"], mine["source"], mine["alias"]) == (RIGHT, 330.0, name, "Q1")
assert mine["path"] == orig["path"] == "cell(nodes.fetch, 0, 'gmv')" and "330" in mine["rationale"]
# 理由组装成的文档:正文同 rationale,数字挂着盲算自己那次查询的格子
doc = mine["rationale_doc"]
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
[cited] = [seg for block in doc["blocks"] for unit in block["units"] for seg in unit["segments"] if seg.get("cite")]
assert cited["text"] == "330" and cited["cite"]["alias"] == "Q1"
assert metric["original"]["value"] == 310.0 and metric["blind"]["value"] == 330.0
assert set(metric["diff"]) == {"equivalent", "items", "fallback", "text_diff"}
assert metric["diff"]["equivalent"] is False and metric["diff"]["fallback"] is False
assert [set(i) for i in metric["diff"]["items"]] == [{"aspect", "text"}]
assert metric["diff"]["items"][0]["aspect"] == "time" and metric["diff"]["text_diff"]
evs = await events(run.id)
[said] = logs(evs, "dual_check_disagreed")
assert said["metrics"] == [{"id": "gmv", "name": "销售额"}] and said["card_artifact"] == payload["card_artifact"]
assert len([e for e in evs if e.type == "human.requested"]) == 1
async def test_a_bare_number_in_the_blind_rationale_is_marked(monkeypatch, shop):
"""盲算的理由里写了没有出处的数(「比上周多 20」):对照工件里的理由文档把它标成裸数字,审批卡据此醒目标出"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}, rationale="按口径说明统计,结果是 {ref},比上周多 20。")
run = await start(graph(name, BY_ORDER_DATE))
card = artifact_store.load((await pending(run.id)).payload["card_artifact"])
[mine] = card["metrics"][0]["blind"]["inputs"]
doc = mine["rationale_doc"]
bare = [seg["text"] for block in doc["blocks"] for unit in block["units"] for seg in unit["segments"]
if seg.get("issue") == "uncited_number"]
assert bare == ["20"] and mine["rationale"] == doc["markdown"]
async def test_choosing_the_original_leaves_no_gap(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
done = await decide(client, run.id, {"gmv": "a"}, note="周报一直按下单日统计")
assert done.status == "succeeded", done.error
evs = await events(run.id)
out = dual_output(done, evs)
assert out["status"] == "resolved" and out["choices"] == {"gmv": "a"} and "issuance_gaps" not in out
assert out["note"] == "周报一直按下单日统计" and out["metrics"][0]["choice"] == "a"
assert [d["gaps"] for d in logs(evs, "dual_check_resolved")] == [[]]
assert logs(evs, "dual_check_resolved")[0]["choices"] == {"gmv": "a"}
assert done.output["_issuance"]["tier"] == "formal"
assert len([e for e in evs if e.type == "human.resolved"]) == 1
# 只差时间列一处:采纳原流程,业务日期记原流程那一列
entry = await notes_of(source_id)
item = entry.notes["business_date"]
assert item["value"] == {"column": "order_date"} and (item["source"], item["status"]) == ("human", "confirmed")
assert run.id in item["note"] and "复核销售额" in item["note"] and "销售额" in item["note"]
[wrote] = logs(evs, "dual_check_catalog")
assert wrote["written"] == [{"source": name, "table": "orders", "item": "business_date",
"value": {"column": "order_date"}}] and wrote["skipped"] == []
async def test_choosing_the_blind_result_degrades_and_writes_the_catalog(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "succeeded", done.error
out = dual_output(done, await events(run.id))
assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人选择了盲算结果"]
issuance = done.output["_issuance"]
assert issuance["tier"] == "degraded"
assert issuance["gaps"] == ["双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果"]
assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"}
async def test_a_dual_check_named_after_its_type_is_named_once_in_the_gap(monkeypatch, shop, client):
"""拖进画布没改名的双盲复算:缺口读成「双盲复算:指标「销售额」两路结果不一致……」,不写两遍「双盲复算」"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
spec = graph(name, BY_ORDER_DATE)
next(n for n in spec["nodes"] if n["id"] == "dual")["data"]["label"] = "双盲复算"
run = await start(spec)
done = await decide(client, run.id, {"gmv": "neither"})
assert done.status == "succeeded", done.error
assert done.output["_issuance"]["gaps"] == ["双盲复算:指标「销售额」两路结果不一致,审核人判断两边都不对"]
async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
done = await decide(client, run.id, {"gmv": "neither"}, note="两边都没排除测试单")
assert done.status == "succeeded", done.error
evs = await events(run.id)
out = dual_output(done, evs)
assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人判断两边都不对"]
assert done.output["_issuance"]["tier"] == "degraded"
assert not logs(evs, "dual_check_catalog") and await notes_of(source_id) is None
async def test_a_missing_choice_is_recorded_as_no_choice(monkeypatch, shop, client):
"""没选、选了认不出的:照实记「没有作出选择」,不替审核人说「两边都不对」;没采用原流程,照样降档。"""
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
done = await decide(client, run.id, {"gmv": "maybe"})
assert done.status == "succeeded", done.error
evs = await events(run.id)
out = dual_output(done, evs)
assert out["choices"] == {} and "choice" not in out["metrics"][0]
assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人没有作出选择"]
[resolved] = logs(evs, "dual_check_resolved")
assert resolved["choices"] == {} and resolved["gaps"] == out["issuance_gaps"]
assert "没有作出选择" in resolved["message"] and "两边都不对" not in resolved["message"]
assert done.output["_issuance"]["tier"] == "degraded"
assert not logs(evs, "dual_check_catalog") and await notes_of(source_id) is None
async def test_rejecting_the_approval_ends_the_run_as_rejected(monkeypatch, shop, client):
"""明确驳回裁决:人的决定,不是故障(05-已知遗留 (a))。运行收成 cancelled + end_reason=rejected,原话照旧。"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
approval = await pending(run.id)
res = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "口径要重新对"})
assert res.status_code == 200, res.text
done = await wait(run.id, ("succeeded", "failed", "cancelled"))
assert (done.status, done.end_reason) == ("cancelled", "rejected"), done.error
assert "人工驳回" in (done.error or "") and "口径要重新对" in done.error
[ended] = [e for e in await events(run.id) if e.type == "run.cancelled"]
assert (ended.data["kind"], ended.data["note"]) == ("dual_check", "口径要重新对")
async def test_rejecting_the_approval_with_a_reject_exit_goes_on_along_it(monkeypatch, shop, client):
"""裁决接了「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):驳回时这一步正常结束、沿驳回边往下走,产出带
哪种关卡、结论、备注;运行照它的路径结束。"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
spec = graph(name, BY_ORDER_DATE)
spec["nodes"].append(node("redo", "output", "重新核对", fields=[{"name": "结论", "value": "{{ nodes.dual.note }}"}]))
spec["edges"].append({"source": "dual", "target": "redo", "sourceHandle": "rejected"})
run = await start(spec)
approval = await pending(run.id)
res = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "口径要重新对"})
assert res.status_code == 200, res.text
done = await wait(run.id, ("succeeded", "failed", "cancelled"))
assert (done.status, done.end_reason) == ("succeeded", None), done.error
assert done.output == {"结论": "口径要重新对"}
evs = await events(run.id)
[finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
assert (finished.data["outcome"], finished.data["kind"]) == ("rejected", "dual_check")
assert not [e for e in evs if e.type == "node.finished" and e.node_id == "out"], "普通出口没有走"
async def test_a_rejected_arbitration_through_the_exit_still_degrades_the_issuance(monkeypatch, shop, client):
"""驳回出口直接接到出具的成果节点:审核人驳回了裁决,那组数不能照样完整出具。走出口时这一步的产出照样带出具缺口
(「审核人驳回了裁决」),台账照记一条结论为驳回的 dual_check,证据面板的指标步骤据此认出复核过这份指标集;
不比选「两边都不对」宽。"""
from app.api.evidence import _dual_check_of, _sealed
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
spec = graph(name, BY_ORDER_DATE)
spec["edges"].append({"source": "dual", "target": "out", "sourceHandle": "rejected"})
run = await start(spec)
approval = await pending(run.id)
res = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "口径要重新对"})
assert res.status_code == 200, res.text
done = await wait(run.id, ("succeeded", "failed", "cancelled"))
assert done.status == "succeeded", done.error
issuance = done.output["_issuance"]
assert issuance["tier"] == "degraded", issuance
assert "双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人驳回了裁决" in issuance["gaps"], issuance["gaps"]
evs = await events(run.id)
[finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
assert finished.data["outcome"] == "rejected"
[entry] = [e for e in finished.data.get("evidence") or [] if e.get("kind") == "dual_check"]
assert (entry["status"], entry["card"]) == ("rejected", "card") and entry["metrics"][0]["status"] == "disagreed"
assert finished.data["preview"]["status"] == "rejected" and finished.data["preview"]["rejected"] is True
sealed = await _sealed(run.id)
[(_, artifact, _)] = [c for c in __import__("app.api.evidence", fromlist=["_sealed_cards"])._sealed_cards(sealed)]
checked = _dual_check_of(sealed, "card", artifact, "gmv")
assert checked is not None and checked["status"] == "disagreed" and "choice" not in checked, checked
# 对照:同一处选「两边都不对」也是降档
other = await start(graph(name, BY_ORDER_DATE))
assert (await decide(client, other.id, {"gmv": "neither"})).output["_issuance"]["tier"] == "degraded"
async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE, dual={"on_reject": "fail"}))
# 审批卡上看得出:这一步没采用原流程,运行就会停下
assert (await pending(run.id)).payload["on_reject"] == "fail"
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "failed" and "审核人没有采用原流程的结果" in (done.error or "") and "销售额" in done.error
evs = await events(run.id)
assert [d["gaps"] for d in logs(evs, "dual_check_resolved")] == [
["指标「销售额」两路结果不一致,审核人选择了盲算结果"]]
# 审核人的选择照样写进了目录:那是对数据的判断,和运行停不停无关
entry = await notes_of(source_id)
assert entry.notes["business_date"]["value"] == {"column": "paid_date"} and entry.version == 1
async def test_on_reject_fail_lets_the_original_through(monkeypatch, shop, client):
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE, dual={"on_reject": "fail"}))
done = await decide(client, run.id, {"gmv": "a"})
assert done.status == "succeeded", done.error
async def test_the_same_choice_twice_writes_the_catalog_once(monkeypatch, shop, client):
"""同样的不一致、同样的选择再来一次:目录里已经是这个说法,不再写,也不升版本。"""
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
for _ in range(2):
run = await start(graph(name, BY_ORDER_DATE))
await decide(client, run.id, {"gmv": "b"})
[wrote] = logs(await events(run.id), "dual_check_catalog")
assert wrote["written"] == [] and "无需重复写入" in wrote["skipped"][0]["reason"]
assert (await notes_of(source_id)).version == 1
# --------------------------------------------------------------------------
# 写数据目录:只差一个方面才写,确认过的不覆盖
# --------------------------------------------------------------------------
@pytest.fixture
async def text_dated_shop(tmp_path):
"""同一个库,只是下单日、支付日两列声明成文本(SQLite 常这样存日期)。"""
name, source_id = await add_source(build(tmp_path / "text_dated.db", date_type="TEXT"))
yield name, source_id
await drop_source(source_id)
async def test_time_columns_declared_as_text_are_not_written_as_the_business_date(monkeypatch, text_dated_shop, client):
"""两边按不同的列筛时间,可这两列在表结构里声明成文本:认不准存的是不是日期,选哪边都不记成业务日期。审批卡上的
预告照实说(认不出可记的事实,不带写到哪),裁决后时间线上跳过的原因和预告一字不差。声明成 DATE 的同一个库照常记
(test_choosing_the_blind_result_degrades_and_writes_the_catalog)。"""
name, source_id = text_dated_shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted", run.error
[item] = (await pending(run.id)).payload["summary"]
assert len(item["diff"]) == 1 and "order_date" in item["diff"][0] and "paid_date" in item["diff"][0]
said = item["catalog"]
# 两列都是文本:各说被采纳的那一边的列
for side, label, column in (("a", "原流程", "order_date"), ("b", "盲算", "paid_date")):
assert said[side] == {"write": False, "reason": f"指标「销售额」被采纳的{label}按时间筛的列「orders.{column}」"
"在表结构里的类型是 TEXT,无法确认存的是日期,不写入数据目录"}
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "succeeded", done.error
[wrote] = logs(await events(run.id), "dual_check_catalog")
assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [said["b"]["reason"]]
assert await notes_of(source_id) is None
async def test_an_uploaded_source_says_its_dates_are_stored_as_text(monkeypatch, text_dated_shop, client):
"""上传表格建的数据源(origin=upload),导入时日期一律存成文字:审批卡的预告和裁决后时间线上跳过的原因,都说上传的
表格按文字保存日期,不说「类型是 TEXT」(那不是用户声明的,导入确认时这一列标的是「日期」)。上传源的查询要走版本
快照,这里只把节点认数据源来历的那一步(_source_info)换成上传源,查询照手工源走;_source_info 自己认 origin 的那一步
另见下一个用例。"""
from app.engine.nodes import dual_check
name, source_id = text_dated_shop
real = dual_check._source_info
async def uploaded(sources, source):
return {**await real(sources, source), "uploaded": True}
monkeypatch.setattr(dual_check, "_source_info", uploaded)
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted", run.error
[item] = (await pending(run.id)).payload["summary"]
said = item["catalog"]
for side, label, column in (("a", "原流程", "order_date"), ("b", "盲算", "paid_date")):
assert said[side] == {"write": False, "reason": f"指标「销售额」被采纳的{label}按时间筛的列「orders.{column}」"
"来自上传的表格,表格里的日期一律按文字保存,无法确认存的是"
"日期,不写入数据目录"}
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "succeeded", done.error
[wrote] = logs(await events(run.id), "dual_check_catalog")
assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [said["b"]["reason"]]
assert await notes_of(source_id) is None
async def test_the_source_info_knows_an_uploaded_source(shop):
from app.engine.nodes.dual_check import _source_info, _Sources
name, source_id = shop
assert (await _source_info(_Sources(), name))["uploaded"] is False
async with SessionLocal() as session:
(await session.get(DataSource, source_id)).origin = "upload"
await session.commit()
assert (await _source_info(_Sources(), name))["uploaded"] is True
assert (await _source_info(_Sources(), "dc_no_such_source"))["uploaded"] is False
async def test_a_refund_filter_becomes_the_valid_row_filter(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, WITH_REFUNDS))
payload = (await pending(run.id)).payload
assert "status = 'refunded'" in payload["summary"][0]["diff"][0]
await decide(client, run.id, {"gmv": "b"})
item = (await notes_of(source_id)).notes["valid_filter"]
assert item["value"] == "status <> 'refunded'" and item["status"] == "confirmed" and item["source"] == "human"
async def test_the_adopted_side_without_the_filter_writes_nothing(monkeypatch, shop, client):
"""多出条件的是原流程,审核人却采纳了没有这个条件的盲算:目录里没有对应的写法,照实说。"""
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (WITH_REFUNDS, "gmv")})
run = await start(graph(name, RIGHT))
done = await decide(client, run.id, {"gmv": "b"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "被采纳的盲算没有这个条件" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_two_differences_are_not_written(monkeypatch, shop, client):
name, source_id = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BOTH_WRONG))
assert len((await pending(run.id)).payload["summary"][0]["diff"]) == 2
done = await decide(client, run.id, {"gmv": "b"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "不止差一个方面" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_a_confirmed_catalog_entry_is_not_overwritten(monkeypatch, shop, client):
name, source_id = shop
async with SessionLocal() as session:
await catalog.write_entry(session, source_id, "orders", {
"business_date": catalog.make_item({"column": "order_date"}, "human", "confirmed")}, if_version=0,
actor="数据组")
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
# 预告按目录里已有的说法算:选原流程是同一个说法、不用再写,选盲算不会覆盖已确认的
preview = (await pending(run.id)).payload["summary"][0]["catalog"]
assert preview["a"]["write"] is False and "无需重复写入" in preview["a"]["reason"]
assert preview["b"]["write"] is False and "已有人工确认过的说法「order_date」" in preview["b"]["reason"]
# 认出了事实、只是被目录挡下:照样说写到哪张表的哪一项、本来要写什么
assert {k: preview["b"][k] for k in ("source", "table", "item", "value")} == \
{"source": name, "table": "orders", "item": "business_date", "value": {"column": "paid_date"}}
done = await decide(client, run.id, {"gmv": "b"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [preview["b"]["reason"]]
assert "已有人工确认过的说法「order_date」" in wrote["skipped"][0]["reason"] and "paid_date" in wrote["skipped"][0]["reason"]
entry = await notes_of(source_id)
assert entry.version == 1 and entry.notes["business_date"]["value"] == {"column": "order_date"}
async def test_a_rejected_filter_is_not_written_back_along_with_another_metric(monkeypatch, shop, client, tmp_path):
"""目录里「status <> 'refunded'」被人工驳回过。同一次裁决里「销售额」采纳了排除退款的盲算、「订单金额」采纳了排除
测试单的盲算:审批卡上预告前者不写、后者写「is_test = 0」。真写的和预告一字不差——驳回过的条件不会借着两个指标
并成一项又写成人工确认。"""
name, source_id = shop
db = sqlite3.connect(tmp_path / "shop.db")
db.execute("INSERT INTO orders VALUES (6, '华东', 105, 40.0, 'paid', '2026-09-03', '2026-09-03', 'online', 1)")
db.commit()
db.close()
async with SessionLocal() as session:
await catalog.write_entry(session, source_id, "orders", {
"valid_filter": catalog.make_item("status <> 'refunded'", "human", "rejected")}, if_version=0, actor="数据组")
original = f"SELECT SUM(amount) AS gmv, SUM(amount) AS total FROM orders WHERE paid_date {WEEK}"
no_test = f"SELECT SUM(amount) AS total FROM orders WHERE is_test = 0 AND paid_date {WEEK}"
total = {**GMV, "id": "total", "name": "订单金额", "expression": "cell(nodes.fetch, 0, 'total')"}
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv"), "订单金额": (no_test, "total")})
run = await start(graph(name, original, metrics=[GMV, total], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
said = {s["metric_id"]: s["catalog"]["b"] for s in payload["summary"]}
assert said["gmv"]["write"] is False and "曾被人工驳回" in said["gmv"]["reason"]
assert said["total"]["write"] is True and said["total"]["value"] == "is_test = 0"
# 两个指标都落到有效记录条件上:合起来的结果另有预告,和真写一致
choices = {"gmv": "b", "total": "b"}
expected = card_said(payload, choices)
assert expected["gmv"]["write"] is False and expected["gmv"]["reason"] == said["gmv"]["reason"]
assert expected["total"] == {"write": True, "value": "is_test = 0", "item": "valid_filter"}
done = await decide(client, run.id, choices)
assert done.status == "succeeded", done.error
[wrote] = logs(await events(run.id), "dual_check_catalog")
assert wrote["written"] == [{"source": name, "table": "orders", "item": "valid_filter", "value": "is_test = 0"}]
assert [s["reason"] for s in wrote["skipped"]] == [said["gmv"]["reason"]]
await assert_written_as_said(expected, wrote, source_id)
item = (await notes_of(source_id)).notes["valid_filter"]
assert item["value"] == said["total"]["value"] and (item["source"], item["status"]) == ("human", "confirmed")
assert "「订单金额」" in item["note"] and "「销售额」" not in item["note"]
def card_said(payload: dict, choices: dict) -> dict:
"""审批卡按这组选择会怎么预告(DualCheckApproval 的认法):选的那一边认出了写到哪,就看按当前的选择还有哪些指标
落到同一项上——只有它自己时照摘要里它的预告,几个时照对照工件 catalog_joint 里这几个成员的那一条。"""
summary = {s["metric_id"]: s["catalog"] for s in payload["summary"]}
joint = artifact_store.load(payload["card_artifact"]).get("catalog_joint") or []
def where(metric_id: str, choice: str):
side = summary[metric_id][choice]
return (side["source"], side["table"], side["item"]) if "table" in side else None
out = {}
for metric_id, choice in choices.items():
if choice not in ("a", "b"):
continue
target = where(metric_id, choice)
peers = sorted((m, c) for m, c in choices.items() if c in ("a", "b") and target and where(m, c) == target)
if len(peers) < 2:
out[metric_id] = summary[metric_id][choice]
continue
[entry] = [e for e in joint if sorted((m["metric_id"], m["choice"]) for m in e["members"]) == peers]
out[metric_id] = {**entry["outcomes"][metric_id], "item": entry["item"]}
return out
async def assert_written_as_said(said: dict, wrote: dict, source_id: str) -> None:
"""预告和真写逐字一致:说会记为什么的,目录里就是那个值、日志的 written 里有它;说不记的,原因就在日志的跳过原因里"""
entry = await notes_of(source_id)
for metric_id, side in said.items():
if side["write"]:
assert any(w["item"] == side["item"] and w["value"] == side["value"] for w in wrote["written"]), \
(metric_id, side, wrote)
assert entry.notes[side["item"]]["value"] == side["value"]
else:
assert side["reason"] in [s["reason"] for s in wrote["skipped"]], (metric_id, side, wrote)
#: 两个指标取自同一条查询的两列,都是按下单日筛的:盲算按支付日算,两个指标都只差时间列,落到 orders 的业务日期上
TWO_BY_ORDER_DATE = (f"SELECT SUM(amount) AS gmv, SUM(amount) AS total FROM orders "
f"WHERE status <> 'refunded' AND order_date {WEEK}")
TOTAL = {**GMV, "id": "total", "name": "订单金额", "expression": "cell(nodes.fetch, 0, 'total')"}
@pytest.mark.parametrize("choices", [
{"gmv": "a", "total": "b"}, # 说法不同:两个都不写
{"gmv": "b", "total": "b"}, # 说法一样:写一次
{"gmv": "a", "total": "a"},
{"gmv": "b", "total": "neither"}, # 只有一个落到这一项上:照它自己的预告
])
async def test_the_card_preview_is_what_the_decision_writes(monkeypatch, shop, client, choices):
"""图级:两个指标都只差时间列、落到数据目录的同一项上。审批卡按当前的选择预告的(摘要里各自的预告、对照工件里
合起来的预告),和裁决后真写进目录的逐字一致:写的值一样,不写的原因一句不差"""
name, source_id = shop
right_total = f"SELECT SUM(amount) AS total FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}"
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv"), "订单金额": (right_total, "total")})
run = await start(graph(name, TWO_BY_ORDER_DATE, metrics=[GMV, TOTAL], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
card = artifact_store.load(payload["card_artifact"])
assert len(card["catalog_joint"]) == 4, "两个指标两边都落在业务日期上:四种组合各预告一次"
said = card_said(payload, choices)
done = await decide(client, run.id, choices)
assert done.status == "succeeded", done.error
[wrote] = logs(await events(run.id), "dual_check_catalog")
await assert_written_as_said(said, wrote, source_id)
if choices == {"gmv": "a", "total": "b"}:
assert not said["gmv"]["write"] and said["gmv"] == {**said["total"], "item": "business_date"}
assert wrote["written"] == [] and "给出了不同的说法" in said["gmv"]["reason"]
async def test_a_metric_scope_filter_is_not_written_as_a_valid_filter(monkeypatch, shop, client):
"""「华东销售额」原流程按指标名只看华东,盲算漏了地区:审核人选原流程,但 region = '华东' 是这个指标自己的筛选
范围,写成表的有效记录条件,以后所有查订单表的工作流(包括下一次盲算)都会只看华东。"""
name, source_id = shop
east = {**GMV, "id": "east", "name": "华东销售额"}
Blind(monkeypatch, name, {"华东销售额": (RIGHT, "gmv")})
original = RIGHT.replace("WHERE ", "WHERE region = '华东' AND ")
run = await start(graph(name, original, metrics=[east], dual={"definitions": {"east": "华东地区已支付订单的金额"}}))
[item] = (await pending(run.id)).payload["summary"]
assert len(item["diff"]) == 1 and "region = '华东'" in item["diff"][0]
# 预告和真写的是同一套判断:两边选哪个都不写,原因照实
assert item["catalog"]["a"]["write"] is False and "可能是指标自己的筛选范围" in item["catalog"]["a"]["reason"]
assert item["catalog"]["b"]["write"] is False and "被采纳的盲算没有这个条件" in item["catalog"]["b"]["reason"]
done = await decide(client, run.id, {"east": "a"})
assert done.status == "succeeded", done.error
[wrote] = logs(await events(run.id), "dual_check_catalog")
assert wrote["written"] == [] and "可能是指标自己的筛选范围" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_equivalent_sql_with_different_values_points_at_the_data(monkeypatch, shop, client, tmp_path):
"""原流程查完之后库里多了一单:两条 SQL 规范化后一致、值却不同,差异说明写「差在数据」,不写数据目录。"""
from app.engine.nodes.dual_check import SAME_SQL
name, source_id = shop
def late_order() -> None:
db = sqlite3.connect(tmp_path / "shop.db")
db.execute("INSERT INTO orders VALUES (6, '华东', 104, 70.0, 'paid', '2026-09-04', '2026-09-04', 'online', 0)")
db.commit()
db.close()
Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")}, on_query=late_order)
run = await start(graph(name, RIGHT))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
[item] = payload["summary"]
assert item["diff"] == [SAME_SQL] and item["fallback"] is False
assert item["original_rendered"].startswith("330") and item["blind_rendered"].startswith("400")
[metric] = artifact_store.load(payload["card_artifact"])["metrics"]
assert metric["diff"]["equivalent"] is True and metric["diff"]["items"] == [{"aspect": "data", "text": SAME_SQL}]
done = await decide(client, run.id, {"gmv": "b"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "规范化后一致" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_a_multi_column_query_is_compared_on_its_own_column(monkeypatch, shop, client):
"""原流程一条查询算了好几列,口径卡取其中一列:差异只比这一列是怎么算的,不混进「选出的列」这类差别。"""
name, source_id = shop
wide = f"SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE status <> 'refunded' AND order_date {WEEK}"
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, wide))
payload = (await pending(run.id)).payload
[item] = payload["summary"]
assert len(item["diff"]) == 1 and "order_date" in item["diff"][0] and "paid_date" in item["diff"][0]
[metric] = artifact_store.load(payload["card_artifact"])["metrics"]
assert [i["aspect"] for i in metric["diff"]["items"]] == ["time"]
# 对照里的原 SQL 还是原流程整条查询:给人看的是原文
assert metric["original"]["inputs"][0]["sql"] == wide
await decide(client, run.id, {"gmv": "b"})
assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"}
async def test_a_grouped_query_is_compared_on_the_row_it_was_taken_from(monkeypatch, shop, client):
"""原流程按地区分组、取华东那一行:收窄成「华东这一组」再比,差异里不出现分组、地区条件这类和这一格无关的差别。"""
name, source_id = shop
by_region = (f"SELECT region, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND order_date {WEEK} "
"GROUP BY region ORDER BY region")
east_paid = RIGHT.replace("WHERE ", "WHERE region = '华东' AND ")
east = {**GMV, "id": "east", "name": "华东销售额"}
Blind(monkeypatch, name, {"华东销售额": (east_paid, "gmv")})
run = await start(graph(name, by_region, metrics=[east], dual={"definitions": {}}))
[item] = (await pending(run.id)).payload["summary"]
assert item["original_rendered"].startswith("200") and item["blind_rendered"].startswith("300")
assert len(item["diff"]) == 1 and "order_date" in item["diff"][0] and "paid_date" in item["diff"][0]
await decide(client, run.id, {"east": "b"})
assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"}
async def test_a_having_clause_is_dropped_when_narrowing_to_one_row(monkeypatch, shop, client):
"""原流程分组后另有 HAVING:取的这一行既然在结果里,它所在的组就满足 HAVING,对这一格是噪声。收窄时连 HAVING
一起去掉,差异只剩真正的那一处(时间列),写得进目录。"""
name, source_id = shop
by_region = (f"SELECT region, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND order_date {WEEK} "
"GROUP BY region HAVING SUM(amount) > 10 ORDER BY region")
east_paid = RIGHT.replace("WHERE ", "WHERE region = '华东' AND ")
east = {**GMV, "id": "east", "name": "华东销售额"}
Blind(monkeypatch, name, {"华东销售额": (east_paid, "gmv")})
run = await start(graph(name, by_region, metrics=[east], dual={"definitions": {}}))
[item] = (await pending(run.id)).payload["summary"]
assert item["original_rendered"].startswith("200") and item["blind_rendered"].startswith("300")
assert len(item["diff"]) == 1 and "order_date" in item["diff"][0] and "paid_date" in item["diff"][0]
await decide(client, run.id, {"east": "b"})
assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"}
async def test_a_condition_on_a_grouped_column_is_not_written(monkeypatch, shop, client, tmp_path):
"""原流程按 is_test 分组、口径卡取 is_test = 0 那一行:收窄后原流程多出「is_test = 0」,看着像排除测试单的有效
记录条件,可它用的是原流程分组的列——是这个指标按组取的范围,不写目录。取值 0 太短、不在任何文字里,只有分组的列
拦得下它。"""
name, source_id = shop
db = sqlite3.connect(tmp_path / "shop.db")
db.execute("INSERT INTO orders VALUES (6, '华东', 105, 40.0, 'paid', '2026-09-03', '2026-09-03', 'online', 1)")
db.commit()
db.close()
by_flag = (f"SELECT is_test, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK} "
"GROUP BY is_test ORDER BY is_test")
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, by_flag, dual={"definitions": {}}))
[item] = (await pending(run.id)).payload["summary"]
assert item["original_rendered"].startswith("330") and item["blind_rendered"].startswith("370")
assert len(item["diff"]) == 1 and "is_test = 0" in item["diff"][0]
done = await decide(client, run.id, {"gmv": "a"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "分组的列" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_a_condition_matching_a_run_input_is_not_written(monkeypatch, shop, client):
"""排除退款的条件看着像有效记录条件,可取值正是本次运行的输入(这一期要排除的状态):是这个指标这一期的筛选范围,
不是整张表的事实,不写目录。没有这个输入时同样的选择会写(test_a_refund_filter_becomes_the_valid_row_filter)。"""
name, source_id = shop
spec = graph(name, RIGHT, dual={"definitions": {}})
spec["nodes"][0]["data"]["config"]["fields"] = [{"name": "exclude_status"}]
Blind(monkeypatch, name, {"销售额": (WITH_REFUNDS, "gmv")})
run = await start(spec, {"exclude_status": "refunded"})
[item] = (await pending(run.id)).payload["summary"]
assert len(item["diff"]) == 1 and "refunded" in item["diff"][0]
done = await decide(client, run.id, {"gmv": "a"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "本次运行的输入" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_two_differing_inputs_do_not_write_the_catalog(monkeypatch, shop, client):
"""一个指标的两个输入都对不上:审核人选的是整个指标哪边对,拆不出是哪一处,不写目录。"""
name, source_id = shop
share = (f"SELECT SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END) AS east, SUM(amount) AS total FROM orders "
f"WHERE status <> 'refunded' AND order_date {WEEK}")
east_paid = (f"SELECT SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END) AS east FROM orders "
f"WHERE status <> 'refunded' AND paid_date {WEEK}")
ratio = {"id": "east_share", "name": "华东占比", "unit": "", "decimals": 3, "format": "plain",
"expression": "cell(nodes.fetch, 0, 'east') / cell(nodes.fetch, 0, 'total')"}
blind = Blind(monkeypatch, name, {"华东占比|输入1": (east_paid, "east"), "华东占比|输入2": (RIGHT, "gmv")})
run = await start(graph(name, share, metrics=[ratio], dual={"definitions": {}}))
assert blind.runs == 2
[item] = (await pending(run.id)).payload["summary"]
assert len(item["diff"]) == 2 and all("输入「" in d for d in item["diff"])
done = await decide(client, run.id, {"east_share": "b"})
[wrote] = logs(await events(done.id), "dual_check_catalog")
assert wrote["written"] == [] and "不止差一个方面" in wrote["skipped"][0]["reason"]
assert await notes_of(source_id) is None
async def test_only_the_configured_metrics_are_checked(monkeypatch, shop):
name, _ = shop
wide = f"SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}"
count = {"id": "orders", "name": "订单数", "unit": "单", "decimals": 0, "expression": "cell(nodes.fetch, 0, 'n')"}
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv"), "订单数": (BUYERS, "buyers")})
run = await start(graph(name, wide, metrics=[GMV, count], dual={"metrics": ["gmv"]}))
assert run.status == "succeeded", run.error
out = dual_output(run, await events(run.id))
assert [m["metric_id"] for m in out["metrics"]] == ["gmv"] and out["status"] == "agreed"
assert blind.runs == 1 and all("指标:订单数" not in t for t in blind.tasks)
async def test_a_zero_original_compares_by_absolute_error_and_has_no_delta_pct(monkeypatch, shop):
name, _ = shop
voids = {"id": "voids", "name": "作废单数", "unit": "单", "decimals": 0, "expression": "cell(nodes.fetch, 0, 'n')"}
none_void = f"SELECT COUNT(*) AS n FROM orders WHERE status = 'void' AND paid_date {WEEK}"
refunded = f"SELECT COUNT(*) AS n FROM orders WHERE status = 'refunded' AND paid_date {WEEK}"
Blind(monkeypatch, name, {"作废单数": (refunded, "n")})
run = await start(graph(name, none_void, metrics=[voids], dual={"definitions": {}}))
[item] = (await pending(run.id)).payload["summary"]
assert item["original_rendered"].startswith("0") and item["blind_rendered"].startswith("1")
assert item["delta_pct"] is None
def test_zero_originals_use_absolute_error():
from app.engine.nodes.dual_check import _agree, _delta_pct
assert _agree(0, 1e-7, 1e-6) and not _agree(0, 0.5, 1e-6)
assert _agree(100.0, 100.00005, 1e-6) and not _agree(100.0, 100.01, 1e-6)
assert _delta_pct(0, 5) is None and _delta_pct(310.0, 330.0) == 6.45 and _delta_pct(-10, -5) == 50.0
async def test_the_summary_lists_at_most_three_differences(monkeypatch, shop):
name, _ = shop
blind_sql = RIGHT + " AND region <> '华北' AND customer_id <> 999"
Blind(monkeypatch, name, {"销售额": (blind_sql, "gmv")})
run = await start(graph(name, BOTH_WRONG))
payload = (await pending(run.id)).payload
[item] = payload["summary"]
[metric] = artifact_store.load(payload["card_artifact"])["metrics"]
assert len(metric["diff"]["items"]) == 4 and len(item["diff"]) == 3
assert item["diff"] == [i["text"] for i in metric["diff"]["items"][:3]]
# --------------------------------------------------------------------------
# 遮罩
# --------------------------------------------------------------------------
async def test_a_blind_cell_on_a_masked_column_is_not_used(monkeypatch, masked_shop, client):
"""盲算交的那一格在遮罩列上:值不采用(不进审批卡、对照工件、产出),这个指标按无法复算记。"""
name, _ = masked_shop
east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
north = {**GMV, "id": "north", "name": "华北销售额", "expression": "cell(nodes.fetch, 1, 'gmv')"}
east_by_order = BY_ORDER_DATE.replace("WHERE ", "WHERE region = '华东' AND ")
north_masked = (f"SELECT SUM(amount) + 0.25 AS secret_gmv FROM orders WHERE status <> 'refunded' "
f"AND paid_date {WEEK} AND region = '华北'")
Blind(monkeypatch, name, {"华东销售额": (east_by_order, "gmv"), "华北销售额": (north_masked, "secret_gmv")})
run = await start(graph(name, BY_REGION, metrics=[east, north], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
card = artifact_store.load(payload["card_artifact"])
assert [m["metric_id"] for m in payload["summary"]] == ["east"]
assert [m["metric_id"] for m in card["metrics"]] == ["east"]
assert "30.25" not in json.dumps(payload, ensure_ascii=False)
assert "30.25" not in json.dumps(card, ensure_ascii=False)
done = await decide(client, run.id, {"east": "a"})
out = dual_output(done, await events(run.id))
[_, row] = out["metrics"]
assert row["status"] == "unchecked" and "遮罩" in row["reason"] and row["blind_rendered"] is None
assert "30.25" not in json.dumps(out, ensure_ascii=False)
async def test_a_renamed_masked_column_in_the_blind_query_is_masked_before_the_blind_agent_sees_it(
monkeypatch, masked_shop):
"""盲算的查询选出了改了名字的遮罩列(region AS 区域):交给盲算模型、落进盲算快照之前就是「已遮罩」,快照记下查询
时遮了哪一列;没碰遮罩列的那一列(销售额)照常取值、照常比对。"""
name, _ = masked_shop
east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
renamed = (f"SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK} "
"GROUP BY region ORDER BY region")
Blind(monkeypatch, name, {"华东销售额": (renamed, "gmv")})
run = await start(graph(name, BY_REGION, metrics=[east], dual={"definitions": {}}))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed"
[end] = [e.data for e in evs if e.type == "tool.end" and e.node_id == "dual" and e.data.get("query_artifact")]
snapshot = artifact_store.load(end["query_artifact"])
assert {row[0] for row in snapshot["rows"]} == {"已遮罩"} and snapshot["masked_at_query"] == ["区域"]
shown = json.loads(end["preview"].split("\n", 1)[1])
assert {row[0] for row in shown["rows"]} == {"已遮罩"}, "交给盲算模型的结果里也是「已遮罩」"
async def test_a_metric_summed_over_a_masked_column_is_not_blind_computed(monkeypatch, tmp_path):
"""复核第 1 轮:金额这一列设了遮罩,指标是 SUM(amount)。核查 agent 的查询结果在工具边界遮好,汇总遮罩列的值也遮
(只有一行的汇总就是那一行的原值),盲算交回的格子注定取不到。所以派出盲算之前按 SQL 认出这一列由遮罩列算出,
直接按无法复算记、说清原因,不白花一次盲算的钱。以前只按列名认,gmv 认不出,盲算照样派出去,最后才在格子引用上
被拒,原因说成「盲算交回的格子引用不可用」。"""
name, source_id = await add_source(build(tmp_path / "amount_masked.db"), options={"mask_columns": "amount"})
try:
blind = Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")})
run = await start(graph(name, RIGHT))
evs = await events(run.id)
out = dual_output(run, evs)
[row] = out["metrics"]
assert row["status"] == "unchecked" and blind.runs == 0, "没派盲算"
assert row["reason"] == ("输入 cell(nodes.fetch, 0, 'gmv') 由设置了遮罩的列算出(经改名、函数或汇总),"
"盲算时会被遮罩,不能用于复算")
assert not [e for e in evs if e.type == "tool.end" and e.node_id == "dual"]
finally:
await drop_source(source_id)
async def test_a_metric_filtered_by_a_masked_column_inside_its_sum_is_still_blind_computed(monkeypatch, masked_shop):
"""遮罩列(区域)只在汇总的 CASE 条件里(SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END)):同写在 WHERE 里,
值全部来自金额,原 SQL 和盲算的查询都不遮,照常比对。"""
name, _ = masked_shop
east = (f"SELECT SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END) AS gmv FROM orders "
f"WHERE status <> 'refunded' AND paid_date {WEEK}")
blind = Blind(monkeypatch, name, {"销售额": (east, "gmv")})
run = await start(graph(name, east))
out = dual_output(run, await events(run.id))
assert blind.runs == 1 and out["metrics"][0]["status"] == "agreed", out["metrics"][0]
async def test_masked_group_columns_are_not_shown_to_the_blind_agent(monkeypatch, masked_shop):
name, _ = masked_shop
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
blind = Blind(monkeypatch, name, {"首组销售额": (RIGHT, "gmv")})
await start(graph(name, BY_REGION, metrics=[first], dual={"definitions": {}}))
[task] = blind.tasks
assert "华东" not in task and "region" not in task
async def test_masked_group_values_stay_out_of_the_card_and_the_catalog_log(monkeypatch, masked_shop, client):
"""原流程按遮罩列(region)分组、口径卡取华东那一行:差异说明收窄到这一行时,不能拿这一行的分组值补等值条件——
那样「region = '华东'」就进了审批卡、对照工件、事件和目录日志。遮罩的分组列上不收窄,照整条比。
盲算没分组、按下单日筛:收窄了的话,两边差在时间列和原流程多出来的「region = '华东'」,说明和对照里都带着这个取值;
照整条比,时间列照样说得出,原流程的分组、多选的列归纳不了(_compared_whole)。"""
name, _ = masked_shop
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
Blind(monkeypatch, name, {"首组销售额": (BY_ORDER_DATE, "gmv")})
run = await start(graph(name, BY_REGION, metrics=[first], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
[item] = payload["summary"]
assert item["original_rendered"].startswith("300") and item["blind_rendered"].startswith("310")
card = artifact_store.load(payload["card_artifact"])
done = await decide(client, run.id, {"first": "a"})
assert done.status == "succeeded", done.error
evs = await events(run.id)
_no_masked_value(payload, card, done, evs)
_compared_whole(payload, card)
# 差异说明里有未能归纳的部分:选了也不写目录
[wrote] = logs(evs, "dual_check_catalog")
assert wrote["written"] == [] and "不止差一个方面" in wrote["skipped"][0]["reason"]
async def test_a_renamed_masked_group_column_is_not_narrowed_either(monkeypatch, masked_shop, client):
"""原流程把遮罩列改了名再分组(region AS area):结果列名 area 不在遮罩里,分组值却还是 region 的取值。收窄时看的
是分组键用到的列,一样不拿这一行的分组值补等值条件,「华东」不进审批卡、对照工件、产出和事件,照整条比。"""
name, _ = masked_shop
renamed = (f"SELECT region AS area, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' "
f"AND paid_date {WEEK} GROUP BY region ORDER BY region")
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
blind = Blind(monkeypatch, name, {"首组销售额": (BY_ORDER_DATE, "gmv")})
run = await start(graph(name, renamed, metrics=[first], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
[item] = payload["summary"]
assert item["original_rendered"].startswith("300") and item["blind_rendered"].startswith("310")
card = artifact_store.load(payload["card_artifact"])
done = await decide(client, run.id, {"first": "a"})
assert done.status == "succeeded", done.error
_no_masked_value(payload, card, done, await events(run.id))
_compared_whole(payload, card)
# 交给盲算的这一行的分组取值也不给:结果列 area 的值取自遮罩列 region(以前 _dims 只按结果列名认遮罩)
[task] = blind.tasks
assert "华东" not in task, task
#: 外层按 area 分组、取每组的销售额
_BY_AREA = f"SELECT area, SUM(amount) AS gmv FROM {{}} WHERE status <> 'refunded' AND paid_date {WEEK} GROUP BY area ORDER BY area"
#: 遮罩列在子查询、WITH 里改了名(直接起别名、套函数、写在列名表里、隔了两层),外层再按新名字分组:外层的分组键 area
#: 看不出取自遮罩列 region
RENAMED_INSIDE = {
"subquery": _BY_AREA.format("(SELECT region AS area, amount, status, paid_date FROM orders) t"),
"cte": "WITH t AS (SELECT region AS area, amount, status, paid_date FROM orders) " + _BY_AREA.format("t"),
"function": "WITH t AS (SELECT UPPER(region) AS area, amount, status, paid_date FROM orders) " + _BY_AREA.format("t"),
"column_list": ("WITH t(area, amount, status, paid_date) AS (SELECT region, amount, status, paid_date FROM orders) "
+ _BY_AREA.format("t")),
"nested": _BY_AREA.format("(SELECT area, amount, status, paid_date FROM (SELECT region AS area, amount, status, "
"paid_date FROM orders) a) b"),
}
@pytest.mark.parametrize("shape", list(RENAMED_INSIDE))
async def test_a_masked_group_column_renamed_inside_is_not_narrowed_or_shown(monkeypatch, masked_shop, client, shape):
"""遮罩列在子查询或 WITH 里改了名(region AS area),外层按 area 分组:外层只看得到 area,以前照常拿这一行的分组值
补上「area = '华东'」,差异说明把别名还原成「region = '华东'」,进了审批卡、对照工件和目录日志;交给盲算的这一行的
分组取值里也有「华东」。现在按 SQL 认出取值来自遮罩列的名字(同审批质询认改了名的遮罩列):不收窄、照整条比,
盲算任务里也不给这一组的取值。"""
name, _ = masked_shop
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
blind = Blind(monkeypatch, name, {"首组销售额": (BY_ORDER_DATE, "gmv")})
run = await start(graph(name, RENAMED_INSIDE[shape], metrics=[first], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
[item] = payload["summary"]
assert item["original_rendered"].startswith("300") and item["blind_rendered"].startswith("310")
card = artifact_store.load(payload["card_artifact"])
done = await decide(client, run.id, {"first": "a"})
assert done.status == "succeeded", done.error
evs = await events(run.id)
_no_masked_value(payload, card, done, evs)
[task] = blind.tasks
assert "华东" not in task, task
def _compared_whole(payload: dict, card: dict) -> None:
"""差异说明照整条比的痕迹(原流程按地区分组取一行,盲算没分组、按下单日筛):时间列照样说得出;原流程的分组、多选的
地区列归纳不了,另起一条「另有未能归纳的差异」;规范化后的对照里还留着原流程的 GROUP BY。收窄了的话分组去掉、换成
等值条件,两边都是简单形状,说明里就是时间列和多出来的那个条件,这几样都不会有。"""
[metric] = card["metrics"]
diff = metric["diff"]
assert [i["aspect"] for i in diff["items"]] == ["time", "other"], diff["items"]
assert diff["fallback"] is True and any("GROUP BY" in line for line in diff["text_diff"]), diff["text_diff"]
[item] = payload["summary"]
assert len(item["diff"]) == 2 and "order_date" in item["diff"][0] and "另有未能归纳的差异" in item["diff"][1], item
assert item["fallback"] is False
async def test_a_masked_group_column_of_the_blind_query_is_not_narrowed_either(monkeypatch, masked_shop, client):
"""盲算自己按遮罩列分组、交了华东那一行:同样不拿这一行的分组值改写盲算的 SQL。"""
name, _ = masked_shop
Blind(monkeypatch, name, {"销售额": (BY_REGION, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted", run.error
payload = (await pending(run.id)).payload
assert payload["summary"][0]["blind_rendered"].startswith("300")
card = artifact_store.load(payload["card_artifact"])
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "succeeded", done.error
_no_masked_value(payload, card, done, await events(run.id))
async def test_a_with_clause_that_does_not_touch_masked_columns_is_still_narrowed(monkeypatch, masked_shop):
"""数据源设了遮罩,原流程的 WITH 里却没用到遮罩列、按渠道分组:照常收窄到这一行(补上 channel = 'offline'),差异
说明说得出这个条件。认改了名的遮罩列是按 SQL 追到底的,不是见到子查询、WITH 就一律不收窄。"""
name, _ = masked_shop
by_channel = (f"WITH t AS (SELECT channel, amount, status, paid_date FROM orders) SELECT channel, SUM(amount) AS gmv "
f"FROM t WHERE status <> 'refunded' AND paid_date {WEEK} GROUP BY channel ORDER BY channel")
first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}
blind = Blind(monkeypatch, name, {"首组销售额": (RIGHT, "gmv")})
run = await start(graph(name, by_channel, metrics=[first], dual={"definitions": {}}))
assert run.status == "interrupted", run.error
[item] = (await pending(run.id)).payload["summary"]
assert item["original_rendered"].startswith("230") and item["blind_rendered"].startswith("330")
assert any("channel = 'offline'" in d for d in item["diff"]), item["diff"]
# 渠道不是遮罩列:交给盲算的这一行的分组取值照给
[task] = blind.tasks
assert "「offline」" in task, task
#: 销售额(第一份快照的一格)加上首组地区名的字数(第二份快照按位置取的一格)
MIX = {"id": "mix", "name": "销售额加首组名称字数", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'gmv') + len(vars.res.rows[0][0])"}
#: 数据整形解析第二份查询结果的两种写法。原样解析(模板整串就是 {{ nodes.regions }})的,vars.res.rows[0][0] 确定地对到
#: 那份快照的第 0 行第 0 列,卡上记成 cell() 一样的单元格取数(engine/lineage.py);模板带了过滤器的不算原样解析,按位置
#: 取的格卡上只记路径和值,遮罩顺着取值链认(_positional_masks)——下面几个测试守的是后一条路
PARSED = "{{ nodes.regions }}"
FILTERED = "{{ nodes.regions | trim }}"
def with_regions(spec: dict, source: str, template: str = FILTERED) -> dict:
"""取数之后另查一次按地区分组的结果(第二份快照),数据整形把它解析进 vars.res:口径卡按位置取它的格。"""
nodes = list(spec["nodes"])
at = next(i for i, n in enumerate(nodes) if n["id"] == "fetch") + 1
nodes[at:at] = [node("regions", "tool", tool=f"db_query__{source}", args={"sql": BY_REGION}),
node("parse", "transform", mode="json", template=template, assign_to="res")]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def test_a_masked_cell_read_by_position_from_another_snapshot_is_not_blind_computed(monkeypatch, masked_shop):
"""口径卡上一个指标:cell() 取第一次查询(A,不碰遮罩列)的销售额,另按位置取第二次查询(B,按遮罩列 region 分组)的
首组名称(vars.res.rows[0][0])。B 不是 cell() 用到的快照:以前预检只为 A 算遮罩,认不出按位置取的那一格在遮罩列上,
指标照常盲算,「华东」作为字面量写进了交给盲算模型的计算式。现在按需算 B 的遮罩:这个指标按无法复算记缺口、不盲算,
遮罩列的取值不进盲算的任务、节点产出和节点发的事件。"""
name, _ = masked_shop
blind = Blind(monkeypatch, name, {"销售额加首组名称字数": (RIGHT, "gmv")})
run = await start(with_regions(graph(name, RIGHT, metrics=[MIX], dual={"definitions": {}}), name))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
[row] = out["metrics"]
assert row["metric_id"] == "mix" and row["status"] == "unchecked", row
# 和 cell() 取自遮罩列的说法一致,点名是哪个输入:指标有好几个输入时审核人才找得到是哪一格
assert row["reason"] == "输入 vars.res.rows[0][0] 取自设置了遮罩的列,不能用于复算", row["reason"]
assert out["status"] == "unchecked" and out["issuance_gaps"] == [GAP_UNCHECKED.format(name=MIX["name"],
reason=row["reason"])]
assert blind.tasks == [] and blind.runs == 0, blind.tasks
assert "华东" not in json.dumps(out, ensure_ascii=False)
assert not [e.type for e in evs if e.node_id == "dual" and not e.type.startswith(("tool.", "llm."))
and "华东" in json.dumps(e.data, ensure_ascii=False)]
async def test_an_unmasked_cell_read_by_position_from_another_snapshot_is_blind_computed(monkeypatch, shop):
"""同样的图、数据源没设遮罩:预检照常通过,指标进入盲算(按位置取的那一格作为字面量写进计算式),两边一致。拦下上面
那个指标的确实是遮罩。"""
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额加首组名称字数": (RIGHT, "gmv")})
run = await start(with_regions(graph(name, RIGHT, metrics=[MIX], dual={"definitions": {}}), name))
assert run.status == "succeeded", run.error
out = dual_output(run, await events(run.id))
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed", out
[task] = blind.tasks
assert "len('华东')" in task
async def test_a_cell_read_through_a_plain_parse_is_judged_like_cell(monkeypatch, masked_shop, shop):
"""数据整形只是原样解析第二次查询的结果:vars.res.rows[0][0] 卡上记成单元格取数(落在第二份快照的第 0 行 region 列),
和 cell(nodes.regions, 0, 'region') 一个说法——遮罩列上的照实说是遮罩、不盲算、取值不进任何产出;没遮罩时它是查询来
的文本,和 cell() 取的文本格一样不能单独复算,照实说不是数值。"""
for (name, _), reason in ((masked_shop, "输入 vars.res.rows[0][0] 取自设置了遮罩的列,不能用于复算"),
(shop, "输入 vars.res.rows[0][0] 不是数值")):
blind = Blind(monkeypatch, name, {"销售额加首组名称字数": (RIGHT, "gmv")})
run = await start(with_regions(graph(name, RIGHT, metrics=[MIX], dual={"definitions": {}}), name, PARSED))
assert run.status == "succeeded", run.error
evs = await events(run.id)
[card] = [e.data for e in evs if e.type == "node.finished" and e.node_id == "card"]
item = next(i for i in artifact_store.load(card["artifact"])["metrics"][0]["inputs"]
if i["path"] == "vars.res.rows[0][0]")
assert (item["via"], item["through"], item["node_id"], item["locator"]) == (
"tool_cell", "parse", "regions", {"row": 0, "column": "region"}), item
out = dual_output(run, evs)
[row] = out["metrics"]
assert row["status"] == "unchecked" and row["reason"] == reason, row
assert blind.tasks == [] and blind.runs == 0, blind.tasks
assert "华东" not in json.dumps(out, ensure_ascii=False)
#: 按位置取的那一格:数据整形解析好的第二份查询结果(首组地区名),快照 id 是编的
POSITIONAL = {"path": "vars.res.rows[0][0]", "value": "华东", "node_id": "parse", "via": "transform", "status": "ok"}
LOST = "cd" * 32
def parsed_regions(source: str) -> dict:
return {"columns": ["region", "gmv"], "rows": [["华东", 300.0], ["华北", 30.0]], "artifact": LOST, "source": source}
async def test_a_cell_read_by_position_from_an_unreadable_snapshot_is_not_trusted(masked_shop):
"""按位置取的那一格所在的快照读不出来(被改过、文件不在了):查询当时记下的遮罩无从核对。数据源现在设的遮罩遮着这一列
的,照遮罩算(交给重算的预检按遮罩说);没遮的也不当成没遮,按无法复算记、原因说清。"""
from app.engine.nodes.dual_check import _positional_masks, _Sources
name, _ = masked_shop
for source, expected in ((name, "输入 vars.res.rows[0][0] 取自设置了遮罩的列,不能用于复算"),
("dc_no_such_source", "输入 vars.res.rows[0][0] 所在的查询快照与哈希不一致,疑似被修改,"
"无法核对这一格是否取自设置了遮罩的列")):
sources = _Sources()
sources.snapshots[LOST] = (None, "查询快照与哈希不一致,疑似被修改")
masks: dict = {}
state = {"state:parse": parsed_regions(source)}
why, shown = await _positional_masks(POSITIONAL, sources, state.get, {"parse": "state:parse"}, masks)
assert (why, shown) == (expected, False), source
assert ("region" in masks[LOST]) is (source == name), masks
async def test_a_cell_whose_origin_cannot_be_told_is_not_written_into_the_formula(masked_shop):
"""按位置取的一格认不出取自哪份结果的哪一列——下标不是字面量(rows[vars.i])、数据整形改过形状的结果既没带快照的 id
也没带数据源名——遮没遮无从核对:计算式里写成「其他输入」,不把卡上记的值交给盲算模型。认得出、又没遮的照常写值;
不伸进查询结果的行的输入(运行输入、常量)也照常写。"""
from app.engine.nodes.dual_check import _formula, _positional_masks, _Sources
name, _ = masked_shop
reshaped = {"columns": ["region", "gmv"], "rows": [["华东", 300.0], ["华北", 30.0]]}
cases = [
({"path": "vars.res.rows[vars.i][0]", "value": "华东", "node_id": "parse", "via": "transform"},
{**reshaped, "artifact": LOST, "source": name}, False),
({**POSITIONAL}, reshaped, False),
({"path": "vars.limit", "value": 3, "node_id": "parse", "via": "transform"}, reshaped, True),
]
for item, parsed, expected in cases:
state = {"state:parse": parsed}
why, shown = await _positional_masks(item, _Sources(), state.get, {"parse": "state:parse"}, {})
assert (why, shown) == (None, expected), item["path"]
metric = {"expression": "cell(nodes.fetch, 0, 'gmv') + len(vars.res.rows[vars.i][0]) + vars.limit",
"inputs": [{"path": "cell(nodes.fetch, 0, 'gmv')", "value": 330.0},
{"path": "vars.res.rows[vars.i][0]", "value": "华东"}, {"path": "vars.i", "value": 0},
{"path": "vars.limit", "value": 3}]}
formula = _formula(metric, {"cell(nodes.fetch, 0, 'gmv')": "输入1"}, {"vars.res.rows[vars.i][0]"})
assert formula == "输入1 + len(其他输入) + 3", formula
#: 销售额,加上第二份快照的行数(容器)和首组的销售额(按位置取的一格,不在遮罩列上)
MIX_COUNTS = {"id": "mix", "name": "销售额加地区数和首组销售额", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'gmv') + len(vars.res.rows) + vars.res.rows[0][1]"}
async def test_reading_a_cell_by_position_does_not_change_how_a_container_input_is_judged(monkeypatch, masked_shop):
"""按位置取的那一格在第二份快照(B)的没遮的列上,同一个指标另用了 B 的行数(容器,计算式里写成「其他输入」,值不交给
盲算)。按位置取的那一格只为认遮罩算 B 的遮罩,不把 B 当成 cell() 用到的快照:以前 B 因此进了重算的快照清单,容器的
完整值对上了 B,B 有一列遮罩就整个按「用到了遮罩列」挡下——只写 len(vars.res.rows) 能核、加上这一格就不能核了。"""
name, _ = masked_shop
blind = Blind(monkeypatch, name, {MIX_COUNTS["name"]: (RIGHT, "gmv")})
run = await start(with_regions(graph(name, RIGHT, metrics=[MIX_COUNTS], dual={"definitions": {}}), name))
assert run.status == "succeeded", run.error
out = dual_output(run, await events(run.id))
assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed", out
[task] = blind.tasks
assert "len(其他输入) + 300.0" in task and "华东" not in task, task
def _no_masked_value(payload: dict, card: dict, done: Run, evs: list[RunEvent], value: str = "华东") -> None:
"""遮罩列上的取值不出现在审批 payload、对照工件、节点产出和节点发的事件里。查询本身的 tool.end、模型的往来按
设计带着原始结果(证据面板显示时再遮),不在此列。"""
seen = {"payload": payload, "card": card, "output": dual_output(done, evs),
"events": [e.data for e in evs if (e.node_id == "dual" or e.type.startswith("human.") or e.type == "log")
and not e.type.startswith(("tool.", "llm."))]}
for where, content in seen.items():
assert value not in json.dumps(content, ensure_ascii=False), where
# --------------------------------------------------------------------------
# 设置:全局开关、节点覆盖、发起时的快照
# --------------------------------------------------------------------------
async def save_verify(values: dict) -> None:
async with SessionLocal() as session:
row = await session.get(Setting, "verify")
if row is None:
session.add(Setting(key="verify", value=values))
else:
row.value = values
await session.commit()
async def launch(spec: dict) -> Run:
async with SessionLocal() as session:
run = await launch_run(session, graph=spec, actor="测试")
return await wait(run.id)
async def test_globally_disabled_passes_through(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
await save_verify({"dual_check": {"enabled": False}})
run = await launch(graph(name, BY_ORDER_DATE))
assert run.status == "succeeded", run.error
assert run.options["verify"]["dual_check"]["enabled"] is False
evs = await events(run.id)
assert dual_output(run, evs) == {"status": "skipped", "reason": "disabled"}
assert [d["reason"] for d in logs(evs, "dual_check_skipped")] == ["disabled"]
assert blind.calls == [] and run.output["_issuance"]["tier"] == "formal"
async def test_the_node_can_override_the_global_switch(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
await save_verify({"dual_check": {"enabled": False}})
run = await launch(graph(name, RIGHT, dual={"enabled": True}))
assert run.status == "succeeded" and dual_output(run, await events(run.id))["status"] == "agreed"
assert blind.runs == 1
await save_verify({"dual_check": {"enabled": True}})
off = await launch(graph(name, RIGHT, dual={"enabled": False}))
assert dual_output(off, await events(off.id)) == {"status": "skipped", "reason": "disabled"}
assert blind.runs == 1
async def test_no_query_inputs_pass_through(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {})
constant = {"id": "target", "name": "目标", "unit": "元", "expression": "300"}
run = await start(graph(name, RIGHT, metrics=[constant], dual={"definitions": {}}))
evs = await events(run.id)
assert dual_output(run, evs) == {"status": "skipped", "reason": "no_inputs"}
assert [d["reason"] for d in logs(evs, "dual_check_skipped")] == ["no_inputs"] and blind.calls == []
assert run.output["_issuance"]["tier"] == "formal" and run.output["_issuance"]["gaps"] == []
async def test_settings_changed_while_waiting_do_not_apply(monkeypatch, shop, client):
"""发起时是开着的:停在审批上的时候把双盲复算关了,恢复后照旧按发起时的设置裁决,不会走成直接放行。"""
name, _ = shop
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await launch(graph(name, BY_ORDER_DATE))
assert run.status == "interrupted" and run.options["verify"]["dual_check"]["enabled"] is True
await save_verify({"dual_check": {"enabled": False}})
done = await decide(client, run.id, {"gmv": "b"})
assert done.status == "succeeded", done.error
evs = await events(run.id)
out = dual_output(done, evs)
assert out["status"] == "resolved" and out["issuance_gaps"]
assert not logs(evs, "dual_check_skipped")
started = [e.data for e in evs if e.type == "run.started"]
assert len(started) == 2 and all(s["options"]["verify"]["dual_check"]["enabled"] is True for s in started)
async def test_the_node_reads_the_snapshot_not_the_current_settings(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE), options={"verify": {"dual_check": {"enabled": False}}})
assert dual_output(run, await events(run.id)) == {"status": "skipped", "reason": "disabled"}
assert blind.calls == []
# --------------------------------------------------------------------------
# 重放、写作目录
# --------------------------------------------------------------------------
async def test_resuming_does_not_run_the_blind_agent_again(monkeypatch, shop, client):
"""照 test_replay_side_effects:恢复后节点从头重放,盲算 agent 不再执行,事件不重复。"""
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
run = await start(graph(name, BY_ORDER_DATE))
calls_before = list(blind.calls)
before = await events(run.id)
verify_starts = [e for e in before if e.type == "tool.start" and e.node_id == "dual"]
llm_ends = [e for e in before if e.type == "llm.end" and e.node_id == "dual"]
assert verify_starts and llm_ends
done = await decide(client, run.id, {"gmv": "a"})
assert done.status == "succeeded", done.error
assert blind.calls == calls_before, f"恢复之后盲算又调用了模型:{blind.calls[len(calls_before):]}"
evs = await events(run.id)
assert [e.seq for e in evs if e.type == "tool.start" and e.node_id == "dual"] == [e.seq for e in verify_starts]
assert [e.seq for e in evs if e.type == "llm.end" and e.node_id == "dual"] == [e.seq for e in llm_ends]
for code in ("dual_check_disagreed", "dual_check_resolved", "dual_check_catalog"):
assert len(logs(evs, code)) == 1, code
assert len([e for e in evs if e.type == "human.requested"]) == 1
assert len([e for e in evs if e.type == "human.resolved"]) == 1
assert len([e for e in evs if e.type == "node.finished" and e.node_id == "dual"]) == 1
async def test_the_report_catalog_leaves_out_the_blind_queries(monkeypatch, shop):
name, _ = shop
blind = Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")}, writer="本周销售额 [[m:gmv]]。")
run = await start(graph(name, RIGHT, report=True))
assert run.status == "succeeded", run.error
[prompt] = blind.writer_prompts
assert "blind_total" not in prompt and "Q2" not in prompt, "盲算的查询进了报告的写作目录"
assert "Q1" in prompt and "m:gmv" in prompt
evs = await events(run.id)
blind_queries = [e.data["query_artifact"] for e in evs
if e.type == "tool.end" and e.node_id == "dual" and e.data.get("query_artifact")]
ledger = [entry for e in evs if e.type == "node.finished" for entry in e.data.get("evidence") or []]
assert blind_queries and not any(entry.get("artifact") in blind_queries for entry in ledger)
# --------------------------------------------------------------------------
# 查询区间里这张表没有数据(data/coverage.py):不判一致,按无法复算记
# --------------------------------------------------------------------------
#: 订单表只有 2026 年的数:2030 年那一周表里没有数据,COUNT 是 0
EMPTY_WEEK = "BETWEEN '2030-01-07' AND '2030-01-13'"
NO_ORDERS = f"SELECT COUNT(*) AS orders FROM orders WHERE status <> 'refunded' AND paid_date {EMPTY_WEEK}"
ORDERS = {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}
async def test_an_original_cell_without_data_is_not_checked(monkeypatch, shop):
"""原值所在的格子落在 no_data 的列上:那个 0 不代表真的是 0,盲算查到 0 也说明不了一致。不派盲算,按无法复算记。"""
name, _ = shop
blind = Blind(monkeypatch, name, {"订单数": (NO_ORDERS, "orders")})
run = await start(graph(name, NO_ORDERS, metrics=[ORDERS], dual={"definitions": {"orders": "已支付订单数"}}))
assert run.status == "succeeded", run.error
evs = await events(run.id)
out = dual_output(run, evs)
[row] = out["metrics"]
assert row["status"] == "unchecked" and row["reason"].startswith("查询区间里这张表没有数据(输入 cell(nodes.fetch")
assert "2030-01-07 到 2030-01-13" in row["reason"]
assert "step" not in blind.calls, "原值就没有数据:不白派一次盲算"
assert out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 1}
async def test_a_blind_cell_without_data_is_not_agreed(monkeypatch, shop):
"""盲算交回的格子落在 no_data 的列上(它查了一段表里没有数据的区间):不判一致,按无法复算记。"""
name, _ = shop
buyers = {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}
original = f"SELECT COUNT(*) AS orders FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}"
Blind(monkeypatch, name, {"订单数": (NO_ORDERS.replace("AS orders", "AS n"), "n")})
run = await start(graph(name, original, metrics=[buyers], dual={"definitions": {"orders": "已支付订单数"}}))
assert run.status == "succeeded", run.error
[row] = dual_output(run, await events(run.id))["metrics"]
assert row["status"] == "unchecked", row
assert row["reason"].startswith("查询区间里这张表没有数据(盲算查到的那一格:「n」查的是 2030-01-07 到 2030-01-13")