From ff23c6a8ad49bc153238752963b0a1fc16379c1d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:23:00 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=8F=91=E8=B5=B7=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E8=AE=BE=E7=BD=AE=E5=BF=AB=E7=85=A7=EF=BC=9A=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E6=A0=A1=E9=AA=8C=E3=80=81=E6=A0=B8=E6=9F=A5=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=BF=AB=E7=85=A7=E8=BF=9B=E8=BF=90=E8=A1=8C=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E3=80=81=E5=8A=A9=E6=89=8B=E8=8A=82=E7=82=B9=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 画布校验(engine/schema.py):「口径卡」必须是上游的口径卡,没写时上游只能有一张(dual_check_card,节点执行也用这一个认法); 「复算的指标」「口径说明」的键必须是那张卡上的指标 ID(引用自别的工作流的口径卡画图时查不出,不查);开关、允许的相对误差、 未采用原流程时的取值。报错写检查器上的中文叫法,叫法暂放在 DUAL_CHECK_FIELD_LABEL(labels.py 不归这个工作包)。 - 发起(engine/launch.py):图里有双盲复算或反方审稿节点时,把核查设置快照进 options["verify"]。这两种节点在运行中途读设置, 审批停下期间改了设置,恢复后的那一段就会按另一套规则走;发起时定下来,重放和正式运行才可复现。没有这两种节点时 options 原样不动,run.started 和以前逐字相同;调用方已经给了 options["verify"] 的照它的。 - 助手的节点说明(NODE_REFERENCE)加 dual_check 一条:要正式出具、口径卡上的指标来自查询时接在口径卡之后、报告之前,各配置项 怎么写(口径说明按业务写、盲算模型默认不写)。 --- backend/app/api/copilot.py | 11 ++ backend/app/engine/launch.py | 30 +++- backend/app/engine/schema.py | 93 +++++++++++ backend/tests/test_dual_check_config.py | 202 ++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_dual_check_config.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 6b849d96..fdb042d6 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -214,6 +214,17 @@ NODE_REFERENCE = """\ 写 2 只剩「2%」,0.004 这样的小比率还会显示不出来、报告引用不了。on_missing 默认 fail(缺输入整张卡失败);写 null 则缺输入的指标记为空值,交给出具契约判档 取 tool 节点查库的结果用 cell(nodes.取数节点id, 行, '列名')(行号从 0 数,列按列名):cell() 直接认查询工具 交回的结果,出处精确到快照里的那一格,不用再接 transform 解析。取 agent 的字段写 vars..字段 +- dual_check:双盲复算。正式出具前,口径卡上输入来自查询的指标另起一个看不到原 SQL 的只读 agent 独立写 SQL 再算一遍: + 一致就放行;不一致时停下来,审批卡并排列出两边的 SQL、差异说明和两个值,由人逐个指标选采用原流程、采用盲算还是 + 两边都不对。选盲算或都不对的计入缺口、出具降档;两边只差一个方面(业务日期按哪一列、有效记录条件、去重)时,选择 + 会写进数据目录。**要正式出具的模板、口径卡上的指标来自查询(tool 节点查库,或 agent 查库并开了 cite_fields)时加**, + 接在口径卡之后、report 之前(取数 → 口径卡 → dual_check → report → output);只回答一个问题的问数据、指标都是 + 常量或运行输入的不必加。config: {card, metrics, definitions, provider, model, tolerance, on_reject, enabled}: + card 是要复算的口径卡节点 id(上游只有一张口径卡时不写);metrics 是要复算的指标 id 列表(不写就是输入来自查询的 + 全部指标);definitions 是 {指标 id: 口径说明},写清业务定义(按哪个日期归属、排除哪些行、是否去重),盲算 agent + 只看得到它和数据目录,所以要按业务写,不要抄原 SQL;provider / model 指定盲算模型(**默认不写**,取设置里的核查 + 模型;建议和取数的 agent 不同);tolerance 是允许的相对误差(不写取设置);on_reject:degrade(默认,计入缺口、 + 出具降档)/ fail(审核人没采用原流程的结果就判为失败);enabled 不写跟随设置里的开关,true / false 强制开关 - report:报告撰写(带引用)。config: {instructions, system, metrics_from:[口径卡id], numbers, on_violation, max_repairs, claims, entities, assign_to} **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游的口径卡指标、查询结果 diff --git a/backend/app/engine/launch.py b/backend/app/engine/launch.py index 087be199..0f05c1c0 100644 --- a/backend/app/engine/launch.py +++ b/backend/app/engine/launch.py @@ -60,7 +60,8 @@ async def launch_run( 运行也没有开始执行。 参数和 POST /api/runs 的请求体一一对应(input 叫 input_payload);actor 是记在运行上的发起人。 - schedule_id 是发起它的定时任务,options 是整次运行沿用的选项快照,都原样交给 run_manager.start。 + schedule_id 是发起它的定时任务,options 是整次运行沿用的选项快照,都原样交给 run_manager.start;图里有双盲复算、 + 反方审稿节点时 options 另加核查设置的快照(_with_verify_snapshot)。 """ # 没挂在工作流上(画布没保存就发起、问数据当场搭的)。老数据里是「临时图」,前端两种都认 name = "未保存的工作流" @@ -135,6 +136,7 @@ async def launch_run( if not graph: raise LaunchRefused(400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容") await _refuse_missing_tools(session, graph) + options = await _with_verify_snapshot(session, graph, options) from app.api.settings import resolve_run_scope @@ -178,6 +180,32 @@ async def launch_run( return run +#: 运行中途要读核查设置的节点:双盲复算、反方审稿 +_VERIFY_NODES = (NodeType.DUAL_CHECK, NodeType.CHALLENGE) + + +async def _with_verify_snapshot(session: AsyncSession, graph: dict[str, Any], + options: dict[str, Any] | None) -> dict[str, Any] | None: + """图里有双盲复算或反方审稿节点时,把核查设置(api/settings.py 的 verify 一组)快照进 options["verify"]。 + + 这两种节点在运行中途读设置(开关、上限、核查模型)。审批停下期间有人改了设置,恢复后的那一段读到的就和前面 + 不一样:本该复算的被关掉了、重放时走了另一条路,正式运行也说不清当时按什么规则核的。所以在发起这一刻定下来, + 节点只读运行选项里的这一份(同 _approval_default 的道理)。没有这两种节点的图 options 原样不动,run.started + 和以前逐字相同;调用方已经给了 options["verify"] 的照它的。 + """ + from app.api.settings import VERIFY_GROUP, verify_settings + + if isinstance(options, dict) and VERIFY_GROUP in options: + return options + try: + spec = GraphSpec.model_validate(graph) + except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报 + return options + if not any(node.type in _VERIFY_NODES for node in spec.nodes): + return options + return {**(options or {}), VERIFY_GROUP: await verify_settings(session)} + + # -------------------------------------------------------------------------- # 发起前检查绑定的工具 # -------------------------------------------------------------------------- diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index d3ae6325..306f5a2a 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -425,6 +425,97 @@ def _check_report_sources(node: GraphNode, spec: GraphSpec, result: ValidationRe node_id=node.id, field="metrics_from", code=code) +#: 双盲复算节点各配置项在检查器上的叫法(前端 nodeDefs 的 dual_check 字段)。按 labels.py 的规矩应放在那里, +#: 那个文件不归双盲复算的工作包,先放在校验旁边,节点执行时报错也从这里取 +DUAL_CHECK_FIELD_LABEL = { + "card": "口径卡", "metrics": "复算的指标", "definitions": "口径说明", "provider": "盲算模型 · 接入", + "model": "盲算模型", "tolerance": "允许的相对误差", "on_reject": "未采用原流程时", "enabled": "启用双盲复算", +} +#: 审核人没有采用原流程的结果(选了盲算,或者判断两边都不对)时:degrade 计入缺口、出具降档(默认)/ fail 判为失败 +DUAL_CHECK_ON_REJECT = ("degrade", "fail") +DUAL_CHECK_ON_REJECT_LABEL = {"degrade": "计入缺口、出具降档", "fail": "判为失败"} + + +def dual_check_card(spec: GraphSpec, node: GraphNode) -> tuple[str | None, str | None]: + """双盲复算要复算的口径卡:(节点 id, None) 或 (None, 为什么认不出)。写了「口径卡」(card)就是它,否则取上游唯一的 + 那张口径卡。校验和节点执行用的是这一个认法;写了的那个节点存不存在、是不是口径卡、在不在上游由调用方查。""" + card = node.config.get("card") + if card not in (None, ""): + return (card, None) if isinstance(card, str) else (None, f"「{DUAL_CHECK_FIELD_LABEL['card']}」需要选择一个口径卡节点") + upstream = _ancestors(spec, node.id) + cards = [n for n in spec.nodes if n.id in upstream and n.type == NodeType.METRICS] + if not cards: + return None, "「双盲复算」的上游没有口径卡:它复算的是口径卡上的指标,请把它接在口径卡之后" + if len(cards) > 1: + names = "、".join(f"「{n.title}」" for n in cards[:5]) + return None, (f"「双盲复算」的上游有 {len(cards)} 张口径卡({names})," + f"请在「{DUAL_CHECK_FIELD_LABEL['card']}」中选择要复算的那一张") + return cards[0].id, None + + +def _check_dual_check(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None: + """双盲复算的配置:口径卡必须是它上游的口径卡;复算的指标、口径说明的键必须是那张卡上的指标 ID;开关、 + 误差、处置的取值。指标定义从别的工作流引用来的口径卡(caliber_from),画图时不知道有哪些指标,不查指标 ID。""" + cfg = node.config + label = DUAL_CHECK_FIELD_LABEL + + def add(message: str, field: str, code: str) -> None: + result.add(message, node_id=node.id, field=field, code=code) + + if cfg.get("enabled") not in (None, True, False): + add(f"「{label['enabled']}」只能选跟随设置、开启或关闭,当前为「{cfg.get('enabled')}」", "enabled", + "dual_check.option_invalid") + if cfg.get("on_reject") not in (None, "", *DUAL_CHECK_ON_REJECT): + allowed = "或".join(f"「{v}」" for v in DUAL_CHECK_ON_REJECT_LABEL.values()) + add(f"「{label['on_reject']}」只能选{allowed},当前为「{cfg.get('on_reject')}」", "on_reject", + "dual_check.option_invalid") + tolerance = cfg.get("tolerance") + if tolerance not in (None, "") and (isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) + or not math.isfinite(tolerance) or tolerance < 0): + add(f"「{label['tolerance']}」需要填写不小于 0 的数(例如 0.000001 表示百万分之一),留空表示按设置," + f"当前为「{tolerance}」", "tolerance", "dual_check.option_invalid") + + card_id, problem = dual_check_card(spec, node) + if problem: + add(problem, "card", "dual_check.card_invalid") + return + target = spec.node_map().get(card_id or "") + if target is None: + add(f"「{label['card']}」选择了「{card_id}」,但工作流中不存在这个节点", "card", "dual_check.card_invalid") + return + if target.type != NodeType.METRICS: + add(f"「{label['card']}」选择了「{target.title}」,它是「{type_label(target.type)}」节点,不是口径卡;" + "双盲复算只能复算口径卡上的指标", "card", "dual_check.card_invalid") + return + if target.id not in _ancestors(spec, node.id): + add(f"「{label['card']}」选择的口径卡「{target.title}」不在双盲复算的上游:复算时这张卡还没有计算。" + "请把口径卡连到双盲复算之前", "card", "dual_check.card_invalid") + return + if target.config.get("caliber_from") not in (None, "", {}): + known = None + else: + known = [str(d.get("id")) for d in target.config.get("metrics") or [] if isinstance(d, dict) and d.get("id")] + hint = (",可选的指标 ID:" + "、".join(known[:12])) if known else "" + + metrics = cfg.get("metrics") + if metrics not in (None, "", []): + if not isinstance(metrics, list) or not all(isinstance(m, str) and m.strip() for m in metrics): + add(f"「{label['metrics']}」需要填写口径卡上的指标 ID 列表,留空表示复算输入来自查询的全部指标", "metrics", + "dual_check.metrics_invalid") + elif known is not None and (unknown := [m for m in metrics if m not in known]): + add(f"「{label['metrics']}」中的「{'、'.join(unknown[:5])}」不是口径卡「{target.title}」上的指标 ID{hint}", + "metrics", "dual_check.metrics_invalid") + definitions = cfg.get("definitions") + if definitions not in (None, "", {}): + if not isinstance(definitions, dict) or not all(isinstance(k, str) and isinstance(v, str) + for k, v in definitions.items()): + add(f"「{label['definitions']}」需要写成「指标 ID → 口径说明」的对照,口径说明写成文字", "definitions", + "dual_check.definitions_invalid") + elif known is not None and (unknown := [k for k in definitions if k not in known]): + add(f"「{label['definitions']}」中的「{'、'.join(unknown[:5])}」不是口径卡「{target.title}」上的指标 ID{hint}", + "definitions", "dual_check.definitions_invalid") + + def _produces_query(node: GraphNode) -> bool: """这个节点的产出是一份存成快照的查询结果:调用数据库查询工具的「调用工具」节点,或另一个合并查询。""" if node.type == NodeType.MERGE: @@ -674,6 +765,8 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if not d.get("id") or not d.get("expression"): result.add(f"指标定义缺少 ID 或表达式:{d.get('id') or '(空)'}", node_id=node.id, field=f"metrics[{i}]") + elif node.type == NodeType.DUAL_CHECK: + _check_dual_check(node, spec, result) elif node.type == NodeType.REPORT: _check_report_sources(node, spec, result) _check_report_claims(node, spec, result) diff --git a/backend/tests/test_dual_check_config.py b/backend/tests/test_dual_check_config.py new file mode 100644 index 00000000..c88696bb --- /dev/null +++ b/backend/tests/test_dual_check_config.py @@ -0,0 +1,202 @@ +"""双盲复算的配置、发起时的设置快照、助手的节点说明。 + +- 画布校验(engine/schema.py):口径卡必须是上游的口径卡(没写时上游只能有一张),复算的指标、口径说明的键必须是 + 那张卡上的指标 ID,开关、误差、处置的取值;报错写检查器上的中文叫法 +- 发起(engine/launch.py):图里有双盲复算或反方审稿节点时把核查设置快照进 options["verify"],没有时 options 不变、 + run.started 和以前逐字相同;调用方给了的照它的 +- 助手的节点说明(api/copilot.py 的 NODE_REFERENCE)有 dual_check 一条:什么时候加、各配置项 + +表名、数字都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import delete, select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Setting +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec, validate_graph + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +CARD = node("card", "metrics", "销售口径卡", caliber="销售口径", metrics=[ + {"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "orders", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'n')"}]) + + +def spec(*middle: dict, dual: dict | None = None, edges: list | None = None) -> dict: + nodes = [node("start", "input"), node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT 1"}), *middle, + node("dual", "dual_check", "复核", **(dual or {})), node("out", "output", fields=[])] + chain = edges or [(a["id"], b["id"]) for a, b in zip(nodes, nodes[1:])] + return {"nodes": nodes, "edges": [{"source": s, "target": t} for s, t in chain]} + + +def problems(graph: dict) -> list[tuple[str | None, str]]: + report = validate_graph(GraphSpec.model_validate(graph)) + return [(i.field, i.message) for i in report.issues if i.level == "error" and i.node_id == "dual"] + + +# -------------------------------------------------------------------------- +# 画布校验 +# -------------------------------------------------------------------------- + + +def test_a_valid_config_has_no_problems(): + assert problems(spec(CARD, dual={"card": "card", "metrics": ["gmv"], "definitions": {"gmv": "按支付日期,不含退款"}, + "tolerance": 0.001, "on_reject": "fail", "enabled": True})) == [] + # 不写口径卡:上游只有一张,就是它 + assert problems(spec(CARD)) == [] + + +def test_the_card_must_be_an_upstream_metrics_card(): + [(where, text)] = problems(spec(CARD, dual={"card": "fetch"})) + assert where == "card" and "「口径卡」选择了「fetch」" in text and "不是口径卡" in text + [(where, text)] = problems(spec(CARD, dual={"card": "ghost"})) + assert where == "card" and "不存在这个节点" in text + # 口径卡接在双盲复算后面:复算时它还没算 + nodes = [node("start", "input"), node("dual", "dual_check", "复核", card="card"), CARD] + graph = {"nodes": nodes, "edges": [{"source": "start", "target": "dual"}, {"source": "dual", "target": "card"}]} + [(where, text)] = problems(graph) + assert where == "card" and "不在双盲复算的上游" in text + + +def test_without_a_card_there_must_be_exactly_one_upstream(): + [(where, text)] = problems(spec()) + assert where == "card" and "上游没有口径卡" in text + second = node("card2", "metrics", "利润口径卡", metrics=[{"id": "profit", "expression": "1"}]) + [(where, text)] = problems(spec(CARD, second)) + assert where == "card" and "2 张口径卡" in text and "「销售口径卡」" in text and "「利润口径卡」" in text + assert problems(spec(CARD, second, dual={"card": "card2"})) == [] + + +def test_metrics_and_definitions_must_be_on_the_card(): + found = dict(problems(spec(CARD, dual={"metrics": ["gmv", "refund"], + "definitions": {"gmv": "按支付日", "cost": "成本"}}))) + assert "「复算的指标」中的「refund」不是口径卡「销售口径卡」上的指标 ID" in found["metrics"] + assert "可选的指标 ID:gmv、orders" in found["metrics"] + assert "「口径说明」中的「cost」不是口径卡「销售口径卡」上的指标 ID" in found["definitions"] + [(where, text)] = problems(spec(CARD, dual={"definitions": ["gmv"]})) + assert where == "definitions" and "指标 ID → 口径说明" in text + + +def test_a_card_pinned_elsewhere_is_not_checked_for_metric_ids(): + pinned = node("card", "metrics", "引用的口径卡", caliber_from={"workflow_id": "w", "workflow_version": 1, "node_id": "c"}) + assert problems(spec(pinned, dual={"definitions": {"anything": "说明"}})) == [] + + +def test_option_values_are_checked(): + found = dict(problems(spec(CARD, dual={"enabled": "yes", "on_reject": "ignore", "tolerance": -1}))) + assert "「启用双盲复算」" in found["enabled"] + assert "「未采用原流程时」只能选「计入缺口、出具降档」或「判为失败」" in found["on_reject"] + assert "「允许的相对误差」需要填写不小于 0 的数" in found["tolerance"] + + +# -------------------------------------------------------------------------- +# 发起:设置快照 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine(): + await run_manager.setup() + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key == "verify")) + await session.commit() + yield + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key == "verify")) + await session.commit() + await run_manager.shutdown() + + +async def settle(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.03) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + await run_manager.wait_idle(run_id) + return row + raise AssertionError(f"运行 {run_id} 停在 {row.status}") + + +async def started_of(run_id: str) -> dict: + async with SessionLocal() as session: + return (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "run.started"))).scalars().first().data + + +PLAIN = {"nodes": [node("start", "input"), node("out", "output", fields=[{"name": "r", "value": "完成"}])], + "edges": [{"source": "start", "target": "out"}]} +GATED = {"nodes": [node("start", "input"), node("card", "metrics", metrics=[{"id": "x", "expression": "1"}]), + node("dual", "dual_check"), node("out", "output", fields=[])], + "edges": [{"source": "start", "target": "card"}, {"source": "card", "target": "dual"}, + {"source": "dual", "target": "out"}]} + + +async def test_a_graph_with_a_verify_node_snapshots_the_settings(engine): + from app.api.settings import verify_settings + from app.engine.launch import launch_run + + async with SessionLocal() as session: + session.add(Setting(key="verify", value={"dual_check": {"tolerance": 0.01}, "daily_max_usd": 3})) + await session.commit() + expected = await verify_settings(session) + run = await launch_run(session, graph=GATED, actor="测试") + assert expected["dual_check"]["tolerance"] == 0.01 and run.options == {"verify": expected} + row = await settle(run.id) + assert row.status == "succeeded", row.error + assert (await started_of(run.id))["options"] == {"verify": expected} + + +async def test_a_challenge_node_also_snapshots_the_settings(engine): + from app.engine.launch import _with_verify_snapshot + + graph = {"nodes": [node("start", "input"), node("review", "challenge")], "edges": []} + async with SessionLocal() as session: + out = await _with_verify_snapshot(session, graph, None) + assert set(out) == {"verify"} and out["verify"]["challenge"]["enabled"] is True + + +async def test_without_verify_nodes_options_and_run_started_are_unchanged(engine): + from app.engine.launch import launch_run + + async with SessionLocal() as session: + run = await launch_run(session, graph=PLAIN, actor="测试") + assert run.options == {} + await settle(run.id) + assert "options" not in await started_of(run.id) + + +async def test_options_given_by_the_caller_are_kept(engine): + from app.engine.launch import launch_run + + mine = {"verify": {"dual_check": {"enabled": False}}, "note": "例行"} + async with SessionLocal() as session: + run = await launch_run(session, graph=GATED, actor="测试", options=mine) + assert run.options == mine + await settle(run.id) + + +# -------------------------------------------------------------------------- +# 助手的节点说明 +# -------------------------------------------------------------------------- + + +def test_the_copilot_reference_explains_when_and_how_to_add_it(): + from app.api.copilot import NODE_REFERENCE + + assert "- dual_check:" in NODE_REFERENCE + block = NODE_REFERENCE.split("- dual_check:", 1)[1].split("\n- ", 1)[0] + for key in ("card", "metrics", "definitions", "provider", "model", "tolerance", "on_reject", "enabled"): + assert key in block, key + assert "正式出具" in block and "口径卡之后" in block and "degrade" in block and "fail" in block + # 排在口径卡和报告之间:搭图的先后就是这样 + at = NODE_REFERENCE.index + assert at("- metrics:") < at("- dual_check:") < at("- report:") From 77f3119cf94ed76119e79d137748f914ca6beabc Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:23:43 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E8=8A=82=E7=82=B9=EF=BC=9A=E6=8C=89=E8=BE=93=E5=85=A5=E7=9B=B2?= =?UTF-8?q?=E7=AE=97=E3=80=81=E4=BB=A3=E5=9B=9E=E5=8E=9F=E5=BC=8F=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=EF=BC=8C=E4=B8=8D=E4=B8=80=E8=87=B4=E4=BA=A4=E4=BA=BA?= =?UTF-8?q?=E8=A3=81=E5=86=B3=EF=BC=8C=E5=8F=AA=E5=B7=AE=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=96=B9=E9=9D=A2=E6=97=B6=E5=86=99=E8=BF=9B=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 占位的直通桩换成真实现(engine/nodes/dual_check.py): - 按输入复算:口径卡上输入来自查询快照的指标,每个这样的输入另起一个看不到原 SQL 的只读 agent(verify_agent),只给它指标名、 口径说明、这个输入在计算式里是哪一项(「输入1」,不露取数节点和 cell() 的行列)、分组结果那一行的维度列、数据源的数据目录; 它交一个格子引用,由代码从它自己的快照取值。盲值用原式代入盲算的输入算(engine/recompute.py,取整同口径卡),按相对误差比, 原值为 0 时按绝对误差。两个指标用到同一格只盲算一次。合并查询、整段结果、遮罩列、文本输入不能单独复算,记「无法复算」、不花钱。 - 一致不挂起,产出 agreed;不一致时逐个对不上的输入用 explain_sql_diff 说清差在哪(规范化后一致却值不同的写「差在数据」), 完整对照落成 card_artifact 工件(避开审批 payload 的截断),按约定的 payload 用 ask() 挂起。审核人选 a 无缺口,选 b、都不对 写 issuance_gaps,on_reject 为 fail 时节点失败;明确驳回按人工驳回失败;没选的按「都不对」。触顶、核查模型不可用、交不出数的 指标记「无法复算」并写缺口,核查模型本身用不了时后面的盲算不再一个个去试。 - 设置读发起时的快照,没快照的读一次记进 checkpoint;读设置、盲算连同比对、差异说明连同工件、写目录各在一个 @task 里,审批恢复 重放时不再调用模型、不再计费、不再写,事件也只发一次。日志按约定的 code 和结构化字段发(dual_check_skipped / same_model / agreed / disagreed / resolved / catalog / limit)。盲算的查询只发 tool.end(role: verify),台账只记一条 kind: dual_check, 报告的写作目录不含盲算的查询;用量计入节点。 选择写进数据目录(engine/dual_check_facts.py):只在审核人选 a 或 b、差异只有一条具体条目、又对得上目录事实时写——时间列不同记 业务日期,一边多一个只涉及一张表的筛选条件记有效记录条件,聚合只差去不去重记去重规则,都按被采纳那一边的写法。照对话提案的 规矩核对,记为人工填写、已确认,备注写明运行、节点、指标;已是同样的已确认说法不再写,确认过的不同说法不覆盖、照实交回原因。 测试:虚构零售库埋了退款行、下单日对支付日两个陷阱,盲算模型是 MockChatModel 剧本,覆盖一致、不一致挂起与 payload / 工件形状、 三种选择与 on_reject、写目录与幂等、不覆盖、全局关闭与节点覆盖、发起时快照、重放不再执行盲算、写作目录不含盲算查询、用量、 同模型提示、触顶、不能单独复算的输入。占位节点的旧测试只留反方审稿。 --- backend/app/engine/dual_check_facts.py | 308 ++++++ backend/app/engine/nodes/dual_check.py | 985 ++++++++++++++++++- backend/tests/fixtures/dual_check_shop.py | 145 +++ backend/tests/test_dual_check_executor.py | 160 +++ backend/tests/test_dual_check_facts.py | 198 ++++ backend/tests/test_dual_check_node.py | 571 +++++++++++ backend/tests/test_issuance_upstream_gaps.py | 5 +- 7 files changed, 2361 insertions(+), 11 deletions(-) create mode 100644 backend/app/engine/dual_check_facts.py create mode 100644 backend/tests/fixtures/dual_check_shop.py create mode 100644 backend/tests/test_dual_check_executor.py create mode 100644 backend/tests/test_dual_check_facts.py create mode 100644 backend/tests/test_dual_check_node.py diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py new file mode 100644 index 00000000..ccbbabfb --- /dev/null +++ b/backend/app/engine/dual_check_facts.py @@ -0,0 +1,308 @@ +"""双盲复算的选择写进数据目录:审核人在不一致的指标上选了原流程或盲算,而两边只差一个方面、又正好对得上数据目录 +里的一项事实时,把被采纳那一边的写法记成人工确认过的目录项。 + +对得上的事实只有三种(data/catalog.py 的表级项): + +- 两边按不同的时间列筛时间 → 业务日期(business_date):被采纳的一边按哪一列筛,业务日期就记哪一列 +- 一边多一个只涉及底层表列的筛选条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件 +- 聚合只差去不去重 → 去重规则(dedup):按被采纳那一边去不去重写一句 + +**为什么要这么严。** 目录是给助手、db_schema 工具和 SQL 检查看的数据事实,人工确认过的项还会让 SQL 检查报错误级。 +只差一个方面时,人选的那一边就是对这一处的判断;差了好几处、或者差异说明里有兜底(未能归纳的差异),人选的是 +「整体上哪边对」,拆不出是哪一处对,写进去就是替人编了一条事实。两条 SQL 规范化后一致却算出不同的值(两次取数之间 +数据变了、结果被截断),差在数据不在写法,更没有可记的事实。 + +**怎么写。** 照对话提案的规矩核对(plan_patch:列在不在表结构里、取值格式),记为人工填写、已确认,备注写明来自哪次 +运行、哪个节点、哪个指标。目录里这一项已经是人工确认过的另一种说法时不覆盖,照实交回原因;已经是同样的说法就什么都 +不写(再写一遍也不升版本)。整个写入由节点放在 ask() 之后的一个 @task 里:审批恢复重放、接着运行时不会再写一遍。 + +catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 +""" +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select + +from app.data import catalog +from app.db.base import SessionLocal +from app.db.models import DataSource + +logger = logging.getLogger(__name__) + +#: 能写的三项(表级) +FACT_ITEMS = ("business_date", "valid_filter", "dedup") +#: 审核人采纳的一边在差异说明里的位置:a 是原流程、b 是盲算(节点调用 explain_sql_diff 时按这个顺序给两边) +SIDE_LABEL = {"a": "原流程", "b": "盲算"} +#: 差异条目的方面(sql_semantics.FacetDiff.aspect)在说明里的叫法 +ASPECT_LABEL = { + "time": "时间列", "time_range": "时间范围", "filter": "筛选条件", "aggregate": "聚合方式", "expression": "选出的列", + "join": "关联", "tables": "查询的表", "set_op": "几支查询的合并方式", "group_by": "分组", "having": "分组后的筛选", + "limit": "取哪几行", "order": "排序", "parse": "能否解析", "other": "未能归纳的地方", +} +#: 聚合函数的说法(去重规则那一句用) +_VERB = {"count": "计数", "sum": "求和", "avg": "求平均"} +#: 写入时版本对不上(别人刚改过这张表)最多重试几次 +_RETRIES = 2 + + +@dataclass(frozen=True) +class Fact: + """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明。""" + + source: str + table: str + item: str + value: Any + metric: str + choice: str + + +def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None) -> tuple[Fact | None, str | None]: + """一个候选(审核人在某个指标上选了 a 或 b)→ (要写的目录项, None) 或 (None, 为什么不写)。 + + candidate:{metric 指标名, choice "a" | "b", source 数据源名, dialect, sql: {a, b}, diff: {equivalent, fallback, + items: [FacetDiff.as_dict()]}}。只看差异说明,不重新比 SQL:审核人看到的就是这份说明。 + """ + name = str(candidate.get("metric") or "") + side = candidate.get("choice") + diff = candidate.get("diff") if isinstance(candidate.get("diff"), Mapping) else {} + if side not in SIDE_LABEL: + return None, f"指标「{name}」没有采用任何一边的结果,不写入数据目录" + if diff.get("equivalent"): + return None, f"指标「{name}」两边的 SQL 规范化后一致,差异来自数据而不是写法,不写入数据目录" + items = [i for i in diff.get("items") or [] if isinstance(i, Mapping)] + if diff.get("fallback") or len(items) != 1: + return None, (f"指标「{name}」两边的 SQL 不止差一个方面,或有未能归纳的差异," + "无法确定审核人认可的是哪一处,不写入数据目录") + item = items[0] + aspect = str(item.get("aspect") or "") + if aspect == "time": + fact, why = _business_date(item, side) + elif aspect == "filter": + fact, why = _valid_filter(item, side, candidate.get("dialect")) + elif aspect == "aggregate": + fact, why = _dedup(item, side, candidate, schema_cache) + else: + return None, (f"指标「{name}」两边差在{ASPECT_LABEL.get(aspect, '其他地方')}," + "对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录") + if why is not None: + return None, f"指标「{name}」{why},不写入数据目录" + table, key, value = fact + resolved = catalog.resolve_table_name(schema_cache, table) + if resolved is None: + return None, f"指标「{name}」涉及的表「{table}」不在数据源的表结构里,不写入数据目录" + return Fact(source=str(candidate.get("source") or ""), table=resolved, item=key, value=value, metric=name, + choice=str(side)), None + + +def _business_date(item: Mapping[str, Any], side: str) -> tuple[tuple[str, str, Any] | None, str | None]: + detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {} + mine = [c for c in detail.get(side) or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")] + if not mine: + return None, f"被采纳的{SIDE_LABEL[side]}没有按时间筛选,没有可记的业务日期" + if len(mine) > 1: + return None, f"被采纳的{SIDE_LABEL[side]}按好几列筛时间,无法确定业务日期按哪一列" + return (str(mine[0]["table"]), "business_date", {"column": str(mine[0]["column"])}), None + + +def _valid_filter(item: Mapping[str, Any], side: str, dialect: Any) -> tuple[tuple[str, str, Any] | None, str | None]: + other = "b" if side == "a" else "a" + condition, rival = item.get(side), item.get(other) + if rival is not None: + if condition is None: + return None, f"多出筛选条件的是{SIDE_LABEL[other]},被采纳的{SIDE_LABEL[side]}没有这个条件" + return None, "两边各有不同的筛选条件,不是一边多出一个条件" + if not isinstance(condition, str) or not condition.strip(): + return None, "差异说明里没有被采纳一边的筛选条件" + detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {} + tables = {str(c.get("table")) for c in detail.get("columns") or [] if isinstance(c, Mapping) and c.get("table")} + if len(tables) != 1: + return None, "筛选条件不是只涉及一张表的列" + (table,) = tables + return (table, "valid_filter", _unqualified(condition.strip(), dialect)), None + + +def _unqualified(condition: str, dialect: Any) -> str: + """条件里的列去掉表名:有效记录条件记在这张表自己名下,写成 status <> 'refunded' 而不是 orders.status <> …。 + 解析不了就原样。""" + import sqlglot + from sqlglot import exp + + from app.data.sqlcheck import dialect_of + + read = dialect_of(dialect) if isinstance(dialect, str) else None + try: + tree = sqlglot.parse_one(condition, read=read) + except Exception: # noqa: BLE001 - 写法特殊、解析不了:差异说明里给人看的就是这一句,原样记 + return condition + if not any(col.table for col in tree.find_all(exp.Column)): + return condition + for col in tree.find_all(exp.Column): + col.set("table", None) + try: + return tree.sql(dialect=read) + except Exception: # noqa: BLE001 + return condition + + +def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], + schema_cache: Mapping[str, Any] | None) -> tuple[tuple[str, str, Any] | None, str | None]: + """聚合只差去不去重(同一个函数、同一个参数,一边写了 DISTINCT)。表从被采纳那一边的 SQL 里认:参数列属于哪张实表。""" + from app.data.sql_semantics import facets + + pair = _aggregate_pair(item.get("a"), item.get("b"), candidate.get("dialect")) + if pair is None: + return None, "两边的聚合不只是去不去重的差别" + func, distinct = pair + adopted = distinct[0] if side == "a" else distinct[1] + sql = (candidate.get("sql") or {}).get(side) if isinstance(candidate.get("sql"), Mapping) else None + found = facets(str(sql or ""), candidate.get("dialect"), schema=schema_cache) + hits = [a for a in found.aggregates if a.func == func and a.distinct == adopted and not a.scope] + columns = {ref for a in hits for ref in a.arg.columns} + if len(columns) != 1: + return None, "无法确定去重的是哪一张表的哪一列" + ((table, column),) = columns + if table is None: + # 参数列没认出属于哪张表(没给表结构、列没写表名):整条语句只查了一张表时就是它 + names = list(dict.fromkeys(t.text for t in found.tables)) + if len(names) != 1: + return None, "无法确定去重的列属于哪一张表" + table = names[0] + verb = _VERB.get(func, func) + text = f"对 {column} {verb}时去重" if adopted else f"对 {column} {verb}时不去重" + return (str(table), "dedup", text), None + + +def _aggregate_pair(a: Any, b: Any, dialect: Any) -> tuple[str, tuple[bool, bool]] | None: + """两边的聚合写法 → (函数名, (A 去重吗, B 去重吗));不是「同一个函数、同一个参数、只差 DISTINCT」返回 None。""" + import sqlglot + from sqlglot import exp + + from app.data.sqlcheck import dialect_of + + read = dialect_of(dialect) if isinstance(dialect, str) else None + + def shape(text: Any) -> tuple[str, str, bool] | None: + if not isinstance(text, str) or not text.strip(): + return None + try: + node = sqlglot.parse_one(text, read=read) + except Exception: # noqa: BLE001 + return None + if not isinstance(node, exp.AggFunc): + return None + arg = node.this + distinct = isinstance(arg, exp.Distinct) + if distinct: + inner = arg.expressions + if len(inner) != 1: + return None + arg = inner[0] + if arg is None: + return None + return node.key, arg.sql(dialect=read), distinct + + left, right = shape(a), shape(b) + if left is None or right is None or left[:2] != right[:2] or left[2] == right[2]: + return None + return left[0], (left[2], right[2]) + + +# -------------------------------------------------------------------------- +# 写入 +# -------------------------------------------------------------------------- + + +def note_of(fact: Fact, *, run_id: str, node_title: str) -> str: + """目录项上的备注:来自哪次运行、哪个节点、哪个指标、采纳了哪一边。""" + return (f"双盲复算(运行 {run_id},节点「{node_title}」):审核人在指标「{fact.metric}」上" + f"采用了{SIDE_LABEL.get(fact.choice, fact.choice)}的写法") + + +def _value_text(item: str, value: Any) -> str: + if item == "business_date" and isinstance(value, Mapping): + return str(value.get("column") or "") + return str(value) + + +async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str, + actor: str | None) -> dict[str, list[dict[str, Any]]]: + """把审核人的选择写进数据目录:{written: [{source, table, item}], skipped: [{reason}]}。永远不抛:数据源没了、 + 目录写不进去,都落在 skipped 里照实说。""" + written: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for candidate in candidates: + name = str(candidate.get("source") or "") + try: + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none() + if row is None: + skipped.append({"reason": f"数据源「{name}」已不存在,指标「{candidate.get('metric')}」的选择未写入数据目录"}) + continue + fact, why = derive(candidate, row.schema_cache) + if fact is None: + skipped.append({"reason": why}) + continue + done, why = await _write(session, row, fact, note=note_of(fact, run_id=run_id, node_title=node_title), + actor=actor) + except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记 + logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", name) + skipped.append({"reason": f"指标「{candidate.get('metric')}」的选择写入数据目录时出错,未写入"}) + continue + if done: + written.append({"source": fact.source, "table": fact.table, "item": fact.item}) + else: + skipped.append({"reason": why}) + return {"written": written, "skipped": skipped} + + +async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: str | None) -> tuple[bool, str | None]: + """写一项。(写了没有, 没写的原因)。""" + label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) + tables = (row.schema_cache or {}).get("tables") if isinstance(row.schema_cache, dict) else None + for _ in range(_RETRIES): + entry = await catalog.read_entry(session, row.id, fact.table) + notes = entry.notes if entry else {} + version = entry.version if entry else 0 + live = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None + live = live if live is not None and live.get("status") != "rejected" else None + value = fact.value + if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \ + and live["value"].get("column") == value.get("column"): + # 业务日期按哪一列是这次选出来的事实;目录里同一列另写了规则、时区的,原样留着 + value = live["value"] + changes = [{"path": fact.item, "value": value, "reason": note}] + plan = catalog.plan_patch(notes, changes, table=fact.table, tables=tables) + if plan.problems: + return False, f"「{fact.table}」的{label}未写入数据目录:{plan.problems[0]}" + change = plan.changes[0] + if change.state == "same": + return False, (f"「{fact.table}」的{label}已是人工确认过的「{_value_text(fact.item, value)}」," + "无需重复写入") + if change.before_status == "confirmed": + return False, (f"「{fact.table}」的{label}已有人工确认过的说法「{_value_text(fact.item, change.before)}」," + f"与这次指标「{fact.metric}」采纳的「{_value_text(fact.item, value)}」不同,未覆盖。" + "如需更改,请到数据目录中修改") + at = catalog.now_iso() + try: + merged = catalog.apply_patch(notes, changes, table=fact.table, tables=tables, at=at) + except catalog.CatalogInvalid as e: + return False, f"「{fact.table}」的{label}未写入数据目录:{e}" + # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处 + merged[fact.item] = catalog.make_item(change.after, "human", "confirmed", note=note, at=at) + try: + await catalog.write_entry(session, row.id, fact.table, merged, if_version=version, actor=actor) + except catalog.CatalogConflict: + await session.rollback() + continue + except catalog.CatalogInvalid as e: + return False, f"「{fact.table}」的{label}未写入数据目录:{e}" + return True, None + return False, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认" + + +__all__ = ["FACT_ITEMS", "Fact", "derive", "note_of", "record_choices"] diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 5174ffd4..83fa9cb1 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -1,21 +1,988 @@ -"""双盲复算节点:另起一路、不看原结果独立算一遍,再和原结果逐项比对。 +"""双盲复算节点:正式出具前,口径卡上输入来自查询的指标,另起一个看不到原 SQL 的只读 agent 独立算一遍再比对。 -目前只是占位:图里放了这个节点也能运行,执行器原样跳过、记一条日志,不改任何状态。 -真正的复算和比对由后续工作包补上;比不上的项通过节点产出里的 issuance_gaps 交给成果节点的 -出具契约降档(见 nodes/io.py 的 _upstream_issuance_gaps)。 +**按输入复算。** 指标由口径卡的原式算出,原式本身确定、可复算(metrics 的 recompute_ok);会错的是喂给它的那几个数: +查哪张表、按哪天归属、排没排除退款……所以盲算的单位是「一个来自查询快照的输入」。盲算 agent(engine/verify_agent.py) +只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项、数据源的数据目录,看不到原 SQL 和 +原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询快照里取值。盲值是拿原式代入盲算的输入算出来的 +(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时按绝对误差。两个指标用到同一格的只盲算一次。 + +**一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff), +完整对照落成一件工件(审批 payload 经 runner._safe 会截断,长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用 +原流程(a)、采用盲算(b)或两边都不对(neither):a 没有缺口;b、neither 写进产出的 issuance_gaps,成果节点的出具 +契约据此降档(on_reject 为 fail 时节点直接失败)。两边只差一个方面、又对得上数据目录事实的,选择写进数据目录 +(engine/dual_check_facts.py)。盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口。 + +**重放。** 审批恢复时节点从头重放。读设置(发起时没快照的才读)、盲算连同比对、差异说明连同工件、写数据目录各在一个 +@task 里,重放时直接取 checkpoint 里的结果:不再调用模型、不再计费、不再写目录,事件也都在 task 里发、不会再发一遍; +task 外面的日志经 replay.once 发。task 的先后只取决于这次运行固定下来的东西(图、运行选项、上游的产出)。设置读发起 +时的快照(launch_run 写进 run.options["verify"]):审批停下期间改了设置,恢复后照旧按发起时的。 + +**不进写作目录。** 盲算的查询发 tool.end(role: verify),随运行封存、证据面板能下钻;台账只记一条 kind: dual_check, +绝不写 kind: query——报告的写作目录按台账里的 query 编号,盲算的查询混进去,写作者就会引用它们。 """ from __future__ import annotations +import ast +import asyncio +import copy +import math +import re +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass from typing import Any +from langgraph.func import task +from sqlalchemy import select + +from app.core import artifact_store +from app.core.errors import describe_exception from app.core.events import EventType -from app.engine.context import NodeContext +from app.db.base import SessionLocal +from app.db.models import DataSource +from app.engine import recompute +from app.engine.approval import read_decision +from app.engine.context import NodeContext, NodeError +from app.engine.evidence import build_catalog, is_number, next_exec +from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cell_value, column_kind, numeric_text, \ + parse_expression +from app.engine.replay import _in_graph, ask, once +from app.engine.schema import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT, NodeType, dual_check_card from app.engine.state import GraphState -#: 占位期间的跳过原因:节点产出和日志里都写这一句 -PENDING_REASON = "双盲复算功能开发中,本节点暂未生效,已跳过" +#: 节点产出的状态:跳过、无需裁决、人已裁决 +SKIPPED, AGREED, RESOLVED = "skipped", "agreed", "resolved" +#: 单个指标的结果:一致、不一致、无法复算 +SAME, DIFFERENT, UNCHECKED = "agreed", "disagreed", "unchecked" +#: 审核人的三种选择:采用原流程、采用盲算、两边都不对 +CHOICES = ("a", "b", "neither") +#: 盲算触顶的原因里交给界面认的几种(dual_check_limit 日志的 limit);max_steps 只由核查本身的 verify_limit 日志说 +LIMIT_CODES = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd") +#: 输入的出处:口径卡的 cell() 取的格、Agent 经 cite_fields 交来的字段,都落在一份查询快照上 +QUERY_VIAS = ("tool_cell", "agent_field") +#: 差异说明里两边的叫法,先原流程后盲算:dual_check_facts 按 a / b 认这个顺序 +SIDES = ("原流程", "盲算") +#: 审批卡摘要里每个指标最多列几条差异 +SUMMARY_DIFFS = 3 +#: 一次 SQL 比较最多等多久:解析是纯计算,放在线程里,超时就给兜底说明 +DIFF_TIMEOUT_S = 15.0 +#: 交给盲算 agent 的数据目录最多这么多字(表清单、各表的说明分开算),再多就请它自己用 db_schema 查 +_INDEX_CHARS = 3_000 +_NOTES_CHARS = 6_000 +#: 交给盲算 agent 的同一行其他列最多几列、每个值最多几个字 +_DIMS = 6 +_DIM_CHARS = 40 +#: 卡上给容器输入记的大概(metrics._brief):「[12 项]」「{3 个字段}」,写进计算式时不当成字面量 +_BRIEF = re.compile(r"^(?:\[\d+ 项\]|\{\d+ 个字段\})$") + +SKIP_DISABLED = "双盲复算已在设置中关闭,本节点直接放行" +SKIP_NODE_OFF = "本节点已关闭双盲复算,直接放行" +SKIP_NO_INPUTS = "口径卡上没有输入来自查询的指标,无需双盲复算,直接放行" +#: 两条 SQL 规范化后一致、算出的值却不同:差在数据(两次取数之间数据变了、结果被截断),不在写法 +SAME_SQL = "两条 SQL 规范化后一致,差异可能来自两次取数之间数据有变化或取数结果被截断" +SAME_SQL_ASPECT = "data" +DIFF_TIMEOUT = "两条 SQL 比较超时,未能归纳成具体差异,请对照原文" +DIFF_FAILED = "两条 SQL 比较时出错,未能归纳成具体差异,请对照原文" +NO_SQL = "有一边没有可比较的 SQL,请对照原文" +#: 交给成果节点的缺口(不带节点名:出具契约统一加「双盲复算「节点名」:」) +GAP_BLIND = "指标「{name}」双盲复算不一致,审核人选择了盲算结果" +GAP_NEITHER = "指标「{name}」双盲复算不一致,审核人判断两边都不对" +GAP_UNCHECKED = "指标「{name}」无法复算:{reason}" + +BLIND_SYSTEM = ( + "你在做双盲复算:另一条流程已经算出了口径卡上的一个指标,你要在看不到它的 SQL 和结果的情况下,按业务定义独立算出" + "计算式里的一个数,交给人和原流程比对。\n" + "- 业务含义只按下面给的指标名称、口径说明和数据目录理解。数据目录里人工确认过的说明(业务日期按哪一列、有效记录" + "条件、去重规则)要遵守,标「推断,未确认」的谨慎使用\n" + "- 时间范围、要排除哪些行、要不要去重,都按口径说明来;口径说明没写到的,按数据目录和最常见的业务理解,并在理由里" + "写明你的取舍\n" + "- 尽量用一条 SQL 直接算出要交的那个数(聚合放在 SQL 里做),最后交出它所在的格子" +) +BLIND_TASK_PROMPT = ( + "指标:{name}{unit}\n" + "口径说明:{definition}\n" + "计算式:{formula}\n" + "你要算的是计算式里的 {label}{whole}:原流程里它是一次查询结果中「{column}」列的值{dims}。只交这一个数。\n" + "数据源:{source}\n\n" + "{catalog}" +) +BLIND_UNIT_PROMPT = "(单位:{unit})" +BLIND_NO_DEFINITION_PROMPT = "(未提供,按指标名称和数据目录理解)" +BLIND_WHOLE_PROMPT = "(这个指标就是它本身)" +BLIND_DIMS_PROMPT = ",所在行的其他列:{dims}" +BLIND_INDEX_PROMPT = "数据源里的表(表名|中文名|粒度):\n{index}" +BLIND_NOTES_PROMPT = "数据目录(标「推断,未确认」的项是推断,使用前请核实):\n{notes}" +BLIND_NO_CATALOG_PROMPT = "这个数据源还没有数据目录,请用 db_schema 工具查看表结构。" +BLIND_MORE_PROMPT = "\n…(其余的请用 db_schema 工具查看)" +#: 盲算交回的结构:一个数(交给模型时换成格子引用,由代码取值),verify_agent 另加理由 +BLIND_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"value": {"type": "number", "description": "要你算的那个数"}}, + "required": ["value"], +} + + +@dataclass(frozen=True) +class _Options: + """这次复算按什么来:节点配置优先,没写的取发起时的核查设置快照。""" + + tolerance: float + on_reject: str + metrics: list[str] | None + definitions: dict[str, str] + max_queries: int | None + timeout_s: float | None + daily_max_usd: float | None async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]: - ctx.emit(EventType.LOG, level="info", code="node_pending", message=PENDING_REASON) - return {"nodes": {ctx.node.id: {"skipped": True, "reason": PENDING_REASON}}} + settings = await _settings(ctx) + part = settings.get("dual_check") if isinstance(settings.get("dual_check"), dict) else {} + switch = ctx.config.get("enabled") + if switch is False or (switch is not True and not part.get("enabled", True)): + await once(ctx, EventType.LOG, level="info", code="dual_check_skipped", reason="disabled", + message=SKIP_NODE_OFF if switch is False else SKIP_DISABLED) + return _skipped(ctx, "disabled") + + card_id, card = _card(state, ctx) + options = _options(ctx, part, settings) + targets = _targets(card, options, ctx) if card is not None else [] + if not targets: + await once(ctx, EventType.LOG, level="info", code="dual_check_skipped", reason="no_inputs", + message=SKIP_NO_INPUTS) + return _skipped(ctx, "no_inputs") + + checked = await _step("dual_check_blind", lambda: _check(state, ctx, targets, options, settings)) + rows = checked["rows"] + disagreed = [r for r in rows if r["status"] == DIFFERENT] + if not disagreed: + return _finish(state, ctx, card_id, card, checked, status=AGREED, gaps=_unchecked_gaps(rows)) + + review = await _step("dual_check_review", lambda: _review(ctx, checked)) + response = await ask(ctx, review["payload"], **review["payload"]) + # 谁裁决的跟着事件走,和人工审批节点一样 + await once(ctx, EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) + choices, note = _choices(ctx, response, disagreed) + gaps = _gaps(rows, choices) + await once(ctx, EventType.LOG, level="info", code="dual_check_resolved", choices=choices, gaps=gaps, + message=_resolved_text(disagreed, choices)) + candidates = [{**review["facts"][r["metric_id"]], "choice": choices[r["metric_id"]]} + for r in disagreed if choices[r["metric_id"]] in ("a", "b") and r["metric_id"] in review["facts"]] + catalog = await _step("dual_check_catalog", lambda: _catalog(ctx, candidates)) if candidates else None + rejected = [r for r in disagreed if choices[r["metric_id"]] != "a"] + if rejected and options.on_reject == "fail": + # 目录照样写了:审核人的选择是对数据的判断,和这次运行停不停无关 + names = "、".join(f"「{r['name']}」" for r in rejected[:5]) + raise NodeError(ctx.node.id, f"双盲复算不一致,审核人没有采用原流程的结果(指标{names})。" + f"「{DUAL_CHECK_FIELD_LABEL['on_reject']}」设为「判为失败」,运行已停止;" + "请按审核意见修正取数或口径后重新运行") + return _finish(state, ctx, card_id, card, checked, status=RESOLVED, gaps=gaps, choices=choices, note=note, + artifact=review["card_artifact"], catalog=catalog) + + +# -------------------------------------------------------------------------- +# 设置、口径卡、要复算的指标 +# -------------------------------------------------------------------------- + + +async def _step(name: str, work: Callable[[], Awaitable[Any]]) -> Any: + """在一个 @task 里做 work:审批恢复、接着运行时节点从头重放,结果直接取 checkpoint 里的,不再做一遍;work 里发的 + 事件也就只发一次。不在图里执行(单独调用执行器)时直接做。""" + if not _in_graph(): + return await work() + + @task(name=name) + async def step() -> Any: + return await work() + + return await step() + + +async def _settings(ctx: NodeContext) -> dict[str, Any]: + """这次运行的核查设置:发起时的快照(run.options["verify"])。发起时没快照的(不经 launch_run 发起、子工作流里的 + 节点)读当前设置,读一次记进 checkpoint:重放时不再读,审批停下期间改了设置也不会让重放走上另一条路。""" + from app.api.settings import VERIFY_GROUP, verify_settings, verify_values + from app.engine.runner import RUN_OPTIONS_KEY + + options = ctx.run.extra.get(RUN_OPTIONS_KEY) + snapshot = options.get(VERIFY_GROUP) if isinstance(options, dict) else None + if isinstance(snapshot, dict): + return verify_values(snapshot) + + async def read() -> dict[str, Any]: + async with SessionLocal() as session: + return await verify_settings(session) + + return await _step("dual_check_settings", read) + + +def _card(state: GraphState, ctx: NodeContext) -> tuple[str, dict[str, Any] | None]: + """要复算的口径卡:(节点 id, 它这次的产出)。配置不对(发起前的校验已经拦下,这里兜底)报节点错误;卡没执行到 + (在没走到的分支上)产出为 None。""" + card_id, problem = dual_check_card(ctx.run.spec, ctx.node) + if problem or not card_id: + raise NodeError(ctx.node.id, problem or f"「{DUAL_CHECK_FIELD_LABEL['card']}」需要选择一个口径卡节点") + node = ctx.run.spec.node_map().get(card_id) + if node is None or node.type != NodeType.METRICS: + raise NodeError(ctx.node.id, f"「{DUAL_CHECK_FIELD_LABEL['card']}」选择的「{card_id}」不是口径卡节点," + "请重新选择") + output = (state.get("nodes") or {}).get(card_id) + return card_id, output if isinstance(output, dict) and output.get("kind") == "metric_set" else None + + +def _options(ctx: NodeContext, part: Mapping[str, Any], settings: Mapping[str, Any]) -> _Options: + cfg = ctx.config + tolerance = cfg.get("tolerance") + if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or not math.isfinite(tolerance) \ + or tolerance < 0: + tolerance = part.get("tolerance") + if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or tolerance < 0: + tolerance = 0.0 + wanted = cfg.get("metrics") + definitions = cfg.get("definitions") if isinstance(cfg.get("definitions"), dict) else {} + return _Options( + tolerance=float(tolerance), + on_reject=cfg.get("on_reject") if cfg.get("on_reject") in DUAL_CHECK_ON_REJECT else "degrade", + metrics=[str(m) for m in wanted if isinstance(m, str) and m.strip()] if isinstance(wanted, list) else None, + definitions={str(k): str(v) for k, v in definitions.items() if isinstance(v, str) and v.strip()}, + max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"), + daily_max_usd=settings.get("daily_max_usd"), + ) + + +def _from_query(item: Any) -> bool: + return isinstance(item, dict) and item.get("via") in QUERY_VIAS and isinstance(item.get("artifact"), str) \ + and bool(item["artifact"]) and isinstance(item.get("path"), str) + + +def _number(value: Any) -> bool: + if not is_number(value): + return False + try: + return math.isfinite(value) + except OverflowError: + return False + + +def _targets(card: dict[str, Any], options: _Options, ctx: NodeContext) -> list[dict[str, Any]]: + """要复算的指标,按配置的先后(没配就按卡上的先后):原值是数、至少一个输入来自查询快照。配置里写了卡上没有的 + 指标 ID 报节点错误(口径卡引用自别的工作流时,画图时查不出来)。""" + metrics = [m for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id") is not None] + by_id = {str(m["id"]): m for m in metrics} + if options.metrics: + if unknown := [m for m in options.metrics if m not in by_id]: + raise NodeError(ctx.node.id, f"「{DUAL_CHECK_FIELD_LABEL['metrics']}」中的「{'、'.join(unknown[:5])}」" + f"不是口径卡上的指标 ID,可选的指标 ID:{'、'.join(list(by_id)[:12])}") + chosen = [by_id[m] for m in dict.fromkeys(options.metrics)] + else: + chosen = metrics + return [m for m in chosen if _number(m.get("value")) and any(_from_query(i) for i in m.get("inputs") or [])] + + +def _skipped(ctx: NodeContext, reason: str) -> dict[str, Any]: + return {"nodes": {ctx.node.id: {"status": SKIPPED, "reason": reason}}} + + +# -------------------------------------------------------------------------- +# 盲算与比对(一个 @task) +# -------------------------------------------------------------------------- + + +class _Sources: + """一次复算里要读的查询快照和数据源:同一份只读一次。""" + + def __init__(self) -> None: + self.snapshots: dict[str, tuple[Any, str | None]] = {} + self.rows: dict[str, DataSource | None] = {} + + def snapshot(self, artifact: str) -> tuple[dict[str, Any] | None, str | None]: + if artifact not in self.snapshots: + try: + content = artifact_store.load(artifact) + self.snapshots[artifact] = (content, None if content is not None else "查询快照无法读取") + except ValueError: + self.snapshots[artifact] = (None, "查询快照与哈希不一致,疑似被修改") + content, why = self.snapshots[artifact] + if why is None and not (isinstance(content, dict) and isinstance(content.get("rows"), list)): + return None, "查询快照无法读取" + return content, why + + async def source(self, name: str) -> DataSource | None: + if name not in self.rows: + async with SessionLocal() as session: + self.rows[name] = (await session.execute( + select(DataSource).where(DataSource.name == name))).scalar_one_or_none() + return self.rows[name] + + async def hidden(self, artifact: str) -> frozenset[str]: + """这份快照要遮的列(小写):查询当时记下的,加上数据源现在设的。宁可多遮。""" + from app.data.engine import masked_columns + + snap, _ = self.snapshot(artifact) + recorded = (snap or {}).get("mask_columns") + out = {c.lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set() + name = (snap or {}).get("source") + if isinstance(name, str) and name: + row = await self.source(name) + out |= {c.lower() for c in masked_columns(row.options if row is not None else None)} + return frozenset(out) + + +def _loader(state: GraphState) -> tuple[Callable[[str], Any], dict[str, str]]: + """重算要的取工件函数和节点产出:nodes.* 的容器输入从这次运行的状态里取(口径卡执行时上游的产出就是这些)。""" + prefix = "state:" + outputs = {str(nid): f"{prefix}{nid}" for nid in state.get("nodes") or {}} + + def load(key: str) -> Any: + if key.startswith(prefix): + return (state.get("nodes") or {}).get(key[len(prefix):]) + return artifact_store.load(key) + + return load, outputs + + +async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, Any]], options: _Options, + settings: Mapping[str, Any]) -> dict[str, Any]: + """逐个指标认出能复算的输入、按输入盲算、代回原式比对。返回 {rows, results, model, same_model, usage}。 + + rows 每个指标一项:{metric_id, name, unit, decimals, status, reason?, original: {value, rendered}, blind: {value, + rendered}, delta_pct, inputs: [{path, key, value, sql, source, alias, artifact, row, column, node_id}], differing}; + results 是每个盲算(按格子去重)的结果,见 _slim。 + """ + sources = _Sources() + load, outputs = _loader(state) + aliases = _original_aliases(state) + rows: list[dict[str, Any]] = [] + jobs: dict[str, dict[str, Any]] = {} + for metric in targets: + row, metric_jobs = await _plan_metric(state, metric, options, sources, aliases, load, outputs) + rows.append(row) + for key, job in metric_jobs.items(): + # 两个指标用到同一格只盲算一次,按先出现的那个指标说清要算什么;先出现的没写口径说明、后一个写了,用后一个 + if key not in jobs or (not jobs[key]["definition"] and job["definition"]): + jobs[key] = job + results, model, same, usage = await _blind(ctx, jobs, options, settings, _producer_models(state, rows)) + for row, metric in zip(rows, targets): + if row["status"] != UNCHECKED: + _compare(row, metric, results, options, load, outputs, row.pop("_masks")) + else: + row.pop("_masks", None) + agreed = sum(1 for r in rows if r["status"] == SAME) + if agreed and not any(r["status"] == DIFFERENT for r in rows): + ctx.emit(EventType.LOG, level="info", code="dual_check_agreed", metrics=agreed, + message=f"双盲复算一致:{agreed} 个指标两边的结果相同,已放行") + return {"rows": rows, "results": results, "model": model, "same_model": same, "usage": usage} + + +def _original_aliases(state: GraphState) -> dict[str, str]: + """原流程的查询快照在这次运行的证据台账里的编号(Q1、Q2…,和报告取全部上游查询时的编号一致)。""" + ledger = [e for e in state.get("evidence") or [] if isinstance(e, dict)] + catalog = build_catalog(nodes={}, ledger=ledger) + return {str(entry.get("artifact")): alias for alias, entry in catalog.items() + if entry.get("kind") == "query" and entry.get("artifact")} + + +async def _plan_metric(state: GraphState, metric: dict[str, Any], options: _Options, sources: _Sources, + aliases: dict[str, str], load: Callable[[str], Any], outputs: dict[str, str] + ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + """一个指标:认出来自查询的输入能不能单独复算,能的话给每个输入建一个盲算任务。""" + row: dict[str, Any] = { + "metric_id": str(metric["id"]), "name": str(metric.get("name") or metric["id"]), + "unit": str(metric.get("unit") or ""), "decimals": metric.get("decimals"), + "status": "pending", "original": {"value": metric.get("value"), "rendered": metric.get("rendered")}, + "blind": {"value": None, "rendered": None}, "delta_pct": None, "inputs": [], "differing": [], + } + tops = _tops(metric) + masks: dict[str, frozenset[str]] = {} + problem: str | None = None + for item in metric.get("inputs") or []: + if not _from_query(item): + continue + info, why = await _query_input(item, sources, aliases, tops) + masks[item["artifact"]] = await sources.hidden(item["artifact"]) + if why: + problem = problem or why + continue + row["inputs"].append(info) + row["_masks"] = masks + if problem is None: + # 什么都不改先重算一遍:和卡上记的对不上(输入还原不了、用到了遮罩列),换了盲算的输入也算不准 + check = recompute.recompute(metric, recompute.Changes(), recompute.Sources( + load=load, outputs=outputs, masked=masks, queries=tuple(dict.fromkeys(masks)))) + if check["status"] != "ok" or check["changed"]: + problem = f"无法按原表达式重算这个指标({check.get('reason') or '重算结果与口径卡不一致'})" + if problem: + row.update(status=UNCHECKED, reason=problem) + return row, {} + labels = {inp["path"]: f"输入{i}" for i, inp in enumerate(row["inputs"], 1)} + formula = _formula(metric, labels) + jobs: dict[str, dict[str, Any]] = {} + for inp in row["inputs"]: + snap, _ = sources.snapshot(inp["artifact"]) + jobs[inp["key"]] = { + "key": inp["key"], "metric_id": row["metric_id"], "metric": row["name"], "unit": row["unit"], + "definition": options.definitions.get(row["metric_id"]) or "", "formula": formula, + "label": labels[inp["path"]], "column": inp["column"], "source": inp["source"], + "dims": _dims(snap or {}, inp["row"], inp["column"], masks.get(inp["artifact"]) or frozenset()), + } + return row, jobs + + +async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[str, str], tops: list[str] + ) -> tuple[dict[str, Any], str | None]: + """一个来自查询快照的输入:(盲算要用的信息, None) 或 (_, 为什么不能单独复算)。""" + from app.engine.merge_query import MERGE_SOURCE + + artifact = item["artifact"] + snap, why = sources.snapshot(artifact) + if why: + return {}, f"输入 {item['path']} 的{why}" + source = snap.get("source") + if source == MERGE_SOURCE: + return {}, f"输入 {item['path']} 来自合并查询(跨数据源合并的结果),盲算只能在单个数据源里查询" + if not isinstance(source, str) or not source: + return {}, f"输入 {item['path']} 的查询快照没有记下数据源" + locator = item.get("locator") if isinstance(item.get("locator"), dict) else {} + row, column = locator.get("row"), locator.get("column") + if isinstance(row, bool) or not isinstance(row, int) or row < 0 or not isinstance(column, str): + return {}, f"输入 {item['path']} 取的是一整段查询结果,盲算只能复核单个格子" + if item["path"] not in tops: + return {}, f"输入 {item['path']} 决定取哪一格,不能单独复算" + value = cell_value(item.get("value")) + if not _number(value): + return {}, f"输入 {item['path']} 不是数值" + if column.lower() in await sources.hidden(artifact): + return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算" + found = await sources.source(source) + if found is None or not found.enabled: + return {}, f"数据源「{source}」不存在或已停用" + return {"path": item["path"], "key": f"{artifact}|{row}|{column}", "value": value, + "sql": str(snap.get("sql") or ""), "source": source, "alias": aliases.get(artifact), + "artifact": artifact, "row": row, "column": column, "node_id": item.get("node_id"), + "via": item["via"]}, None + + +def _tops(metric: dict[str, Any]) -> list[str]: + """表达式最外层的引用:只有它们的值直接参与计算,嵌在下标、cell() 行列里的决定取哪一格。""" + try: + tree, _, _ = parse_expression(str(metric.get("expression") or "")) + except ExpressionError: + return [] + tops, _ = recompute._refs(tree) + return tops + + +class _Swap(ast.NodeTransformer): + """把最外层的引用换成给人(给盲算 agent)看的写法:取值链、整个 cell() 调用,认法同 recompute._Bind。""" + + def __init__(self, shown: Callable[[str], ast.AST | None]) -> None: + self.shown = shown + + def visit_Call(self, node: ast.Call) -> ast.AST: + if _cell_call(node) and (swapped := self.shown(ast.unparse(node))) is not None: + return ast.copy_location(swapped, node) + return self.generic_visit(node) + + def _chain(self, node: ast.AST) -> ast.AST: + if _chain_root(node) is None: + return self.generic_visit(node) + swapped = self.shown(ast.unparse(node)) + return ast.copy_location(swapped, node) if swapped is not None else node + + visit_Attribute = visit_Subscript = visit_Name = _chain + + +def _formula(metric: dict[str, Any], labels: dict[str, str]) -> str: + """交给盲算 agent 的计算式:来自查询的输入写成「输入1」「输入2」,别的输入写当时代进去的值(容器写「其他输入」)。 + 不露出取数节点的 id、cell() 的行列:那是原流程的取法,不是业务定义。""" + expr = str(metric.get("expression") or "") + try: + tree, _, _ = parse_expression(expr) + except ExpressionError: + return "、".join(labels.values()) + values = {i.get("path"): i.get("value") for i in metric.get("inputs") or [] if isinstance(i, dict)} + + def shown(path: str) -> ast.AST | None: + if path in labels: + return ast.Name(id=labels[path], ctx=ast.Load()) + value = values.get(path) + if value is None or isinstance(value, (bool, int, float)) or ( + isinstance(value, str) and len(value) <= _DIM_CHARS and not _BRIEF.match(value)): + return ast.Constant(value=value) + return ast.Name(id="其他输入", ctx=ast.Load()) + + swapped = _Swap(shown).visit(copy.deepcopy(tree)) + return ast.unparse(ast.fix_missing_locations(swapped)) + + +def _dims(snapshot: dict[str, Any], row: int, column: str, hidden: frozenset[str]) -> list[str]: + """原流程那一格所在行的其他列里不是数的那些(地区、日期这类维度):结果分了组时,盲算 agent 要知道算的是哪一组。 + 数不给(同一行别的指标值会带偏它),遮罩列不给。""" + rows = snapshot.get("rows") or [] + if not 0 <= row < len(rows): + return [] + record = rows[row] + columns = [str(c) for c in snapshot.get("columns") or []] + pairs = list(record.items()) if isinstance(record, dict) else list(zip(columns, record)) + out: list[str] = [] + for name, value in pairs: + name = str(name) + if name == column or name.lower() in hidden or value is None: + continue + kind = column_kind(snapshot, name) + if kind == "number" or (kind is None and (is_number(value) or numeric_text(value) is not None)): + continue + text = str(value).replace("\n", " ") + out.append(f"{name} 为「{text[:_DIM_CHARS]}」") + if len(out) >= _DIMS: + break + return out + + +async def _catalog_text(sources: _Sources, name: str) -> str: + """交给盲算 agent 的数据目录:表清单(表名、中文名、粒度)和有目录的表的业务说明(业务日期、有效记录条件、去重、 + 字段含义、码值、关联关系)。太长的截断,其余请它用 db_schema 查。""" + from app.data import catalog + + row = await sources.source(name) + if row is None: + return BLIND_NO_CATALOG_PROMPT + async with SessionLocal() as session: + entries = await catalog.read_catalog(session, row.id) + cache = row.schema_cache if isinstance(row.schema_cache, dict) else {} + tables = cache.get("tables") or {} + notes = {table: entry.notes for table, entry in entries.items()} + parts: list[str] = [] + if index := catalog.render_table_index(cache, notes): + parts.append(BLIND_INDEX_PROMPT.format(index=_clip(index, _INDEX_CHARS))) + blocks = [] + for table, body in notes.items(): + block = catalog.render_table_notes(body, meta=tables.get(table), system_notes=catalog.system_notes_source(row)) + # 每张表的说明去掉标题行,整段只在前面说一次「推断,未确认」的意思 + lines = block.split("\n", 1)[1] if "\n" in block else "" + if lines: + blocks.append(f"表 {table}:\n{lines}") + if blocks: + parts.append(BLIND_NOTES_PROMPT.format(notes=_clip("\n".join(blocks), _NOTES_CHARS))) + return "\n\n".join(parts) or BLIND_NO_CATALOG_PROMPT + + +def _clip(text: str, limit: int) -> str: + return text if len(text) <= limit else text[:limit].rsplit("\n", 1)[0] + BLIND_MORE_PROMPT + + +def _task_text(job: dict[str, Any], catalog: str) -> str: + return BLIND_TASK_PROMPT.format( + name=job["metric"], unit=BLIND_UNIT_PROMPT.format(unit=job["unit"]) if job["unit"] else "", + definition=job["definition"] or BLIND_NO_DEFINITION_PROMPT, formula=job["formula"], label=job["label"], + whole=BLIND_WHOLE_PROMPT if job["formula"].strip() == job["label"] else "", column=job["column"], + dims=BLIND_DIMS_PROMPT.format(dims="、".join(job["dims"])) if job["dims"] else "", + source=job["source"], catalog=catalog) + + +async def _blind(ctx: NodeContext, jobs: dict[str, dict[str, Any]], options: _Options, settings: Mapping[str, Any], + producers: set[str]) -> tuple[dict[str, Any], str | None, bool, dict[str, Any]]: + """按先后逐个盲算:(各格子的结果, 盲算模型, 是否和产出原数据的模型相同, 用量)。producers 是产出原数据的模型。""" + from app.api.settings import verify_model_spec + from app.engine.verify_agent import run_verify_agent + from app.tools.datasource import run_versions + + usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_usd": 0.0, "calls": 0} + if not jobs: + return {}, None, False, usage + async with SessionLocal() as session: + spec = await verify_model_spec(session, ctx.config, settings=dict(settings)) + sources = _Sources() + catalogs: dict[str, str] = {} + results: dict[str, Any] = {} + model: str | None = None + same = False + broken: dict[str, Any] | None = None + for key, job in jobs.items(): + if broken is not None: + # 核查模型本身用不了(没配好、接入出错):后面的盲算一样用不了,照这一次的原因记,不再一个个去试 + results[key] = broken + continue + if job["source"] not in catalogs: + catalogs[job["source"]] = await _catalog_text(sources, job["source"]) + outcome = await run_verify_agent( + spec=spec, system=BLIND_SYSTEM, task=_task_text(job, catalogs[job["source"]]), sources=[job["source"]], + output_schema=BLIND_SCHEMA, max_queries=options.max_queries, timeout_s=options.timeout_s, + daily_max_usd=options.daily_max_usd, emit=ctx.emit, run_id=ctx.run.run_id, node_id=ctx.node.id, + data_versions=run_versions(ctx.run)) + results[key] = _slim(outcome) + if outcome.error and not outcome.model: + broken = results[key] + for k in usage: + usage[k] += outcome.usage.get(k) or 0 + if outcome.model and model is None: + model = outcome.model + if model in producers: + same = True + ctx.emit(EventType.LOG, level="warn", code="dual_check_same_model", model=model, + message=f"盲算模型与产出原数据的模型相同(「{model}」),难以发现原流程自身的错误。" + "请在「设置 → 偏好设置 → 核查」中选择另一个核查模型," + f"或在节点的「{DUAL_CHECK_FIELD_LABEL['model']}」中指定") + if outcome.limit in LIMIT_CODES: + ctx.emit(EventType.LOG, level="warn", code="dual_check_limit", limit=outcome.limit, metric=job["metric_id"], + message=f"指标「{job['metric']}」的盲算{_limit_text(outcome.limit, options)},按无法复算处理") + usage["cost_usd"] = round(usage["cost_usd"], 6) + return results, model, same, usage + + +def _producer_models(state: GraphState, rows: list[dict[str, Any]]) -> set[str]: + """产出原数据的模型:要复算的输入是 Agent 经 cite_fields 交来的,那个 Agent 用的模型(节点产出里的 model)。调用 + 工具节点写死的 SQL 没有模型参与,不算。""" + outputs = state.get("nodes") or {} + out: set[str] = set() + for row in rows: + for inp in row["inputs"]: + found = outputs.get(inp.get("node_id")) if inp.get("via") == "agent_field" else None + if isinstance(found, dict) and isinstance(found.get("model"), str) and found["model"]: + out.add(found["model"]) + return out + + +def _slim(outcome: Any) -> dict[str, Any]: + """一次盲算的结果里要留进 checkpoint 的部分。""" + cell = next((c for c in outcome.cells if c.get("path") == "value"), None) or {} + query = next((q for q in outcome.queries if q.get("alias") == cell.get("alias")), None) or {} + value = cell.get("value") if cell.get("status") == "resolved" else None + reason = None + if outcome.error: + reason = outcome.error + elif cell.get("status") == "missing": + reason = "盲算没有查到这个数" + elif cell.get("status") == "unresolved": + reason = f"盲算交回的格子引用不可用({cell.get('reason') or '没有交这个数'})" + elif value is None: + reason = "盲算取到的那一格是空值" + elif _cell_masked(cell): + value, reason = None, "盲算取的是设置了遮罩的列,不采用" + return { + "value": value, "reason": reason, "limit": outcome.limit, "model": outcome.model, + "alias": cell.get("alias"), "ref": cell.get("ref"), "artifact": cell.get("artifact"), + "row": cell.get("row"), "column": cell.get("column"), "eid": cell.get("eid"), + "sql": query.get("sql"), "source": query.get("source"), + "rationale": (outcome.result or {}).get("rationale") or "", + "queries": [{k: q.get(k) for k in ("alias", "artifact", "sql", "row_count", "truncated", "source")} + for q in outcome.queries], + "notes": list(outcome.notes), + } + + +def _cell_masked(cell: dict[str, Any]) -> bool: + """盲算交的那一格在遮罩列上(查询当时数据源设的遮罩记在它自己的快照里):值会进审批卡和对照工件,不能用。""" + if not cell.get("artifact"): + return False + try: + snap = artifact_store.load(str(cell["artifact"])) + except (ValueError, OSError): + return True + recorded = snap.get("mask_columns") if isinstance(snap, dict) else None + column = str(cell.get("column") or "").lower() + return isinstance(recorded, list) and any(isinstance(c, str) and c.lower() == column for c in recorded) + + +def _limit_text(limit: str | None, options: _Options) -> str: + """盲算为什么停下,接在「盲算」后面读:「盲算已达每项最多查询次数(6 次)」。""" + if limit == "max_queries" and options.max_queries is not None: + return f"已达每项最多查询次数({options.max_queries} 次)" + if limit == "timeout_s" and options.timeout_s is not None: + return f"已达每项时长上限({options.timeout_s:g} 秒)" + if limit == "daily_max_usd" and options.daily_max_usd is not None: + return f"已达核查每日金额上限(${float(options.daily_max_usd):g})" + if limit in LIMIT_CODES: + return "已达核查的上限" + return "轮数已用完" + + +def _agree(original: Any, blind: Any, tolerance: float) -> bool: + """按相对误差比(原值为 0 时按绝对误差)。""" + if original == blind: + return True + if not (_number(original) and _number(blind)): + return False + if original == 0: + return abs(blind) <= tolerance + return abs(blind - original) <= tolerance * abs(original) + + +def _delta_pct(original: Any, blind: Any) -> float | None: + """相差比例,口径同运行对比:(盲算值 − 原流程值) ÷ |原流程值| × 100,两位小数;原流程值为 0 时为 None。""" + if not (_number(original) and _number(blind)) or original == 0: + return None + return round((blind - original) / abs(original) * 100, 2) + + +def _compare(row: dict[str, Any], metric: dict[str, Any], results: dict[str, Any], options: _Options, + load: Callable[[str], Any], outputs: dict[str, str], masks: dict[str, frozenset[str]]) -> None: + """盲算的输入代回原式,和原值比。row 就地改成一致 / 不一致 / 无法复算。""" + values: dict[str, Any] = {} + for inp in row["inputs"]: + found = results.get(inp["key"]) + if found is None: + row.update(status=UNCHECKED, reason="盲算没有执行") + return + if found.get("limit"): + row.update(status=UNCHECKED, reason="盲算" + _limit_text(found["limit"], options)) + return + if found.get("value") is None: + row.update(status=UNCHECKED, reason=found.get("reason") or "盲算没有交回可用的数值") + return + values[inp["path"]] = found["value"] + outcome = recompute.recompute(metric, recompute.Changes(inputs=values), recompute.Sources( + load=load, outputs=outputs, masked=masks, queries=tuple(dict.fromkeys(masks)))) + if outcome["status"] != "ok": + row.update(status=UNCHECKED, reason=f"用盲算的输入按原表达式算不出结果({outcome.get('reason') or '无法计算'})") + return + original = row["original"]["value"] + blind = outcome["new"] + row["blind"] = {"value": blind, "rendered": outcome.get("rendered_new")} + row["delta_pct"] = _delta_pct(original, blind) + if _agree(original, blind, options.tolerance): + row["status"] = SAME + return + row["status"] = DIFFERENT + # 对不上的输入:超出误差的;都没超出(误差经算式放大了)就取值不同的,再不行就全部 + differing = [i["key"] for i in row["inputs"] if not _agree(i["value"], values[i["path"]], options.tolerance)] + differing = differing or [i["key"] for i in row["inputs"] if i["value"] != values[i["path"]]] + row["differing"] = differing or [i["key"] for i in row["inputs"]] + + +def _unchecked_gaps(rows: list[dict[str, Any]]) -> list[str]: + return [GAP_UNCHECKED.format(name=r["name"], reason=r.get("reason") or "原因不明") + for r in rows if r["status"] == UNCHECKED] + + +# -------------------------------------------------------------------------- +# 不一致:差异说明、对照工件、审批(一个 @task) +# -------------------------------------------------------------------------- + + +async def _review(ctx: NodeContext, checked: dict[str, Any]) -> dict[str, Any]: + """不一致的指标逐个说清差在哪,完整对照落成工件,组好审批的 payload。返回 {payload, facts, card_artifact}。 + + facts 是每个不一致指标写数据目录要用的东西(dual_check_facts.derive 的候选,少一个 choice):只有一个输入对不上时是 + 那一处完整的差异条目(带两边的写法和涉及的表、列),几个输入都对不上时只说「不止一处」。 + """ + rows = [r for r in checked["rows"] if r["status"] == DIFFERENT] + results = checked["results"] + sources = _Sources() + infos: dict[str, dict[str, Any]] = {} + metrics: list[dict[str, Any]] = [] + summary: list[dict[str, Any]] = [] + facts: dict[str, dict[str, Any]] = {} + for row in rows: + per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + for inp in row["inputs"]: + if inp["key"] not in row["differing"]: + continue + if inp["source"] not in infos: + infos[inp["source"]] = await _source_info(sources, inp["source"]) + blind = results.get(inp["key"]) or {} + diff = await _explain(inp["sql"], blind.get("sql"), infos[inp["source"]]) + per_input.append((inp, blind, diff)) + merged = _merge(per_input) + metrics.append({ + "metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], "decimals": row["decimals"], + "original": {**row["original"], "inputs": [ + {"path": i["path"], "value": i["value"], "sql": i["sql"], "source": i["source"], "alias": i["alias"]} + for i in row["inputs"]]}, + "blind": {**row["blind"], "inputs": [ + {"path": i["path"], "value": (results.get(i["key"]) or {}).get("value"), + "sql": (results.get(i["key"]) or {}).get("sql"), "source": (results.get(i["key"]) or {}).get("source"), + "alias": (results.get(i["key"]) or {}).get("alias"), + "rationale": (results.get(i["key"]) or {}).get("rationale") or ""} + for i in row["inputs"]]}, + "diff": {"equivalent": merged["equivalent"], + "items": [{"aspect": i.get("aspect"), "text": i.get("text")} for i in merged["items"]], + "fallback": merged["fallback"], "text_diff": merged["text_diff"]}, + }) + summary.append({ + "metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], + "original_rendered": row["original"]["rendered"], "blind_rendered": row["blind"]["rendered"], + "delta_pct": row["delta_pct"], "diff": [str(i.get("text")) for i in merged["items"][:SUMMARY_DIFFS]], + "fallback": not any(i.get("aspect") not in ("other", "parse") for i in merged["items"]), + }) + first = per_input[0][0] if per_input else (row["inputs"][0] if row["inputs"] else {}) + single = per_input[0] if len(per_input) == 1 else None + facts[row["metric_id"]] = { + "metric_id": row["metric_id"], "metric": row["name"], "source": first.get("source"), + "dialect": (infos.get(str(first.get("source"))) or {}).get("kind"), + "sql": {"a": single[0]["sql"], "b": single[1].get("sql")} if single else {}, + "diff": {"equivalent": merged["equivalent"], "fallback": merged["fallback"] or single is None, + "items": merged["items"]}, + } + content = {"metrics": metrics, "model": checked.get("model"), "same_model": bool(checked.get("same_model"))} + try: + artifact = await artifact_store.put_json(content, kind="dual_check", run_id=ctx.run.run_id, + node_id=ctx.node.id, meta={"metrics": len(metrics)}) + except Exception as e: # noqa: BLE001 - 对照存不下,审核人就看不到完整的两条 SQL:不能让人凭摘要裁决 + raise NodeError(ctx.node.id, f"双盲复算的对照结果保存失败({describe_exception(e)}),请稍后接着运行") from e + payload = { + "kind": "dual_check", "node_id": ctx.node.id, "mode": "input", + "title": f"双盲复算:{len(rows)} 个指标两边结果不一致,请选择采用哪一边的结果", + "card_artifact": artifact, "summary": summary, + } + names = "、".join(f"「{r['name']}」" for r in rows[:5]) + (f"等 {len(rows)} 个" if len(rows) > 5 else "") + ctx.emit(EventType.LOG, level="warn", code="dual_check_disagreed", + metrics=[{"id": r["metric_id"], "name": r["name"]} for r in rows], card_artifact=artifact, + message=f"双盲复算不一致:指标{names}两边的结果对不上,已暂停等待审核") + return {"payload": payload, "facts": facts, "card_artifact": artifact} + + +async def _source_info(sources: _Sources, name: str) -> dict[str, Any]: + """比较两条 SQL 要用的:方言、表结构、列的中文名(数据目录里的)。数据源没了就都不给,照样比原文。""" + from app.data import catalog + from app.data.sql_semantics import catalog_labels + + row = await sources.source(name) + if row is None: + return {"kind": None, "schema": None, "names": {}} + async with SessionLocal() as session: + entries = await catalog.read_catalog(session, row.id) + return {"kind": row.kind, "schema": row.schema_cache if isinstance(row.schema_cache, dict) else None, + "names": catalog_labels({table: entry.notes for table, entry in entries.items()})} + + +async def _explain(original: Any, blind: Any, info: dict[str, Any]) -> dict[str, Any]: + """两条 SQL 的差异说明(SqlDiff.as_dict 的形状,条目带两边的写法和 detail)。规范化后一致的换成「差在数据」的 + 一句:值不同,写法却一样,具体差异一条都说不出。""" + from app.data.sql_semantics import explain_sql_diff + + if not isinstance(original, str) or not original.strip() or not isinstance(blind, str) or not blind.strip(): + return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": NO_SQL}], + "fallback": True, "text_diff": []} + try: + diff = await asyncio.wait_for(asyncio.to_thread( + explain_sql_diff, original, blind, info.get("kind"), schema=info.get("schema"), names=info.get("names"), + labels=SIDES), timeout=DIFF_TIMEOUT_S) + except Exception as e: # noqa: BLE001 - 比较本身出了意外(超时、规范化的缺陷):给兜底说明,审核人对照原文 + text = DIFF_TIMEOUT if isinstance(e, asyncio.TimeoutError) else DIFF_FAILED + return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": text}], + "fallback": True, "text_diff": [f"- {original.strip()}", f"+ {blind.strip()}"]} + out = diff.as_dict() + if out["equivalent"]: + return {"equivalent": True, "items": [{"aspect": SAME_SQL_ASPECT, "a": None, "b": None, "text": SAME_SQL}], + "fallback": False, "text_diff": []} + return out + + +def _merge(per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]) -> dict[str, Any]: + """一个指标几个对不上的输入的差异合成一份:一个输入时就是它的;几个时每条前面写明是哪一列的输入。""" + if not per_input: + return {"equivalent": False, "items": [{"aspect": "other", "a": None, "b": None, "text": NO_SQL}], + "fallback": True, "text_diff": []} + if len(per_input) == 1: + diff = per_input[0][2] + return {"equivalent": bool(diff["equivalent"]), "items": list(diff["items"]), + "fallback": bool(diff["fallback"]), "text_diff": list(diff.get("text_diff") or [])} + items: list[dict[str, Any]] = [] + lines: list[str] = [] + for inp, _, diff in per_input: + items += [{**i, "text": f"输入「{inp['column']}」:{i.get('text')}"} for i in diff["items"]] + lines += list(diff.get("text_diff") or []) + return {"equivalent": all(d["equivalent"] for _, _, d in per_input), "items": items, + "fallback": any(d["fallback"] for _, _, d in per_input), "text_diff": lines[:40]} + + +# -------------------------------------------------------------------------- +# 恢复之后:审核人的选择、缺口、数据目录 +# -------------------------------------------------------------------------- + + +def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tuple[dict[str, str], str]: + """审核人的回复 → ({指标 id: a | b | neither}, 备注)。回复的 value 是 {choices, note};某个指标没选、选了认不出的 + 按「两边都不对」:不替人认可任何一边。明确驳回(approved 为否)的按人工驳回让节点失败,和人工审批节点的填写模式 + 一样:这一步没有驳回的出口。""" + decision = read_decision(response) + if isinstance(response, dict) and decision.rejected: + raise NodeError(ctx.node.id, f"人工驳回:{decision.note or '未说明原因'}") + value = decision.value if isinstance(decision.value, dict) else response if isinstance(response, dict) else {} + raw = value.get("choices") if isinstance(value.get("choices"), dict) else {} + choices = {r["metric_id"]: raw[r["metric_id"]] if raw.get(r["metric_id"]) in CHOICES else "neither" for r in rows} + note = value.get("note") if isinstance(value.get("note"), str) and value.get("note").strip() else decision.note + return choices, str(note or "").strip() + + +def _gaps(rows: list[dict[str, Any]], choices: dict[str, str]) -> list[str]: + out: list[str] = [] + for row in rows: + if row["status"] == UNCHECKED: + out.append(GAP_UNCHECKED.format(name=row["name"], reason=row.get("reason") or "原因不明")) + elif row["status"] == DIFFERENT and choices.get(row["metric_id"]) == "b": + out.append(GAP_BLIND.format(name=row["name"])) + elif row["status"] == DIFFERENT and choices.get(row["metric_id"]) != "a": + out.append(GAP_NEITHER.format(name=row["name"])) + return out + + +_CHOICE_TEXT = {"a": "采用原流程", "b": "采用盲算", "neither": "两边都不对"} + + +def _resolved_text(rows: list[dict[str, Any]], choices: dict[str, str]) -> str: + parts = ";".join(f"「{r['name']}」{_CHOICE_TEXT[choices[r['metric_id']]]}" for r in rows[:8]) + return f"审核人已裁决 {len(rows)} 个不一致的指标:{parts}" + ("等" if len(rows) > 8 else "") + + +async def _catalog(ctx: NodeContext, candidates: list[dict[str, Any]]) -> dict[str, Any]: + from app.data.catalog import TABLE_FIELD_LABEL + from app.engine.dual_check_facts import record_choices + + result = await record_choices(candidates, run_id=ctx.run.run_id, node_title=ctx.node.title, actor=ctx.actor()) + written, skipped = result["written"], result["skipped"] + parts = [] + if written: + parts.append("已写入数据目录:" + "、".join( + f"「{w['table']}」的{TABLE_FIELD_LABEL.get(w['item'], w['item'])}" for w in written)) + if skipped: + parts.append("未写入数据目录:" + ";".join(s["reason"] for s in skipped[:3]) + + (f"等 {len(skipped)} 条" if len(skipped) > 3 else "")) + ctx.emit(EventType.LOG, level="info", code="dual_check_catalog", written=written, skipped=skipped, + message="。".join(parts)) + return result + + +# -------------------------------------------------------------------------- +# 产出 +# -------------------------------------------------------------------------- + + +def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, Any] | None, checked: dict[str, Any], + *, status: str, gaps: list[str], choices: dict[str, str] | None = None, note: str = "", + artifact: str | None = None, catalog: dict[str, Any] | None = None) -> dict[str, Any]: + rows = checked["rows"] + picked = choices or {} + metrics = [{ + "metric_id": r["metric_id"], "name": r["name"], "unit": r["unit"], "status": r["status"], + "original_rendered": r["original"]["rendered"], "blind_rendered": r["blind"]["rendered"], + "delta_pct": r["delta_pct"], + **({"reason": r["reason"]} if r.get("reason") else {}), + **({"choice": picked[r["metric_id"]]} if r["metric_id"] in picked else {}), + } for r in rows] + result: dict[str, Any] = {"status": status, "metrics": metrics, "model": checked.get("model"), + "same_model": bool(checked.get("same_model"))} + if artifact: + result["card_artifact"] = artifact + if choices is not None: + result.update(choices=choices, note=note) + if catalog is not None: + result["catalog"] = catalog + if gaps: + result["issuance_gaps"] = gaps + entry: dict[str, Any] = { + "kind": "dual_check", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), "card": card_id, + "status": status, + "metrics": [{"id": m["metric_id"], "status": m["status"], **({"choice": m["choice"]} if "choice" in m else {})} + for m in metrics], + **({"metric_set": card["artifact"]} if card and isinstance(card.get("artifact"), str) else {}), + **({"artifact": artifact} if artifact else {}), + } + updates: dict[str, Any] = {"nodes": {ctx.node.id: result}, "evidence": [entry]} + usage = checked.get("usage") or {} + if usage.get("calls"): + updates["usage"] = dict(usage) + return updates + + +__all__ = ["AGREED", "CHOICES", "RESOLVED", "SKIPPED", "run_dual_check"] diff --git a/backend/tests/fixtures/dual_check_shop.py b/backend/tests/fixtures/dual_check_shop.py new file mode 100644 index 00000000..215b09ac --- /dev/null +++ b/backend/tests/fixtures/dual_check_shop.py @@ -0,0 +1,145 @@ +"""双盲复算测试用的虚构零售库和盲算模型的剧本。表名、数字、地名都是编的。 + +订单表埋了两个陷阱:退款行(status = 'refunded'),以及下单日和支付日不在同一周的单。2026-09-01 至 2026-09-07 这一周: + +- 按支付日、排除退款(口径说明要的):1、2、5 号单,330.0 +- 按下单日、排除退款:2、4、5 号单,310.0 +- 按支付日、不排除退款:1、2、3、5 号单,380.0 +- 按下单日、不排除退款:2、3、4、5 号单,360.0 +- 付款客户数(按支付日、排除退款):按行计数 3,去重计数 2 +""" +from __future__ import annotations + +import json +import re +import sqlite3 +import uuid +from pathlib import Path + +from langchain_core.messages import AIMessage, ToolMessage + +from app.data import introspect +from app.db.base import SessionLocal +from app.db.models import DataSource + +ROWS = [ + (1, "华东", 101, 100.0, "paid", "2026-08-31", "2026-09-01"), + (2, "华东", 102, 200.0, "paid", "2026-09-02", "2026-09-02"), + (3, "华南", 101, 50.0, "refunded", "2026-09-03", "2026-09-03"), + (4, "华南", 103, 80.0, "paid", "2026-09-07", "2026-09-08"), + (5, "华北", 102, 30.0, "paid", "2026-09-05", "2026-09-05"), +] +WEEK = "BETWEEN '2026-09-01' AND '2026-09-07'" +#: 口径说明要的写法:按支付日、排除退款 +RIGHT = f"SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}" +#: 只错在时间列:按下单日 +BY_ORDER_DATE = f"SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND order_date {WEEK}" +#: 只错在没排除退款 +WITH_REFUNDS = f"SELECT SUM(amount) AS gmv FROM orders WHERE paid_date {WEEK}" +#: 两处都错 +BOTH_WRONG = f"SELECT SUM(amount) AS gmv FROM orders WHERE order_date {WEEK}" +#: 付款客户数:按行计数 / 去重计数 +BUYERS = f"SELECT COUNT(customer_id) AS buyers FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}" +BUYERS_DISTINCT = (f"SELECT COUNT(DISTINCT customer_id) AS buyers FROM orders " + f"WHERE status <> 'refunded' AND paid_date {WEEK}") +#: 按地区分组:华东一行是 300.0(1、2 号单) +BY_REGION = (f"SELECT region, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK} " + "GROUP BY region ORDER BY region") +DEFINITION = "已支付订单的金额合计:按支付日期(paid_date)归属到当周,不含退款" + + +def build(path: Path) -> Path: + db = sqlite3.connect(path) + db.executescript( + "CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, customer_id INTEGER, amount REAL, status TEXT, " + "order_date TEXT, paid_date TEXT);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", ROWS) + db.commit() + db.close() + return path + + +async def add_source(path: Path, *, options: dict | None = None) -> tuple[str, str]: + """登记成只读的手工源,探查好表结构。返回 (数据源名, id)。""" + name = f"dc_shop_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, + options=dict(options or {})) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + return name, row.id + + +def _text(messages) -> str: + return "\n".join(m.content if isinstance(m.content, str) else json.dumps(m.content, ensure_ascii=False) + for m in messages) + + +class Blind: + """盲算模型的剧本,也管图里别的模型调用。 + + plans:{任务里出现的指标名: (SQL, 要交的列) 或 [SQL, …]、列}。盲算循环里依次查这几条 SQL,收尾时交最后一条查询的 + 第 0 行那一列(row 可改)。没对上指标名的盲算交 null。不是盲算的调用(报告撰写)返回 writer 的正文并记下收到的消息。 + calls 记下每次盲算调用(step / final),tasks 记下盲算收到的任务原文。 + """ + + def __init__(self, monkeypatch, source: str, plans: dict, *, writer: str = "本周销售额见口径卡。", row: int = 0): + from app.providers import mock_model + + self.source = source + self.plans = plans + self.writer = writer + self.row = row + self.calls: list[str] = [] + self.tasks: list[str] = [] + self.writer_prompts: list[str] = [] + blind = self + + def decide(model, messages): + text = _text(messages) + plan = blind.plan_of(text) + if model.response_format: + blind.calls.append("final") + done = len(re.findall(r"【证据 Q\d+】", text)) + if plan is None or not done: + return AIMessage(content=json.dumps({"value": None, "rationale": "没有查到。"}, ensure_ascii=False)) + _, column = plan + alias = f"Q{len(blind.sqls(plan))}" + body = {"value": {"alias": alias, "row": blind.row, "column": column}, + "rationale": f"按口径说明统计,结果是 [[v:{alias}.r{blind.row}.{column}]]。"} + return AIMessage(content=json.dumps(body, ensure_ascii=False)) + if model.tools: + blind.calls.append("step") + # 盲算的任务是第一条用户消息;同一次盲算的几轮收到的是同一条,只记一次 + task_text = next((str(m.content) for m in messages if m.type == "human"), "") + if not blind.tasks or blind.tasks[-1] != task_text: + blind.tasks.append(task_text) + done = sum(isinstance(m, ToolMessage) for m in messages) + sqls = blind.sqls(plan) if plan is not None else [] + if done < len(sqls): + call = {"name": f"db_query__{blind.source}", "args": {"sql": sqls[done]}, + "id": f"blind-{uuid.uuid4().hex[:6]}"} + return AIMessage(content="查一下。", tool_calls=[call]) + return AIMessage(content="查完了。") + blind.writer_prompts.append(text) + return AIMessage(content=blind.writer) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + + def plan_of(self, text: str): + for name, plan in self.plans.items(): + if f"指标:{name}" in text: + return plan + return None + + @staticmethod + def sqls(plan) -> list[str]: + sql = plan[0] + return list(sql) if isinstance(sql, (list, tuple)) else [sql] + + @property + def runs(self) -> int: + """盲算 agent 一共执行了几次(每次收尾交一次结果)。""" + return self.calls.count("final") diff --git a/backend/tests/test_dual_check_executor.py b/backend/tests/test_dual_check_executor.py new file mode 100644 index 00000000..7a1f3736 --- /dev/null +++ b/backend/tests/test_dual_check_executor.py @@ -0,0 +1,160 @@ +"""单独调用双盲复算的执行器(不在图里执行:@task 直接执行,once 直接发),测图里不好凑的几种情形。 + +- 盲算模型和产出原数据的 Agent 是同一个模型时提醒;调用工具节点写死的 SQL 没有模型参与,不提醒 +- 合并查询、整段结果、遮罩列、文本这类输入不能单独复算:记缺口,一分钱不花 +- 口径卡没执行到的直接放行;配置里写了卡上没有的指标 ID 报节点错误 + +查询快照是现造的工件,表名、数字都是虚构的。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest +from sqlalchemy import delete + +from app.core import artifact_store +from app.db.base import SessionLocal +from app.db.models import Setting +from app.engine.context import NodeContext, NodeError, RunContext +from app.engine.evidence import cell_eid +from app.engine.nodes.dual_check import run_dual_check +from app.engine.runner import RUN_OPTIONS_KEY +from app.engine.schema import GraphSpec +from app.tools.datasource import RUN_VERSIONS_KEY +from tests.fixtures.dual_check_shop import RIGHT, Blind, add_source, build +from tests.fixtures.sources import drop_source + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +@dataclass +class Recorder(NodeContext): + """记下节点发的事件。不在图里执行:@task 直接执行,once 直接发。""" + + events: list[tuple[str, dict[str, Any]]] = field(default_factory=list) + + def emit(self, event_type, **data): + self.events.append((str(event_type), data)) + + def logs(self, code: str) -> list[dict[str, Any]]: + return [d for t, d in self.events if t == "log" and d.get("code") == code] + + +@pytest.fixture +async def shop(tmp_path): + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key == "verify")) + await session.commit() + name, source_id = await add_source(build(tmp_path / "shop.db"), options={"mask_columns": "secret_gmv"}) + yield name + await drop_source(source_id) + + +async def snapshot(source: str, *, columns=("gmv",), rows=((330.0,),), sql=RIGHT, **extra) -> str: + content = {"columns": list(columns), "rows": [list(r) for r in rows], "row_count": len(rows), "truncated": False, + "sql": sql, "source": source, **extra} + return await artifact_store.put_json(content, kind="query_snapshot", run_id="unit", node_id="agent") + + +def context(graph: dict, node_id: str = "dual") -> Recorder: + parsed = GraphSpec.model_validate(graph) + run = RunContext(run_id="unit-run", thread_id="unit-run", spec=parsed, + extra={RUN_OPTIONS_KEY: {"verify": {}}, RUN_VERSIONS_KEY: None}) + return Recorder(node=parsed.node_map()[node_id], run=run) + + +AGENT_GRAPH = {"nodes": [node("start", "input"), node("agent", "agent", prompt="查销售额"), + node("card", "metrics", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]), + node("dual", "dual_check", "复核"), node("out", "output", fields=[])], + "edges": [{"source": a, "target": b} for a, b in + (("start", "agent"), ("agent", "card"), ("card", "dual"), ("dual", "out"))]} + + +def card_of(*metrics: dict) -> dict: + return {"kind": "metric_set", "caliber": "销售口径", "caliber_version": "v1", "metrics": list(metrics), + "artifact": "card-artifact"} + + +def metric(artifact: str, *, path: str = "vars.kpi.gmv", locator: dict | None = None, value: Any = 330.0, + via: str = "agent_field", node_id: str = "agent", expression: str | None = None) -> dict: + where = locator or {"row": 0, "column": "gmv"} + return {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands", "value": 330.0, + "rendered": "330.0元", "expression": expression or path, "substituted": "330.0", "status": "ok", + "inputs": [{"path": path, "value": value, "node_id": node_id, "via": via, "field": "gmv", + "artifact": artifact, "locator": where, "status": "verified", + **({"eid": cell_eid(artifact, where["row"], where["column"])} if "row" in where else {})}]} + + +async def test_the_same_model_as_the_agent_that_fetched_the_data_is_flagged(monkeypatch, shop): + art = await snapshot(shop) + Blind(monkeypatch, shop, {"销售额": (RIGHT, "gmv")}) + ctx = context(AGENT_GRAPH) + state = {"nodes": {"agent": {"model": "mock-fast", "data": {"gmv": 330.0}}, "card": card_of(metric(art))}, + "evidence": [], "trail": []} + out = (await run_dual_check(state, ctx))["nodes"]["dual"] + assert out["status"] == "agreed" and out["same_model"] is True and out["model"] == "mock-fast" + [warned] = ctx.logs("dual_check_same_model") + assert warned["model"] == "mock-fast" and "盲算模型与产出原数据的模型相同" in warned["message"] + assert "设置 → 偏好设置 → 核查" in warned["message"] + + +async def test_data_fetched_by_fixed_sql_raises_no_same_model_warning(monkeypatch, shop): + art = await snapshot(shop) + Blind(monkeypatch, shop, {"销售额": (RIGHT, "gmv")}) + ctx = context(AGENT_GRAPH) + state = {"nodes": {"agent": {"model": "mock-fast"}, + "card": card_of(metric(art, path="cell(nodes.fetch, 0, 'gmv')", via="tool_cell", + node_id="fetch"))}, + "evidence": [], "trail": []} + out = (await run_dual_check(state, ctx))["nodes"]["dual"] + assert out["status"] == "agreed" and out["same_model"] is False and not ctx.logs("dual_check_same_model") + + +@pytest.mark.parametrize("case, reason", [ + ("merge", "来自合并查询"), + ("rows", "取的是一整段查询结果"), + ("masked", "设置了遮罩的列"), + ("text", "不是数值"), +]) +async def test_inputs_that_cannot_be_checked_alone_become_gaps(monkeypatch, shop, case, reason): + blind = Blind(monkeypatch, shop, {"销售额": (RIGHT, "gmv")}) + if case == "merge": + art = await snapshot("合并查询", sources=[shop]) + item = metric(art) + elif case == "rows": + art = await snapshot(shop, rows=((100.0,), (230.0,))) + item = metric(art, path="vars.kpi.weeks", expression="sum(vars.kpi.weeks)", + locator={"rows": [0, 1], "column": "gmv"}, value="[2 项]") + elif case == "masked": + art = await snapshot(shop, columns=("secret_gmv",)) + item = metric(art, locator={"row": 0, "column": "secret_gmv"}) + else: + art = await snapshot(shop, columns=("gmv",), rows=(("三百三",),)) + item = metric(art, value="三百三") # 指标值是数,输入却是文本(比如拿它做了判断) + ctx = context(AGENT_GRAPH) + state = {"nodes": {"agent": {"model": "other"}, "card": card_of(item)}, "evidence": [], "trail": []} + out = (await run_dual_check(state, ctx))["nodes"]["dual"] + [row] = out["metrics"] + assert row["status"] == "unchecked" and reason in row["reason"] + assert out["issuance_gaps"] == [f"指标「销售额」无法复算:{row['reason']}"] + assert blind.calls == [], "不能单独复算的输入也花了钱" + + +async def test_a_card_that_did_not_run_passes_through(shop): + ctx = context(AGENT_GRAPH) + out = await run_dual_check({"nodes": {}, "evidence": [], "trail": []}, ctx) + assert out == {"nodes": {"dual": {"status": "skipped", "reason": "no_inputs"}}} + assert [d["reason"] for d in ctx.logs("dual_check_skipped")] == ["no_inputs"] + + +async def test_unknown_metric_ids_fail_the_node(shop): + art = await snapshot(shop) + graph = {**AGENT_GRAPH, "nodes": [*AGENT_GRAPH["nodes"][:3], node("dual", "dual_check", "复核", metrics=["refund"]), + AGENT_GRAPH["nodes"][4]]} + ctx = context(graph) + with pytest.raises(NodeError, match="「复算的指标」中的「refund」不是口径卡上的指标 ID"): + await run_dual_check({"nodes": {"card": card_of(metric(art))}, "evidence": [], "trail": []}, ctx) diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py new file mode 100644 index 00000000..286ec05d --- /dev/null +++ b/backend/tests/test_dual_check_facts.py @@ -0,0 +1,198 @@ +"""双盲复算的选择写进数据目录(engine/dual_check_facts.py)。 + +- 只差一个方面、又对得上目录事实时才写:时间列 → 业务日期,一边多一个只涉及一张表的筛选条件 → 有效记录条件, + 聚合只差去不去重 → 去重规则;按被采纳那一边的写法记 +- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法:不写,照实说原因 +- 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖, + 推断的不同说法照改 + +差异条目一律由 sql_semantics.explain_sql_diff 真算出来,和节点交给审核人看的是同一份。表名、数字都是虚构的。 +""" +from __future__ import annotations + +import pytest + +from app.data import catalog +from app.data.sql_semantics import explain_sql_diff +from app.db.base import SessionLocal +from app.engine.dual_check_facts import derive, record_choices +from tests.fixtures.dual_check_shop import ( + BOTH_WRONG, + BUYERS, + BUYERS_DISTINCT, + BY_ORDER_DATE, + RIGHT, + WITH_REFUNDS, + add_source, + build, +) +from tests.fixtures.sources import drop_source + +SCHEMA = {"tables": {"orders": {"name": "orders", "columns": [ + {"name": "id", "type": "INTEGER"}, {"name": "region", "type": "TEXT"}, {"name": "customer_id", "type": "INTEGER"}, + {"name": "amount", "type": "REAL"}, {"name": "status", "type": "TEXT"}, {"name": "order_date", "type": "TEXT"}, + {"name": "paid_date", "type": "TEXT"}], "primary_key": ["id"]}}} + + +def candidate(original: str, blind: str, choice: str, *, metric: str = "销售额", source: str = "shop") -> dict: + diff = explain_sql_diff(original, blind, "sqlite", schema=SCHEMA, labels=("原流程", "盲算")).as_dict() + return {"metric": metric, "choice": choice, "source": source, "dialect": "sqlite", + "sql": {"a": original, "b": blind}, "diff": diff} + + +# -------------------------------------------------------------------------- +# 差异 → 目录事实 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("choice, column", [("b", "paid_date"), ("a", "order_date")]) +def test_a_different_time_column_becomes_the_business_date(choice, column): + fact, why = derive(candidate(BY_ORDER_DATE, RIGHT, choice), SCHEMA) + assert why is None + assert (fact.table, fact.item, fact.value, fact.choice) == ("orders", "business_date", {"column": column}, choice) + + +def test_an_extra_filter_on_the_adopted_side_becomes_the_valid_filter(): + fact, why = derive(candidate(WITH_REFUNDS, RIGHT, "b"), SCHEMA) + assert why is None and (fact.item, fact.value) == ("valid_filter", "status <> 'refunded'") + # 原流程多出这个条件、审核人采纳原流程:同样记这个条件 + fact, why = derive(candidate(RIGHT, WITH_REFUNDS, "a"), SCHEMA) + assert why is None and (fact.item, fact.value) == ("valid_filter", "status <> 'refunded'") + + +def test_the_adopted_side_without_the_extra_filter_has_nothing_to_record(): + fact, why = derive(candidate(RIGHT, WITH_REFUNDS, "b"), SCHEMA) + assert fact is None and "被采纳的盲算没有这个条件" in why and "销售额" in why + + +def test_table_names_are_dropped_from_the_recorded_filter(): + qualified = RIGHT.replace("FROM orders WHERE status", "FROM orders o WHERE o.status") \ + .replace("paid_date", "o.paid_date") + fact, why = derive(candidate(WITH_REFUNDS, qualified, "b"), SCHEMA) + assert why is None and fact.value == "status <> 'refunded'" + + +@pytest.mark.parametrize("choice, text", [("b", "对 customer_id 计数时去重"), ("a", "对 customer_id 计数时不去重")]) +def test_counting_with_or_without_distinct_becomes_the_dedup_rule(choice, text): + fact, why = derive(candidate(BUYERS, BUYERS_DISTINCT, choice, metric="付款客户数"), SCHEMA) + assert why is None and (fact.table, fact.item, fact.value) == ("orders", "dedup", text) + + +def test_more_than_one_difference_is_not_recorded(): + fact, why = derive(candidate(BOTH_WRONG, RIGHT, "b"), SCHEMA) + assert fact is None and "不止差一个方面" in why + + +def test_equivalent_sql_or_fallback_or_other_aspects_are_not_recorded(): + same = candidate(RIGHT, RIGHT.replace("SUM(amount) AS gmv", "SUM(amount) AS total"), "b") + assert same["diff"]["equivalent"] is True + assert "规范化后一致" in derive(same, SCHEMA)[1] + + fallback = candidate(BY_ORDER_DATE, RIGHT, "b") + fallback["diff"]["fallback"] = True + assert "未能归纳" in derive(fallback, SCHEMA)[1] + + joined = candidate(RIGHT, RIGHT.replace("SUM(amount)", "MAX(amount)"), "b") + assert [i["aspect"] for i in joined["diff"]["items"]] == ["aggregate"] + assert "不只是去不去重" in derive(joined, SCHEMA)[1] + + ranged = candidate(RIGHT, RIGHT.replace("'2026-09-07'", "'2026-09-06'"), "b") + assert [i["aspect"] for i in ranged["diff"]["items"]] == ["time_range"] + assert "时间范围" in derive(ranged, SCHEMA)[1] + + assert "没有采用任何一边" in derive({**candidate(BY_ORDER_DATE, RIGHT, "b"), "choice": "neither"}, SCHEMA)[1] + + +def test_a_table_missing_from_the_schema_is_not_recorded(): + fact, why = derive(candidate(BY_ORDER_DATE, RIGHT, "b"), {"tables": {"refunds": {"columns": []}}}) + assert fact is None and "不在数据源的表结构里" in why + + +# -------------------------------------------------------------------------- +# 写进目录 +# -------------------------------------------------------------------------- + + +@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) + + +async def entry_of(source_id: str): + async with SessionLocal() as session: + return await catalog.read_entry(session, source_id, "orders") + + +async def seed(source_id: str, notes: dict) -> None: + async with SessionLocal() as session: + await catalog.write_entry(session, source_id, "orders", notes, if_version=0, actor="数据组") + + +async def test_a_choice_is_written_as_a_confirmed_human_item_with_its_origin(shop): + name, source_id = shop + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source=name)], run_id="run-42", + node_title="复核销售额", actor="张三") + assert out == {"written": [{"source": name, "table": "orders", "item": "business_date"}], "skipped": []} + entry = await entry_of(source_id) + item = entry.notes["business_date"] + assert item["value"] == {"column": "paid_date"} and (item["source"], item["status"]) == ("human", "confirmed") + assert "run-42" in item["note"] and "「复核销售额」" in item["note"] and "「销售额」" in item["note"] + assert "盲算" in item["note"] and entry.version == 1 and entry.updated_by == "张三" + + +async def test_writing_the_same_choice_again_changes_nothing(shop): + name, source_id = shop + choice = [candidate(BY_ORDER_DATE, RIGHT, "b", source=name)] + await record_choices(choice, run_id="run-1", node_title="复核", actor=None) + again = await record_choices(choice, run_id="run-2", node_title="复核", actor=None) + assert again["written"] == [] and "无需重复写入" in again["skipped"][0]["reason"] + entry = await entry_of(source_id) + assert entry.version == 1 and "run-1" in entry.notes["business_date"]["note"] + + +async def test_a_confirmed_different_item_is_kept(shop): + name, source_id = shop + await seed(source_id, {"business_date": catalog.make_item({"column": "order_date", "rule": "按下单日"}, "human", + "confirmed")}) + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [] + reason = out["skipped"][0]["reason"] + assert "已有人工确认过的说法「order_date」" in reason and "paid_date" in reason and "未覆盖" in reason + entry = await entry_of(source_id) + assert entry.version == 1 and entry.notes["business_date"]["value"]["column"] == "order_date" + + +async def test_an_inferred_different_item_is_replaced(shop): + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("status = 'paid'", "llm")}) + out = await record_choices([candidate(WITH_REFUNDS, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}] + item = (await entry_of(source_id)).notes["valid_filter"] + assert item["value"] == "status <> 'refunded'" and (item["source"], item["status"]) == ("human", "confirmed") + + +async def test_the_same_column_inferred_is_confirmed_and_keeps_its_rule(shop): + """目录里推断的业务日期正是这一列(还写了规则、时区):确认它,规则、时区原样留着。""" + name, source_id = shop + await seed(source_id, {"business_date": catalog.make_item( + {"column": "paid_date", "rule": "按付款成功时间", "timezone": "Asia/Shanghai"}, "profile")}) + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "business_date"}] + item = (await entry_of(source_id)).notes["business_date"] + assert item["value"] == {"column": "paid_date", "rule": "按付款成功时间", "timezone": "Asia/Shanghai"} + assert (item["source"], item["status"]) == ("human", "confirmed") + + +async def test_a_missing_source_or_an_unrecordable_choice_is_reported(shop): + name, _ = shop + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source="no_such_source"), + candidate(BOTH_WRONG, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [] + assert "数据源「no_such_source」已不存在" in out["skipped"][0]["reason"] + assert "不止差一个方面" in out["skipped"][1]["reason"] diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py new file mode 100644 index 00000000..7f3bc783 --- /dev/null +++ b/backend/tests/test_dual_check_node.py @@ -0,0 +1,571 @@ +"""双盲复算节点(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 +- 不一致:挂起,审批 payload 和对照工件按约定;a / b / neither 三种选择的产出和缺口,on_reject 为 fail 时失败 +- 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖 +- 全局关闭直通、节点覆盖;设置按发起时的快照;重放时盲算不再执行、事件不重复;报告的写作目录不含盲算的查询 +- 触顶、分组结果的维度、两个指标用到同一格只盲算一次 +""" +from __future__ import annotations + +import asyncio + +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, Run, RunEvent, Setting +from app.engine import spend +from app.engine.launch import launch_run +from app.engine.runner import run_manager +from app.main import app +from tests.fixtures.dual_check_shop import ( + BOTH_WRONG, + 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 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, **kwargs) -> Run: + run = await run_manager.start(graph=spec, input_payload={}, **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 + [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"}] + # 用量计入运行:图里只有盲算调用了模型 + 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 + + +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")}) + run = await start(graph(name, BY_REGION, metrics=[metric], dual={"definitions": {}})) + assert run.status == "succeeded", run.error + [task] = blind.tasks + assert "region 为「华东」" in task and "(未提供" in task + out = dual_output(run, await events(run.id)) + assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed" + + +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): + 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) + assert [(d["limit"], d["metric"]) for d in logs(evs, "dual_check_limit")] == [("max_queries", "gmv")] + out = dual_output(run, evs) + assert out["status"] == "agreed" and out["metrics"][0]["status"] == "unchecked" + assert out["issuance_gaps"] == ["指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] + assert not [e for e in evs if e.type == "human.requested"] + assert run.output["_issuance"]["tier"] == "degraded" + assert run.output["_issuance"]["gaps"] == ["双盲复算「复核销售额」:指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] + + +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 + out = dual_output(run, await events(run.id)) + assert [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 + + +# -------------------------------------------------------------------------- +# 不一致:挂起,按约定的 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 + assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary"} + assert payload["kind"] == "dual_check" and payload["node_id"] == "dual" and payload["mode"] == "input" + [item] = payload["summary"] + assert set(item) == {"metric_id", "name", "unit", "original_rendered", "blind_rendered", "delta_pct", "diff", + "fallback"} + 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] + + 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"} + 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"] + 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_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"}] 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_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_counts_as_neither(monkeypatch, shop, client): + name, _ = shop + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + run = await start(graph(name, BY_ORDER_DATE)) + done = await decide(client, run.id, {"gmv": "maybe"}) + out = dual_output(done, await events(run.id)) + assert out["choices"] == {"gmv": "neither"} + assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人判断两边都不对"] + + +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"})) + 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 + + +# -------------------------------------------------------------------------- +# 写数据目录:只差一个方面才写,确认过的不覆盖 +# -------------------------------------------------------------------------- + + +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)) + done = await decide(client, run.id, {"gmv": "b"}) + [wrote] = logs(await events(done.id), "dual_check_catalog") + assert wrote["written"] == [] + 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 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) diff --git a/backend/tests/test_issuance_upstream_gaps.py b/backend/tests/test_issuance_upstream_gaps.py index 3427336d..cc6929c8 100644 --- a/backend/tests/test_issuance_upstream_gaps.py +++ b/backend/tests/test_issuance_upstream_gaps.py @@ -86,8 +86,9 @@ async def test_upstream_gap_degrades_issuance_and_names_the_node(): async def test_stub_gate_nodes_pass_through_without_touching_issuance(): - """双盲复算、反方审稿目前是占位:放在图里照样能运行,出具不受影响""" - for kind in ("dual_check", "challenge"): + """反方审稿目前是占位:放在图里照样能运行,出具不受影响。双盲复算已经实现,没有可复算的指标时直接放行、 + 不碰出具,见 test_dual_check_node.py""" + for kind in ("challenge",): row = await finish(graph(node("gate", kind))) issuance = row.output["_issuance"] assert issuance["tier"] == "formal", (kind, issuance) From ce19b074cfb5ca9256a55c1a1343dbc7f738dbeb Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:27:19 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=EF=BC=9A=E7=9B=B2=E7=AE=97=20agent=20=E7=9A=84=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=87=8C=E5=B8=A6=E4=B8=8A=E6=9C=AC=E6=AC=A1=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=E8=BE=93=E5=85=A5=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原流程的 SQL 常常是拿运行输入拼出来的(统计期的起止、地区),盲算 agent 只看口径说明不知道算哪一期,就算不出同一个数。任务里 加一行本次运行输入里的标量参数(最多 12 项、每项截到 40 字),只给参数、不给 SQL。分组结果的测试顺带核对任务里有统计期参数、 没有原流程算出的数。 --- backend/app/engine/nodes/dual_check.py | 26 +++++++++++++++++++++++--- backend/tests/test_dual_check_node.py | 13 ++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 83fa9cb1..3717c372 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -68,9 +68,10 @@ DIFF_TIMEOUT_S = 15.0 #: 交给盲算 agent 的数据目录最多这么多字(表清单、各表的说明分开算),再多就请它自己用 db_schema 查 _INDEX_CHARS = 3_000 _NOTES_CHARS = 6_000 -#: 交给盲算 agent 的同一行其他列最多几列、每个值最多几个字 +#: 交给盲算 agent 的同一行其他列、运行输入最多几项、每个值最多几个字 _DIMS = 6 _DIM_CHARS = 40 +_INPUTS = 12 #: 卡上给容器输入记的大概(metrics._brief):「[12 项]」「{3 个字段}」,写进计算式时不当成字面量 _BRIEF = re.compile(r"^(?:\[\d+ 项\]|\{\d+ 个字段\})$") @@ -102,13 +103,15 @@ BLIND_TASK_PROMPT = ( "口径说明:{definition}\n" "计算式:{formula}\n" "你要算的是计算式里的 {label}{whole}:原流程里它是一次查询结果中「{column}」列的值{dims}。只交这一个数。\n" - "数据源:{source}\n\n" + "数据源:{source}\n" + "{inputs}\n" "{catalog}" ) BLIND_UNIT_PROMPT = "(单位:{unit})" BLIND_NO_DEFINITION_PROMPT = "(未提供,按指标名称和数据目录理解)" BLIND_WHOLE_PROMPT = "(这个指标就是它本身)" BLIND_DIMS_PROMPT = ",所在行的其他列:{dims}" +BLIND_INPUTS_PROMPT = "本次运行的输入(统计期之类的参数在这里):{inputs}\n" BLIND_INDEX_PROMPT = "数据源里的表(表名|中文名|粒度):\n{index}" BLIND_NOTES_PROMPT = "数据目录(标「推断,未确认」的项是推断,使用前请核实):\n{notes}" BLIND_NO_CATALOG_PROMPT = "这个数据源还没有数据目录,请用 db_schema 工具查看表结构。" @@ -351,10 +354,13 @@ async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, An sources = _Sources() load, outputs = _loader(state) aliases = _original_aliases(state) + inputs = _run_inputs(state) rows: list[dict[str, Any]] = [] jobs: dict[str, dict[str, Any]] = {} for metric in targets: row, metric_jobs = await _plan_metric(state, metric, options, sources, aliases, load, outputs) + for job in metric_jobs.values(): + job["inputs"] = inputs rows.append(row) for key, job in metric_jobs.items(): # 两个指标用到同一格只盲算一次,按先出现的那个指标说清要算什么;先出现的没写口径说明、后一个写了,用后一个 @@ -373,6 +379,19 @@ async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, An return {"rows": rows, "results": results, "model": model, "same_model": same, "usage": usage} +def _run_inputs(state: GraphState) -> list[str]: + """这次运行的输入里的标量(统计期、地区这类参数):原流程的 SQL 常常是拿它们拼出来的,盲算 agent 不知道统计期 + 就算不出同一个数。只给参数,不给 SQL。""" + out: list[str] = [] + for key, value in (state.get("input") or {}).items(): + if isinstance(key, str) and key and (value is None or isinstance(value, (str, int, float, bool))): + text = str(value).replace("\n", " ") + out.append(f"{key} = {text[:_DIM_CHARS]}") + if len(out) >= _INPUTS: + break + return out + + def _original_aliases(state: GraphState) -> dict[str, str]: """原流程的查询快照在这次运行的证据台账里的编号(Q1、Q2…,和报告取全部上游查询时的编号一致)。""" ledger = [e for e in state.get("evidence") or [] if isinstance(e, dict)] @@ -576,7 +595,8 @@ def _task_text(job: dict[str, Any], catalog: str) -> str: definition=job["definition"] or BLIND_NO_DEFINITION_PROMPT, formula=job["formula"], label=job["label"], whole=BLIND_WHOLE_PROMPT if job["formula"].strip() == job["label"] else "", column=job["column"], dims=BLIND_DIMS_PROMPT.format(dims="、".join(job["dims"])) if job["dims"] else "", - source=job["source"], catalog=catalog) + source=job["source"], catalog=catalog, + inputs=BLIND_INPUTS_PROMPT.format(inputs=";".join(job["inputs"])) if job.get("inputs") else "") async def _blind(ctx: NodeContext, jobs: dict[str, dict[str, Any]], options: _Options, settings: Mapping[str, Any], diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 7f3bc783..09392a85 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -106,8 +106,8 @@ async def wait(run_id: str, statuses=("succeeded", "failed", "interrupted")) -> raise AssertionError(f"运行 {run_id} 停在 {row.status}:{row.error}") -async def start(spec: dict, **kwargs) -> Run: - run = await run_manager.start(graph=spec, input_payload={}, **kwargs) +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) @@ -185,14 +185,17 @@ async def test_agreeing_check_passes_without_asking(monkeypatch, shop): async def test_grouped_rows_tell_the_blind_agent_which_group(monkeypatch, shop): - """结果分了组:盲算 agent 要知道算的是哪一组(同一行的维度列),但看不到同一行别的数。""" + """结果分了组:盲算 agent 要知道算的是哪一组(同一行的维度列),但看不到同一行别的数;统计期这类参数在运行输入里。""" name, _ = shop metric = {**GMV, "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} blind = Blind(monkeypatch, name, {"华东销售额": (BY_REGION, "gmv")}) - run = await start(graph(name, BY_REGION, metrics=[metric], dual={"definitions": {}})) + 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 "region 为「华东」" in task and "(未提供" in task + assert "region 为「华东」" in task and "(未提供" in task and "300" 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" From e353706498b892a98e2e781fd0da334c2a92a04d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:31:15 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=86=99=E7=9B=AE=E5=BD=95=EF=BC=9A=E4=BA=BA=E5=B7=A5=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E8=BF=87=E7=9A=84=E5=90=8C=E4=B8=80=E8=AF=B4=E6=B3=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E5=9B=9E=EF=BC=9B=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E8=A1=A5=E4=B8=8A=E7=9B=B2=E7=AE=97=20agent?= =?UTF-8?q?=20=E7=9C=8B=E5=BE=97=E5=88=B0=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据目录里这一项以前被人明确驳回、驳回的正是这次要写的说法:两次人工判断对不上,不替人拿主意,不写并照实说原因;驳回的是 别的说法时照写。业务日期只按哪一列比,规则、时区是补充。 - 节点模块说明和盲算的系统提示补上:结果分了组的另给那一行的维度,统计期看本次运行的输入。 --- backend/app/engine/dual_check_facts.py | 16 ++++++++++++++-- backend/app/engine/nodes/dual_check.py | 11 ++++++----- backend/tests/test_dual_check_facts.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index ccbbabfb..5cbd3f34 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -229,6 +229,13 @@ def _value_text(item: str, value: Any) -> str: return str(value) +def _same_fact(item: str, a: Any, b: Any) -> bool: + """目录里的一项和要写的是不是同一个说法:业务日期只看哪一列(规则、时区是补充),文字压掉多余的空白比。""" + if item == "business_date": + return isinstance(a, Mapping) and isinstance(b, Mapping) and a.get("column") == b.get("column") + return isinstance(a, str) and isinstance(b, str) and " ".join(a.split()) == " ".join(b.split()) + + async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str, actor: str | None) -> dict[str, list[dict[str, Any]]]: """把审核人的选择写进数据目录:{written: [{source, table, item}], skipped: [{reason}]}。永远不抛:数据源没了、 @@ -268,8 +275,13 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: entry = await catalog.read_entry(session, row.id, fact.table) notes = entry.notes if entry else {} version = entry.version if entry else 0 - live = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None - live = live if live is not None and live.get("status") != "rejected" else None + stored = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None + if stored is not None and stored.get("status") == "rejected" and _same_fact(fact.item, stored.get("value"), + fact.value): + # 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意 + return False, (f"「{fact.table}」的{label}「{_value_text(fact.item, fact.value)}」曾被人工驳回," + f"与这次指标「{fact.metric}」的选择相反,未写入。如需更改,请到数据目录中修改") + live = stored if stored is not None and stored.get("status") != "rejected" else None value = fact.value if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \ and live["value"].get("column") == value.get("column"): diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 3717c372..596b211a 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -2,9 +2,10 @@ **按输入复算。** 指标由口径卡的原式算出,原式本身确定、可复算(metrics 的 recompute_ok);会错的是喂给它的那几个数: 查哪张表、按哪天归属、排没排除退款……所以盲算的单位是「一个来自查询快照的输入」。盲算 agent(engine/verify_agent.py) -只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项、数据源的数据目录,看不到原 SQL 和 -原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询快照里取值。盲值是拿原式代入盲算的输入算出来的 -(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时按绝对误差。两个指标用到同一格的只盲算一次。 +只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项(结果分了组的另给那一行的维度)、本次 +运行的输入参数和数据源的数据目录,看不到原 SQL 和原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询 +快照里取值。盲值是拿原式代入盲算的输入算出来的(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时 +按绝对误差。两个指标用到同一格的只盲算一次。 **一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff), 完整对照落成一件工件(审批 payload 经 runner._safe 会截断,长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用 @@ -94,8 +95,8 @@ BLIND_SYSTEM = ( "计算式里的一个数,交给人和原流程比对。\n" "- 业务含义只按下面给的指标名称、口径说明和数据目录理解。数据目录里人工确认过的说明(业务日期按哪一列、有效记录" "条件、去重规则)要遵守,标「推断,未确认」的谨慎使用\n" - "- 时间范围、要排除哪些行、要不要去重,都按口径说明来;口径说明没写到的,按数据目录和最常见的业务理解,并在理由里" - "写明你的取舍\n" + "- 时间范围(统计期看本次运行的输入)、要排除哪些行、要不要去重,都按口径说明来;口径说明没写到的,按数据目录和" + "最常见的业务理解,并在理由里写明你的取舍\n" "- 尽量用一条 SQL 直接算出要交的那个数(聚合放在 SQL 里做),最后交出它所在的格子" ) BLIND_TASK_PROMPT = ( diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 286ec05d..6ecd32a2 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -188,6 +188,21 @@ async def test_the_same_column_inferred_is_confirmed_and_keeps_its_rule(shop): assert (item["source"], item["status"]) == ("human", "confirmed") +async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_does_not_block(shop): + """同一个说法以前被人驳回过:两次人工判断对不上,不写;驳回的是别的说法:照写。""" + name, source_id = shop + await seed(source_id, {"business_date": catalog.make_item({"column": "paid_date"}, "llm", "rejected")}) + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [] and "曾被人工驳回" in out["skipped"][0]["reason"] + assert (await entry_of(source_id)).version == 1 + + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "a", source=name)], run_id="run-2", + node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "business_date"}] + assert (await entry_of(source_id)).notes["business_date"]["value"] == {"column": "order_date"} + + async def test_a_missing_source_or_an_unrecordable_choice_is_reported(shop): name, _ = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source="no_such_source"), From 1491b5c7f389f31933e29efba80a18ecab8e054b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 15:54:03 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E6=95=B4=E6=94=B9=EF=BC=9A=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E7=AD=9B=E9=80=89=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E6=88=90=E6=9C=89=E6=95=88=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=9D=A1=E4=BB=B6=EF=BC=8C=E7=9B=B2=E7=AE=97=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=9C=8B=E5=88=B0=E5=8E=9F=E6=B5=81=E7=A8=8B=E7=9A=84?= =?UTF-8?q?=E5=88=97=E5=90=8D=EF=BC=8C=E5=B7=AE=E5=BC=82=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=88=B0=E8=BF=99=E4=B8=80=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写数据目录(dual_check_facts.py) - 有效记录条件只收「排除无效记录」的条件:被排除的取值是退款、作废、测试这类(或码值表里写着这类意思), 标记列只认留下有效记录的一边(is_test = 0、is_valid = 1、deleted_at IS NULL);取值出现在指标名称、口径说明、 所在分组、运行输入里的,用到分组列的,一律不写,原因写明「可能是指标自己的筛选范围」。以前「华东销售额」的 region = '华东' 会被记成订单表人工确认过的有效记录条件,之后所有查订单表的工作流(含下一次盲算)都只看华东 - 目录里已有的有效记录条件被采纳的一边也按它筛了的,和新条件合起来写(已确认的不改,照实说);没按它筛的不动。 以前整项替换,两边都用着的 is_test = 0 会被静默丢掉。推断的去重规则说法不同的也不替换 - 业务日期只在两边都按时间筛、按的列不同时才写;一边根本没按时间筛的不写 - 采纳了不去重的一边不写去重规则:那是指标自己的算法,写成「不去重」会把别的指标带偏 - 同一次裁决里几个指标对同一项给出矛盾的说法,整组都不写 节点(nodes/dual_check.py) - 盲算任务里不再给原流程结果的列名和同一行的维度列名:分组的只给取值(日期说成「2026-09-02 这一天」), 原 SQL 写死的文字列连取值也不给;只有几项输入都来自查询又没写口径说明、列名看不出取舍时才给列名。 以前「order_date 为 2026-09-02」等于告诉盲算按下单日归属,下单日和支付日的陷阱正好漏掉 - 差异说明先把两条 SQL 收窄到这一格(只留算出这一列的那一项,分组的按这一行的分组值改成等值条件)再比, 多列查询、分组取一行时不再混进「选出的列」「分组」这类噪声,只差一个方面的也写得进目录 - 产出状态:全部一致才是 agreed,一个都没核成是 unchecked,核成一部分是 partial;产出和台账加 counts; 无法复算的指标发 dual_check_unchecked {metrics: [{id, name, reason}]} - 共用一格的几个指标触顶时每个指标各发一条 dual_check_limit - 查询次数刚好用完、交回的格子是真查出来的照常比对(时长、金额触顶仍按无法复算) - 审核人没选或选了认不出的照实记「审核人没有对指标作出选择」,不再替人记成「两边都不对」 - 盲算先单独做第一个,其余最多 3 个同时进行,避免串行撞上运行单段的时长上限 配置 - 画布校验:双盲复算配了失败重试报错(重试会把盲算整轮重做、再问审核人一次);enabled 用 is 判断,0、1 不再放过 - 发起时的核查设置快照:子工作流(按运行时的取法读)里有双盲复算、反方审稿节点时也快照 - 助手节点说明补一句不要给双盲复算配 retries 测试:补上遮罩两道防线、规范化后一致但值不同、两个输入都对不上、只复算配置的指标、原值为 0、摘要最多 3 条、 驳回、多列查询、分组取一行、指标筛选范围不写目录等用例;每条核心改动都做过去掉实现、测试变红的验证 --- backend/app/api/copilot.py | 3 +- backend/app/engine/dual_check_facts.py | 433 ++++++++++++++++++--- backend/app/engine/launch.py | 42 ++- backend/app/engine/nodes/dual_check.py | 438 ++++++++++++++++++---- backend/app/engine/schema.py | 19 +- backend/tests/fixtures/dual_check_shop.py | 38 +- backend/tests/test_dual_check_config.py | 49 ++- backend/tests/test_dual_check_executor.py | 49 +++ backend/tests/test_dual_check_facts.py | 202 +++++++++- backend/tests/test_dual_check_node.py | 321 +++++++++++++++- 10 files changed, 1404 insertions(+), 190 deletions(-) diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index fdb042d6..32994055 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -224,7 +224,8 @@ NODE_REFERENCE = """\ 全部指标);definitions 是 {指标 id: 口径说明},写清业务定义(按哪个日期归属、排除哪些行、是否去重),盲算 agent 只看得到它和数据目录,所以要按业务写,不要抄原 SQL;provider / model 指定盲算模型(**默认不写**,取设置里的核查 模型;建议和取数的 agent 不同);tolerance 是允许的相对误差(不写取设置);on_reject:degrade(默认,计入缺口、 - 出具降档)/ fail(审核人没采用原流程的结果就判为失败);enabled 不写跟随设置里的开关,true / false 强制开关 + 出具降档)/ fail(审核人没采用原流程的结果就判为失败);enabled 不写跟随设置里的开关,true / false 强制开关。 + 不要给它配 retries:它的失败是确定的,重试只会把盲算再做一遍、再请审核人选一次(画布校验会报错) - report:报告撰写(带引用)。config: {instructions, system, metrics_from:[口径卡id], numbers, on_violation, max_repairs, claims, entities, assign_to} **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游的口径卡指标、查询结果 diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 5cbd3f34..c1ad8ae1 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -3,24 +3,37 @@ 对得上的事实只有三种(data/catalog.py 的表级项): -- 两边按不同的时间列筛时间 → 业务日期(business_date):被采纳的一边按哪一列筛,业务日期就记哪一列 -- 一边多一个只涉及底层表列的筛选条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件 -- 聚合只差去不去重 → 去重规则(dedup):按被采纳那一边去不去重写一句 +- 两边按不同的时间列筛时间 → 业务日期(business_date):被采纳的一边按哪一列筛,业务日期就记哪一列。只有一边按时间 + 筛的不算:审核人判断的是要不要限定时间,没在两列之间选过 +- 一边多一个排除无效记录的条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件 +- 聚合只差去不去重、被采纳的一边去重 → 去重规则(dedup)。采纳了不去重的一边不记:那是这个指标自己的算法(数行), + 写成表的「不去重」会把别的指标带偏 -**为什么要这么严。** 目录是给助手、db_schema 工具和 SQL 检查看的数据事实,人工确认过的项还会让 SQL 检查报错误级。 -只差一个方面时,人选的那一边就是对这一处的判断;差了好几处、或者差异说明里有兜底(未能归纳的差异),人选的是 -「整体上哪边对」,拆不出是哪一处对,写进去就是替人编了一条事实。两条 SQL 规范化后一致却算出不同的值(两次取数之间 -数据变了、结果被截断),差在数据不在写法,更没有可记的事实。 +**为什么要这么严。** 目录是给助手、db_schema 工具、SQL 检查和下一次盲算看的数据事实,人工确认过的项还会让 SQL 检查 +报错误级、以后的推断也不再覆盖它,写错了就一直错下去。只差一个方面时,人选的那一边就是对这一处的判断;差了好几处、 +或者差异说明里有兜底(未能归纳的差异),人选的是「整体上哪边对」,拆不出是哪一处对,写进去就是替人编了一条事实。 +两条 SQL 规范化后一致却算出不同的值(两次取数之间数据变了、结果被截断),差在数据不在写法,更没有可记的事实。 + +**有效记录条件和指标自己的筛选范围分不开。** 「华东销售额」的 region = '华东'、「线上订单数」的 channel = 'online' +在写法上和 status <> 'refunded' 一样是一个筛选条件,审核人选原流程只说明这个指标该这么筛,不说明整张表的有效记录 +都得这么筛。写成表的有效记录条件,所有查这张表的工作流(包括下一次盲算)都会被带偏。所以条件要同时满足三条才写: +条件里的取值没有出现在指标名称、口径说明、这一格所在的分组、本次运行的输入里,用到的列也不是分组的列;每一段都是在 +排除无效记录(排除的取值是退款、作废、测试这类,或者码值表里写着这类意思;标记列只认留下有效记录的那一边: +is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不准就不写,原因照实交回。 **怎么写。** 照对话提案的规矩核对(plan_patch:列在不在表结构里、取值格式),记为人工填写、已确认,备注写明来自哪次 运行、哪个节点、哪个指标。目录里这一项已经是人工确认过的另一种说法时不覆盖,照实交回原因;已经是同样的说法就什么都 -不写(再写一遍也不升版本)。整个写入由节点放在 ask() 之后的一个 @task 里:审批恢复重放、接着运行时不会再写一遍。 +不写(再写一遍也不升版本)。有效记录条件是一整句条件:目录里已有的条件被采纳的一边也按它筛了的,和新条件合起来写 +(已确认的不改,照实说);被采纳的一边没按它筛的,两者的关系拿不准,不动。推断的去重规则说的未必是同一件事,也不 +替换。同一次裁决里几个指标对同一项给出不同说法的,一个都不写。整个写入由节点放在 ask() 之后的一个 @task 里:审批 +恢复重放、接着运行时不会再写一遍。 catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 """ from __future__ import annotations import logging +import re from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -48,10 +61,25 @@ _VERB = {"count": "计数", "sum": "求和", "avg": "求平均"} #: 写入时版本对不上(别人刚改过这张表)最多重试几次 _RETRIES = 2 +#: 无效记录的取值:被排除的取值(或码值表里它的意思)里有这些词,才算在排除无效记录 +_INVALID_WORDS = frozenset({ + "refund", "refunded", "refunding", "cancel", "canceled", "cancelled", "void", "voided", "delete", "deleted", + "invalid", "test", "testing", "fraud", "spam", "dummy", "fake", "sandbox", "fail", "failed", "duplicate", + "duplicated", "obsolete", +}) +_INVALID_CJK = ("退款", "作废", "取消", "测试", "无效", "删除", "撤销", "失败", "重复", "虚假", "欺诈", "废弃") +#: 标记列:列名里有这些词的,标记的是无效记录(is_test、is_deleted、del_flag、deleted_at……),留下的是没标记的 +_MARKS_INVALID = _INVALID_WORDS | {"del", "removed", "hidden", "archived", "internal", "disabled", "inactive"} +#: 标记列:列名里有这些词的,标记的是有效记录(is_valid、enabled、is_active……),留下的是标记了的 +_MARKS_VALID = frozenset({"valid", "enabled", "active", "effective"}) +#: 取值和指标名称、口径说明比对时,数字至少这么长才比(0、1 这种标记值到处都是) +_SCOPE_DIGITS = 2 + @dataclass(frozen=True) class Fact: - """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明。""" + """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明;sql、dialect 是被采纳 + 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。""" source: str table: str @@ -59,13 +87,19 @@ class Fact: value: Any metric: str choice: str + sql: str = "" + dialect: str | None = None -def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None) -> tuple[Fact | None, str | None]: +def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, + notes: Mapping[str, Mapping[str, Any]] | None = None) -> tuple[Fact | None, str | None]: """一个候选(审核人在某个指标上选了 a 或 b)→ (要写的目录项, None) 或 (None, 为什么不写)。 - candidate:{metric 指标名, choice "a" | "b", source 数据源名, dialect, sql: {a, b}, diff: {equivalent, fallback, - items: [FacetDiff.as_dict()]}}。只看差异说明,不重新比 SQL:审核人看到的就是这份说明。 + candidate:{metric 指标名, choice "a" | "b", source 数据源名, dialect, sql: {a, b}(比较的两条 SQL), diff: + {equivalent, fallback, items: [FacetDiff.as_dict()]}, scope: {texts, columns}}。scope 是这个指标自己的范围: + texts 是口径说明、所在分组的取值、本次运行的输入(指标名称总算在内),columns 是分组的列;有效记录条件用它排除 + 指标自己的筛选范围。notes 是这个数据源的目录({表名: notes}),认码值的意思用。只看差异说明,不重新比 SQL:审核人 + 看到的就是这份说明。 """ name = str(candidate.get("metric") or "") side = candidate.get("choice") @@ -83,7 +117,7 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None) if aspect == "time": fact, why = _business_date(item, side) elif aspect == "filter": - fact, why = _valid_filter(item, side, candidate.get("dialect")) + fact, why = _valid_filter(item, side, candidate, schema_cache, notes or {}) elif aspect == "aggregate": fact, why = _dedup(item, side, candidate, schema_cache) else: @@ -95,21 +129,36 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None) resolved = catalog.resolve_table_name(schema_cache, table) if resolved is None: return None, f"指标「{name}」涉及的表「{table}」不在数据源的表结构里,不写入数据目录" + sql = candidate.get("sql") if isinstance(candidate.get("sql"), Mapping) else {} + dialect = candidate.get("dialect") return Fact(source=str(candidate.get("source") or ""), table=resolved, item=key, value=value, metric=name, - choice=str(side)), None + choice=str(side), sql=str(sql.get(side) or ""), + dialect=dialect if isinstance(dialect, str) else None), None def _business_date(item: Mapping[str, Any], side: str) -> tuple[tuple[str, str, Any] | None, str | None]: + other = "b" if side == "a" else "a" detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {} - mine = [c for c in detail.get(side) or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")] + + def columns(key: str) -> list[Mapping[str, Any]]: + return [c for c in detail.get(key) or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")] + + mine, theirs = columns(side), columns(other) if not mine: return None, f"被采纳的{SIDE_LABEL[side]}没有按时间筛选,没有可记的业务日期" - if len(mine) > 1: - return None, f"被采纳的{SIDE_LABEL[side]}按好几列筛时间,无法确定业务日期按哪一列" + if not theirs: + return None, (f"只有被采纳的{SIDE_LABEL[side]}按时间筛选,{SIDE_LABEL[other]}没有:审核人判断的是要不要限定" + "时间,不是按哪一列归属,没有可记的业务日期") + if len(mine) > 1 or len(theirs) > 1: + return None, "有一边按好几列筛时间,无法确定业务日期按哪一列" + if (mine[0]["table"], mine[0]["column"]) == (theirs[0]["table"], theirs[0]["column"]): + return None, "两边按同一列筛时间,没有可记的业务日期" return (str(mine[0]["table"]), "business_date", {"column": str(mine[0]["column"])}), None -def _valid_filter(item: Mapping[str, Any], side: str, dialect: Any) -> tuple[tuple[str, str, Any] | None, str | None]: +def _valid_filter(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], + schema_cache: Mapping[str, Any] | None, + notes: Mapping[str, Mapping[str, Any]]) -> tuple[tuple[str, str, Any] | None, str | None]: other = "b" if side == "a" else "a" condition, rival = item.get(side), item.get(other) if rival is not None: @@ -118,28 +167,209 @@ def _valid_filter(item: Mapping[str, Any], side: str, dialect: Any) -> tuple[tup return None, "两边各有不同的筛选条件,不是一边多出一个条件" if not isinstance(condition, str) or not condition.strip(): return None, "差异说明里没有被采纳一边的筛选条件" + condition = condition.strip() detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {} - tables = {str(c.get("table")) for c in detail.get("columns") or [] if isinstance(c, Mapping) and c.get("table")} + refs = [c for c in detail.get("columns") or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")] + tables = {str(c["table"]) for c in refs} if len(tables) != 1: return None, "筛选条件不是只涉及一张表的列" (table,) = tables - return (table, "valid_filter", _unqualified(condition.strip(), dialect)), None + read = _read(candidate.get("dialect")) + tree = _parse(condition, read) + if tree is None: + return None, f"多出的条件「{condition}」无法解析,无法确定是不是有效记录条件" + scope = candidate.get("scope") if isinstance(candidate.get("scope"), Mapping) else {} + grouped = {str(c).lower() for c in scope.get("columns") or [] if isinstance(c, str)} + if hit := sorted({str(c["column"]) for c in refs if str(c["column"]).lower() in grouped}): + return None, (f"多出的条件「{condition}」用到的「{'、'.join(hit)}」是原流程分组的列,可能是指标自己的筛选范围," + "不是整张表的有效记录条件") + texts = [str(candidate.get("metric") or ""), *(str(t) for t in scope.get("texts") or [] if isinstance(t, str))] + if (word := _scope_hit(tree, texts)) is not None: + return None, (f"多出的条件「{condition}」里的「{word}」出现在指标名称、口径说明、所在分组或本次运行的输入里," + "可能是指标自己的筛选范围,不是整张表的有效记录条件") + resolved = catalog.resolve_table_name(schema_cache, table) or table + if not _drops_invalid(tree, notes.get(resolved) or {}): + return None, (f"多出的条件「{condition}」不是在排除退款、作废、测试这类无效记录,可能是指标自己的筛选范围" + "(例如只看某个地区、某个渠道),不是整张表的有效记录条件") + return (table, "valid_filter", _unqualified(condition, candidate.get("dialect"))), None + + +# ---- 有效记录条件:每一段都在排除无效记录 + +def _read(dialect: Any) -> str | None: + from app.data.sqlcheck import dialect_of + + return dialect_of(dialect) if isinstance(dialect, str) else None + + +def _parse(condition: str, read: str | None) -> Any: + import sqlglot + + try: + return sqlglot.parse_one(condition, read=read) + except Exception: # noqa: BLE001 - 写法特殊、解析不了:调用方当作认不出 + return None + + +def _unparen(node: Any) -> Any: + from sqlglot import exp + + while isinstance(node, exp.Paren): + node = node.this + return node + + +def _and_parts(node: Any) -> list[Any]: + """条件按 AND 拆成几段(括号拆开)。""" + from sqlglot import exp + + node = _unparen(node) + if isinstance(node, exp.And): + return _and_parts(node.this) + _and_parts(node.expression) + return [node] + + +def _literal(node: Any) -> tuple[bool, Any]: + """常量的值:(是不是常量, 值)。字符串原样,数字转成 int / float,TRUE / FALSE 是 bool。""" + from sqlglot import exp + + node = _unparen(node) + if isinstance(node, exp.Boolean): + return True, bool(node.this) + negative = isinstance(node, exp.Neg) + if negative: + node = _unparen(node.this) + if not isinstance(node, exp.Literal): + return False, None + if node.is_string: + return (False, None) if negative else (True, str(node.this)) + try: + number = float(node.this) if any(ch in str(node.this) for ch in ".eE") else int(node.this) + except ValueError: + return False, None + return True, -number if negative else number + + +def _scope_hit(tree: Any, texts: list[str]) -> str | None: + """条件里的取值出现在指标自己的范围里(指标名称、口径说明、分组、运行输入):返回那个取值,没有返回 None。""" + from sqlglot import exp + + lowered = [t.lower() for t in texts if t.strip()] + for node in tree.find_all(exp.Literal): + word = str(node.this).strip() + if not word: + continue + if node.is_string: + if any(word.lower() in t for t in lowered): + return word + elif len(word) >= _SCOPE_DIGITS: + pattern = re.compile(rf"(? bool: + lowered = text.lower() + return any(w in _INVALID_WORDS for w in re.split(r"[^a-z0-9]+", lowered) if w) or \ + any(mark in text for mark in _INVALID_CJK) + + +def _invalid_value(value: Any, column: str, notes: Mapping[str, Any]) -> bool: + """被排除的取值是不是无效记录:取值本身('refunded'、'已作废'),或者码值表里它的意思(9 → 作废)。""" + if isinstance(value, bool): + return False + if isinstance(value, str) and _invalid_text(value): + return True + columns = notes.get("columns") if isinstance(notes.get("columns"), Mapping) else {} + entry = next((v for k, v in columns.items() if str(k).lower() == column.lower()), None) + codes = entry.get("codes") if isinstance(entry, Mapping) else None + if not isinstance(codes, Mapping) or codes.get("status") == "rejected" \ + or not isinstance(codes.get("value"), Mapping): + return False + key = str(int(value)) if isinstance(value, float) and value.is_integer() else str(value) + meaning = codes["value"].get(key) + return isinstance(meaning, str) and _invalid_text(meaning) + + +def _polarity(column: str) -> str | None: + """标记列标记的是什么:invalid(is_test、deleted_at)、valid(is_valid、enabled),不是标记列为 None。""" + words = catalog.name_words(column) + if any(w in _MARKS_INVALID for w in words): + return "invalid" + if any(w in _MARKS_VALID for w in words): + return "valid" + return None + + +def _flag_value(value: Any) -> bool | None: + """标记值:0 / 1 / TRUE / FALSE → bool,别的为 None。""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + return None + + +def _drops_invalid(tree: Any, notes: Mapping[str, Any]) -> bool: + """条件的每一段都在排除无效记录。""" + return all(_drops_invalid_part(part, notes) for part in _and_parts(tree)) + + +def _drops_invalid_part(node: Any, notes: Mapping[str, Any]) -> bool: + from sqlglot import exp + + node = _unparen(node) + negated = False + if isinstance(node, exp.Not): + negated, node = True, _unparen(node.this) + if isinstance(node, exp.NEQ): + negated, node = not negated, exp.EQ(this=node.this.copy(), expression=node.expression.copy()) + if isinstance(node, exp.EQ): + left, right = _unparen(node.this), _unparen(node.expression) + column, other = (left, right) if isinstance(left, exp.Column) else (right, left) + ok, value = _literal(other) + return isinstance(column, exp.Column) and ok and _keeps_valid(column.name, [value], negated, notes) + if isinstance(node, exp.In) and isinstance(_unparen(node.this), exp.Column) and not node.args.get("query"): + values = [_literal(e) for e in node.expressions] + return bool(values) and all(ok for ok, _ in values) and \ + _keeps_valid(_unparen(node.this).name, [v for _, v in values], negated, notes) + if isinstance(node, exp.Is) and isinstance(node.expression, exp.Null) \ + and isinstance(_unparen(node.this), exp.Column): + # 列 IS NULL 留下的是没标记的行:标记无效记录的列(deleted_at IS NULL)才是有效记录;IS NOT NULL 反过来 + polarity = _polarity(_unparen(node.this).name) + return polarity == ("valid" if negated else "invalid") + if isinstance(node, exp.Column): + # 布尔标记列直接当条件写:is_valid、NOT is_test + return _polarity(node.name) == ("invalid" if negated else "valid") + if isinstance(node, (exp.Like, exp.ILike)) and negated: + ok, pattern = _literal(node.expression) + return ok and isinstance(pattern, str) and _invalid_text(pattern) + return False + + +def _keeps_valid(column: str, values: list[Any], negated: bool, notes: Mapping[str, Any]) -> bool: + """列 = 取值(negated 时是 <>、NOT IN)留下的是不是有效记录。""" + polarity = _polarity(column) + flags = [_flag_value(v) for v in values] + if polarity is not None and all(f is not None for f in flags): + if len(set(flags)) != 1: + return False + # is_test = 1 留下的是标记了的行,is_test <> 1 留下的是没标记的;标记有效记录的列反过来 + keeps_marked = flags[0] != negated + return keeps_marked if polarity == "valid" else not keeps_marked + # 排除几个取值:被排除的都得是无效记录。只保留几个取值(status = 'paid')就是指标自己的范围 + return negated and all(_invalid_value(v, column, notes) for v in values) def _unqualified(condition: str, dialect: Any) -> str: """条件里的列去掉表名:有效记录条件记在这张表自己名下,写成 status <> 'refunded' 而不是 orders.status <> …。 解析不了就原样。""" - import sqlglot from sqlglot import exp - from app.data.sqlcheck import dialect_of - - read = dialect_of(dialect) if isinstance(dialect, str) else None - try: - tree = sqlglot.parse_one(condition, read=read) - except Exception: # noqa: BLE001 - 写法特殊、解析不了:差异说明里给人看的就是这一句,原样记 - return condition - if not any(col.table for col in tree.find_all(exp.Column)): + read = _read(dialect) + tree = _parse(condition, read) + if tree is None or not any(col.table for col in tree.find_all(exp.Column)): return condition for col in tree.find_all(exp.Column): col.set("table", None) @@ -149,9 +379,57 @@ def _unqualified(condition: str, dialect: Any) -> str: return condition +def _normalized(node: Any, read: str | None) -> str: + """一段条件的比较用写法:列去掉表名、列名小写,按方言重新写一遍(<> 和 !=、NOT IN 的两种写法认成一样)。""" + from sqlglot import exp + + copy = node.copy() + for col in list(copy.find_all(exp.Column)): + col.set("table", None) + col.set("this", exp.to_identifier(col.name.lower())) + return copy.sql(dialect=read) + + +def _conditions_of(sql: str, read: str | None) -> set[str] | None: + """一条 SQL 里所有 WHERE 的每一段(比较用写法);解析不了返回 None。""" + import sqlglot + from sqlglot import exp + + try: + tree = sqlglot.parse_one(sql, read=read) + except Exception: # noqa: BLE001 + return None + return {_normalized(part, read) for where in tree.find_all(exp.Where) for part in _and_parts(where.this)} + + +def _combined_filter(existing: str, fact: Fact) -> tuple[str | None, str | None]: + """目录里已有的有效记录条件和这次的新条件合起来:(合起来的整句条件, None) 或 (None, 为什么不动)。 + + 有效记录条件是一整句(copilot 提案也要求「原有条件也要带上」)。只写新条件会把两边都按着筛的原有条件静默替换掉; + 原有条件被采纳的一边没按它筛,审核人的选择说明不了它对不对,也不能替人删掉。""" + label = catalog.TABLE_FIELD_LABEL["valid_filter"] + read = _read(fact.dialect) + have_tree, new_tree = _parse(existing, read), _parse(str(fact.value), read) + if have_tree is None or new_tree is None: + return None, f"「{fact.table}」已有的{label}「{existing}」无法解析,与这次的条件合不起来,未改动" + have = [_normalized(p, read) for p in _and_parts(have_tree)] + used = _conditions_of(fact.sql, read) or set() + if not set(have) <= used: + return None, (f"「{fact.table}」已有的{label}「{existing}」,被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" + "没有按它筛选,两者的关系无法确定,未改动。如需更改,请到数据目录中修改") + if {_normalized(p, read) for p in _and_parts(new_tree)} <= set(have): + return existing, None + from sqlglot import exp + + # 原有条件最外层是 OR 的,接 AND 之前要加括号,不然新条件只并到 OR 的后一半上 + head = f"({existing})" if isinstance(have_tree, exp.Or) else existing + return f"{head} AND {fact.value}", None + + def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None) -> tuple[tuple[str, str, Any] | None, str | None]: - """聚合只差去不去重(同一个函数、同一个参数,一边写了 DISTINCT)。表从被采纳那一边的 SQL 里认:参数列属于哪张实表。""" + """聚合只差去不去重(同一个函数、同一个参数,一边写了 DISTINCT),被采纳的一边去重。表从被采纳那一边的 SQL 里认: + 参数列属于哪张实表。""" from app.data.sql_semantics import facets pair = _aggregate_pair(item.get("a"), item.get("b"), candidate.get("dialect")) @@ -159,6 +437,9 @@ def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], return None, "两边的聚合不只是去不去重的差别" func, distinct = pair adopted = distinct[0] if side == "a" else distinct[1] + if not adopted: + return None, (f"被采纳的{SIDE_LABEL[side]}没有去重:不去重是这个指标自己的算法," + "不是表的去重规则") sql = (candidate.get("sql") or {}).get(side) if isinstance(candidate.get("sql"), Mapping) else None found = facets(str(sql or ""), candidate.get("dialect"), schema=schema_cache) hits = [a for a in found.aggregates if a.func == func and a.distinct == adopted and not a.scope] @@ -172,27 +453,19 @@ def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], if len(names) != 1: return None, "无法确定去重的列属于哪一张表" table = names[0] - verb = _VERB.get(func, func) - text = f"对 {column} {verb}时去重" if adopted else f"对 {column} {verb}时不去重" - return (str(table), "dedup", text), None + return (str(table), "dedup", f"对 {column} {_VERB.get(func, func)}时去重"), None def _aggregate_pair(a: Any, b: Any, dialect: Any) -> tuple[str, tuple[bool, bool]] | None: """两边的聚合写法 → (函数名, (A 去重吗, B 去重吗));不是「同一个函数、同一个参数、只差 DISTINCT」返回 None。""" - import sqlglot from sqlglot import exp - from app.data.sqlcheck import dialect_of - - read = dialect_of(dialect) if isinstance(dialect, str) else None + read = _read(dialect) def shape(text: Any) -> tuple[str, str, bool] | None: if not isinstance(text, str) or not text.strip(): return None - try: - node = sqlglot.parse_one(text, read=read) - except Exception: # noqa: BLE001 - return None + node = _parse(text, read) if not isinstance(node, exp.AggFunc): return None arg = node.this @@ -236,12 +509,23 @@ def _same_fact(item: str, a: Any, b: Any) -> bool: return isinstance(a, str) and isinstance(b, str) and " ".join(a.split()) == " ".join(b.split()) +def _contradiction(facts: list[Fact]) -> str: + """同一次裁决里对同一项给出的不同说法,说清是谁、各说了什么。""" + first = facts[0] + label = catalog.TABLE_FIELD_LABEL.get(first.item, first.item) + said = ";".join(f"指标「{f.metric}」采纳的是「{_value_text(f.item, f.value)}」" for f in facts[:4]) + return (f"同一次裁决里对「{first.table}」的{label}给出的选择互相矛盾({said}),无法确定哪一个是数据事实," + "都未写入数据目录") + + async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str, actor: str | None) -> dict[str, list[dict[str, Any]]]: """把审核人的选择写进数据目录:{written: [{source, table, item}], skipped: [{reason}]}。永远不抛:数据源没了、 - 目录写不进去,都落在 skipped 里照实说。""" - written: list[dict[str, Any]] = [] + 目录写不进去,都落在 skipped 里照实说。 + + 先把每个候选认成目录项,再按(数据源、表、项)归拢:同一项上说法互相矛盾的整组不写,剩下的逐个写。""" skipped: list[dict[str, Any]] = [] + facts: list[Fact] = [] for candidate in candidates: name = str(candidate.get("source") or "") try: @@ -250,26 +534,50 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no if row is None: skipped.append({"reason": f"数据源「{name}」已不存在,指标「{candidate.get('metric')}」的选择未写入数据目录"}) continue - fact, why = derive(candidate, row.schema_cache) - if fact is None: - skipped.append({"reason": why}) - continue - done, why = await _write(session, row, fact, note=note_of(fact, run_id=run_id, node_title=node_title), - actor=actor) - except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记 - logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", name) - skipped.append({"reason": f"指标「{candidate.get('metric')}」的选择写入数据目录时出错,未写入"}) + notes = {table: entry.notes for table, entry in (await catalog.read_catalog(session, row.id)).items()} + fact, why = derive(candidate, row.schema_cache, notes) + except Exception: # noqa: BLE001 - 目录读不出来不能毁掉审批的结果:缺口照样按选择记 + logger.exception("双盲复算的选择核对数据目录失败(数据源 %s)", name) + skipped.append({"reason": f"指标「{candidate.get('metric')}」的选择核对数据目录时出错,未写入"}) continue - if done: - written.append({"source": fact.source, "table": fact.table, "item": fact.item}) - else: + if fact is None: skipped.append({"reason": why}) + else: + facts.append(fact) + groups: dict[tuple[str, str, str], list[Fact]] = {} + for fact in facts: + groups.setdefault((fact.source, fact.table, fact.item), []).append(fact) + written: list[dict[str, Any]] = [] + for members in groups.values(): + if any(not _same_fact(f.item, f.value, members[0].value) for f in members[1:]): + skipped.append({"reason": _contradiction(members)}) + continue + for fact in members: + done, why = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) + if done: + written.append({"source": fact.source, "table": fact.table, "item": fact.item}) + else: + skipped.append({"reason": why}) return {"written": written, "skipped": skipped} +async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[bool, str | None]: + try: + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == fact.source))).scalar_one_or_none() + if row is None: + return False, f"数据源「{fact.source}」已不存在,指标「{fact.metric}」的选择未写入数据目录" + return await _write(session, row, fact, note=note_of(fact, run_id=run_id, node_title=node_title), + actor=actor) + except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记 + logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", fact.source) + return False, f"指标「{fact.metric}」的选择写入数据目录时出错,未写入" + + async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: str | None) -> tuple[bool, str | None]: """写一项。(写了没有, 没写的原因)。""" label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) + side = SIDE_LABEL.get(fact.choice, fact.choice) tables = (row.schema_cache or {}).get("tables") if isinstance(row.schema_cache, dict) else None for _ in range(_RETRIES): entry = await catalog.read_entry(session, row.id, fact.table) @@ -287,6 +595,21 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: and live["value"].get("column") == value.get("column"): # 业务日期按哪一列是这次选出来的事实;目录里同一列另写了规则、时区的,原样留着 value = live["value"] + elif fact.item == "valid_filter" and isinstance(live, dict) and isinstance(live.get("value"), str) \ + and live["value"].strip() and not _same_fact(fact.item, live["value"], value): + combined, why = _combined_filter(live["value"].strip(), fact) + if combined is None: + return False, why + if live.get("status") == "confirmed" and not _same_fact(fact.item, combined, live["value"]): + return False, (f"「{fact.table}」的{label}已有人工确认过的说法「{live['value'].strip()}」,被采纳的" + f"{side}另外多一个条件「{fact.value}」,未改动。如需补上,请到数据目录中修改") + value = combined + elif fact.item == "dedup" and isinstance(live, dict) and live.get("status") != "confirmed" \ + and not _same_fact(fact.item, live.get("value"), value): + # 推断的去重规则说的未必是同一件事(按哪一列去重、哪种汇总要去重),替换掉就把它丢了 + return False, (f"「{fact.table}」的{label}已有推断的说法「{_value_text(fact.item, live.get('value'))}」," + f"与这次指标「{fact.metric}」采纳的「{value}」不同,无法确定两者的关系,未覆盖。" + "如需更改,请到数据目录中修改") changes = [{"path": fact.item, "value": value, "reason": note}] plan = catalog.plan_patch(notes, changes, table=fact.table, tables=tables) if plan.problems: diff --git a/backend/app/engine/launch.py b/backend/app/engine/launch.py index 0f05c1c0..dddfd94b 100644 --- a/backend/app/engine/launch.py +++ b/backend/app/engine/launch.py @@ -186,12 +186,13 @@ _VERIFY_NODES = (NodeType.DUAL_CHECK, NodeType.CHALLENGE) async def _with_verify_snapshot(session: AsyncSession, graph: dict[str, Any], options: dict[str, Any] | None) -> dict[str, Any] | None: - """图里有双盲复算或反方审稿节点时,把核查设置(api/settings.py 的 verify 一组)快照进 options["verify"]。 + """图里(含子工作流)有双盲复算或反方审稿节点时,把核查设置(api/settings.py 的 verify 一组)快照进 + options["verify"]。 这两种节点在运行中途读设置(开关、上限、核查模型)。审批停下期间有人改了设置,恢复后的那一段读到的就和前面 不一样:本该复算的被关掉了、重放时走了另一条路,正式运行也说不清当时按什么规则核的。所以在发起这一刻定下来, - 节点只读运行选项里的这一份(同 _approval_default 的道理)。没有这两种节点的图 options 原样不动,run.started - 和以前逐字相同;调用方已经给了 options["verify"] 的照它的。 + 节点只读运行选项里的这一份(同 _approval_default 的道理;子工作流的节点经运行上下文拿到的是同一份选项)。没有 + 这两种节点的图 options 原样不动,run.started 和以前逐字相同;调用方已经给了 options["verify"] 的照它的。 """ from app.api.settings import VERIFY_GROUP, verify_settings @@ -201,11 +202,44 @@ async def _with_verify_snapshot(session: AsyncSession, graph: dict[str, Any], spec = GraphSpec.model_validate(graph) except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报 return options - if not any(node.type in _VERIFY_NODES for node in spec.nodes): + if not await _has_verify_nodes(session, spec): return options return {**(options or {}), VERIFY_GROUP: await verify_settings(session)} +async def _has_verify_nodes(session: AsyncSession, spec: GraphSpec) -> bool: + """图里有没有双盲复算、反方审稿节点,子工作流里的也算。 + + 子工作流照运行时的取法读(同 runner._bound_sources):固定了版本的读那一版,没固定的读工作流现在的图。读不到、 + 图不成形的跳过——那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。 + """ + seen: set[tuple[str, str]] = set() + queue: list[GraphSpec] = [spec] + while queue: + graph = queue.pop(0) + if any(node.type in _VERIFY_NODES for node in graph.nodes): + return True + for node in graph.nodes: + if node.type != NodeType.SUBGRAPH: + continue + wid, pinned = (node_setting(graph, node, k) for k in ("workflow_id", "workflow_version")) + key = (str(wid or ""), str(pinned or "")) + if not wid or key in seen: + continue + seen.add(key) + try: + # 只取图:Workflow 的 versions 是 selectin,整行读会把所有历史版本一起读出来 + query = (select(WorkflowVersion.graph).where( + WorkflowVersion.workflow_id == wid, WorkflowVersion.version == int(pinned)) + if pinned else select(Workflow.graph).where(Workflow.id == wid)) + sub = (await session.execute(query)).scalar_one_or_none() + if sub: + queue.append(GraphSpec.model_validate(sub)) + except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时会说清楚 + continue + return False + + # -------------------------------------------------------------------------- # 发起前检查绑定的工具 # -------------------------------------------------------------------------- diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 596b211a..25e2a736 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -2,16 +2,27 @@ **按输入复算。** 指标由口径卡的原式算出,原式本身确定、可复算(metrics 的 recompute_ok);会错的是喂给它的那几个数: 查哪张表、按哪天归属、排没排除退款……所以盲算的单位是「一个来自查询快照的输入」。盲算 agent(engine/verify_agent.py) -只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项(结果分了组的另给那一行的维度)、本次 -运行的输入参数和数据源的数据目录,看不到原 SQL 和原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询 -快照里取值。盲值是拿原式代入盲算的输入算出来的(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时 -按绝对误差。两个指标用到同一格的只盲算一次。 +只看得到指标名、口径说明(节点配置的 definitions)、这个输入在计算式里是哪一项、本次运行的输入参数和数据源的数据目录, +看不到原 SQL 和原结果;它自己写只读 SQL,最后交一个格子引用,由代码从它自己的查询快照里取值。盲值是拿原式代入盲算的 +输入算出来的(engine/recompute.py,取整同口径卡),和原值按相对误差比,原值为 0 时按绝对误差。两个指标用到同一格的 +只盲算一次。几个盲算同时进行(BLIND_CONCURRENCY 个),先单独试第一个:核查模型用不了时不必个个去试。 -**一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff), -完整对照落成一件工件(审批 payload 经 runner._safe 会截断,长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用 -原流程(a)、采用盲算(b)或两边都不对(neither):a 没有缺口;b、neither 写进产出的 issuance_gaps,成果节点的出具 -契约据此降档(on_reject 为 fail 时节点直接失败)。两边只差一个方面、又对得上数据目录事实的,选择写进数据目录 -(engine/dual_check_facts.py)。盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口。 +**原流程的取法一点都不透。** 原流程结果的列名(gmv_by_order_date、下单金额)、同一行的维度列名(order_date)都是原流程 +的取舍,告诉盲算就等于告诉它按哪一列归属,它照着算出同一个数,下单日和支付日这类陷阱就漏过去了。所以结果分了组时只给 +这一行的取值(「华东」、2026-09-02 这一天),按哪一列分组请它按口径说明判断;原 SQL 里写死的文字列('下单日' AS basis) +连取值也不给;遮罩列不给。计算式只有一项来自查询、或者写了口径说明时不给列名;几项都来自查询又没写口径说明的,列名 +里看不出日期、口径的取舍时才给,好让它分得清哪一项是哪一项。 + +**一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff): +两边先收窄到这一格(只留算出这一列的那一项;分组的按这一行的分组值改成等值条件),一条查询算好几列、分组后取一行时 +才不会混进「选出的列」「分组」这类和这一格无关的差别。完整对照落成一件工件(审批 payload 经 runner._safe 会截断, +长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用原流程(a)、采用盲算(b)或两边都不对(neither):a 没有 +缺口;b、neither、没选的写进产出的 issuance_gaps,成果节点的出具契约据此降档(on_reject 为 fail 时节点直接失败)。 +两边只差一个方面、又对得上数据目录事实的,选择写进数据目录(engine/dual_check_facts.py,指标自己的筛选范围不写)。 +盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口,发 dual_check_unchecked。 + +**产出的状态。** 全部一致是 agreed;没有不一致、但有无法复算的:一个都没核成是 unchecked,核成了一部分是 partial; +有不一致、人裁决过的是 resolved。产出和台账都带 counts(一致、不一致、无法复算各几个)。 **重放。** 审批恢复时节点从头重放。读设置(发起时没快照的才读)、盲算连同比对、差异说明连同工件、写数据目录各在一个 @task 里,重放时直接取 checkpoint 里的结果:不再调用模型、不再计费、不再写目录,事件也都在 task 里发、不会再发一遍; @@ -50,8 +61,8 @@ from app.engine.replay import _in_graph, ask, once from app.engine.schema import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT, NodeType, dual_check_card from app.engine.state import GraphState -#: 节点产出的状态:跳过、无需裁决、人已裁决 -SKIPPED, AGREED, RESOLVED = "skipped", "agreed", "resolved" +#: 节点产出的状态:跳过、全部一致、核成了一部分(其余无法复算)、一个都没核成、人已裁决 +SKIPPED, AGREED, PARTIAL, NONE_CHECKED, RESOLVED = "skipped", "agreed", "partial", "unchecked", "resolved" #: 单个指标的结果:一致、不一致、无法复算 SAME, DIFFERENT, UNCHECKED = "agreed", "disagreed", "unchecked" #: 审核人的三种选择:采用原流程、采用盲算、两边都不对 @@ -73,6 +84,15 @@ _NOTES_CHARS = 6_000 _DIMS = 6 _DIM_CHARS = 40 _INPUTS = 12 +#: 几个盲算同时进行:串行时几个慢的输入就能把运行这一段的时长上限(max_run_seconds)耗光 +BLIND_CONCURRENCY = 3 +#: 交给盲算的取值像日期、月份时换个说法:「2026-09-02 这一天」,不说是哪一列 +_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_MONTH = re.compile(r"^\d{4}-\d{2}$") +#: 原流程结果的列名里出现这些,就带着原流程的取舍(按哪天归属、排没排除、去没去重),不交给盲算 +_LOADED_NAME = ("date", "day", "time", "dt", "_at", "month", "week", "year", "paid", "pay", "order", "refund", + "cancel", "distinct", "dedup", "unique", "net", "gross", "valid", "test", "日", "月", "周", "年", + "时间", "下单", "支付", "付款", "退款", "去重", "剔除", "排除", "不含", "含", "有效", "净", "毛") #: 卡上给容器输入记的大概(metrics._brief):「[12 项]」「{3 个字段}」,写进计算式时不当成字面量 _BRIEF = re.compile(r"^(?:\[\d+ 项\]|\{\d+ 个字段\})$") @@ -88,6 +108,7 @@ NO_SQL = "有一边没有可比较的 SQL,请对照原文" #: 交给成果节点的缺口(不带节点名:出具契约统一加「双盲复算「节点名」:」) GAP_BLIND = "指标「{name}」双盲复算不一致,审核人选择了盲算结果" GAP_NEITHER = "指标「{name}」双盲复算不一致,审核人判断两边都不对" +GAP_UNANSWERED = "指标「{name}」双盲复算不一致,审核人没有作出选择" GAP_UNCHECKED = "指标「{name}」无法复算:{reason}" BLIND_SYSTEM = ( @@ -103,7 +124,7 @@ BLIND_TASK_PROMPT = ( "指标:{name}{unit}\n" "口径说明:{definition}\n" "计算式:{formula}\n" - "你要算的是计算式里的 {label}{whole}:原流程里它是一次查询结果中「{column}」列的值{dims}。只交这一个数。\n" + "你要算的是计算式里的 {label}{whole}{hint}。{dims}只交这一个数。\n" "数据源:{source}\n" "{inputs}\n" "{catalog}" @@ -111,7 +132,10 @@ BLIND_TASK_PROMPT = ( BLIND_UNIT_PROMPT = "(单位:{unit})" BLIND_NO_DEFINITION_PROMPT = "(未提供,按指标名称和数据目录理解)" BLIND_WHOLE_PROMPT = "(这个指标就是它本身)" -BLIND_DIMS_PROMPT = ",所在行的其他列:{dims}" +BLIND_HINT_PROMPT = "(原流程的结果里这一项叫「{column}」)" +BLIND_DIMS_PROMPT = "原流程的结果分了组,这个数属于对应 {dims} 的那一组;按哪一列分组、怎么归属,请按口径说明和数据目录判断。" +BLIND_DAY_PROMPT = "{value} 这一天" +BLIND_MONTH_PROMPT = "{value} 这个月" BLIND_INPUTS_PROMPT = "本次运行的输入(统计期之类的参数在这里):{inputs}\n" BLIND_INDEX_PROMPT = "数据源里的表(表名|中文名|粒度):\n{index}" BLIND_NOTES_PROMPT = "数据目录(标「推断,未确认」的项是推断,使用前请核实):\n{notes}" @@ -159,9 +183,12 @@ async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]: rows = checked["rows"] disagreed = [r for r in rows if r["status"] == DIFFERENT] if not disagreed: - return _finish(state, ctx, card_id, card, checked, status=AGREED, gaps=_unchecked_gaps(rows)) + agreed = sum(1 for r in rows if r["status"] == SAME) + # agreed 只说「全部一致」:一个都没核成、只核成一部分的另记,时间线和证据面板才不会把降档的出具说成一致 + status = AGREED if agreed == len(rows) else PARTIAL if agreed else NONE_CHECKED + return _finish(state, ctx, card_id, card, checked, status=status, gaps=_unchecked_gaps(rows)) - review = await _step("dual_check_review", lambda: _review(ctx, checked)) + review = await _step("dual_check_review", lambda: _review(state, ctx, checked, options)) response = await ask(ctx, review["payload"], **review["payload"]) # 谁裁决的跟着事件走,和人工审批节点一样 await once(ctx, EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) @@ -170,9 +197,9 @@ async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]: await once(ctx, EventType.LOG, level="info", code="dual_check_resolved", choices=choices, gaps=gaps, message=_resolved_text(disagreed, choices)) candidates = [{**review["facts"][r["metric_id"]], "choice": choices[r["metric_id"]]} - for r in disagreed if choices[r["metric_id"]] in ("a", "b") and r["metric_id"] in review["facts"]] + for r in disagreed if choices.get(r["metric_id"]) in ("a", "b") and r["metric_id"] in review["facts"]] catalog = await _step("dual_check_catalog", lambda: _catalog(ctx, candidates)) if candidates else None - rejected = [r for r in disagreed if choices[r["metric_id"]] != "a"] + rejected = [r for r in disagreed if choices.get(r["metric_id"]) != "a"] if rejected and options.on_reject == "fail": # 目录照样写了:审核人的选择是对数据的判断,和这次运行停不停无关 names = "、".join(f"「{r['name']}」" for r in rejected[:5]) @@ -349,8 +376,8 @@ async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, An """逐个指标认出能复算的输入、按输入盲算、代回原式比对。返回 {rows, results, model, same_model, usage}。 rows 每个指标一项:{metric_id, name, unit, decimals, status, reason?, original: {value, rendered}, blind: {value, - rendered}, delta_pct, inputs: [{path, key, value, sql, source, alias, artifact, row, column, node_id}], differing}; - results 是每个盲算(按格子去重)的结果,见 _slim。 + rendered}, delta_pct, inputs: [{path, key, value, sql, source, alias, artifact, row, column, node_id, via}], + differing};results 是每个盲算(按格子去重)的结果,见 _slim。 """ sources = _Sources() load, outputs = _loader(state) @@ -358,15 +385,18 @@ async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, An inputs = _run_inputs(state) rows: list[dict[str, Any]] = [] jobs: dict[str, dict[str, Any]] = {} + sharers: dict[str, list[dict[str, str]]] = {} for metric in targets: row, metric_jobs = await _plan_metric(state, metric, options, sources, aliases, load, outputs) - for job in metric_jobs.values(): - job["inputs"] = inputs rows.append(row) for key, job in metric_jobs.items(): + job["inputs"] = inputs + sharers.setdefault(key, []).append({"id": row["metric_id"], "name": row["name"]}) # 两个指标用到同一格只盲算一次,按先出现的那个指标说清要算什么;先出现的没写口径说明、后一个写了,用后一个 if key not in jobs or (not jobs[key]["definition"] and job["definition"]): jobs[key] = job + for key, job in jobs.items(): + job["metrics"] = sharers[key] results, model, same, usage = await _blind(ctx, jobs, options, settings, _producer_models(state, rows)) for row, metric in zip(rows, targets): if row["status"] != UNCHECKED: @@ -377,6 +407,14 @@ async def _check(state: GraphState, ctx: NodeContext, targets: list[dict[str, An if agreed and not any(r["status"] == DIFFERENT for r in rows): ctx.emit(EventType.LOG, level="info", code="dual_check_agreed", metrics=agreed, message=f"双盲复算一致:{agreed} 个指标两边的结果相同,已放行") + if unchecked := [r for r in rows if r["status"] == UNCHECKED]: + # 结构上没法单独复算的(合并查询、遮罩列)以前一条 dual_check_* 日志都没有,时间线上看不出为什么没核成 + parts = ";".join(f"「{r['name']}」({r.get('reason') or '原因不明'})" for r in unchecked[:5]) + ctx.emit(EventType.LOG, level="warn", code="dual_check_unchecked", + metrics=[{"id": r["metric_id"], "name": r["name"], "reason": r.get("reason") or "原因不明"} + for r in unchecked], + message=f"双盲复算未能核对 {len(unchecked)} 个指标:{parts}" + + ("等" if len(unchecked) > 5 else "") + ",按无法复算计入缺口") return {"rows": rows, "results": results, "model": model, "same_model": same, "usage": usage} @@ -435,14 +473,17 @@ async def _plan_metric(state: GraphState, metric: dict[str, Any], options: _Opti return row, {} labels = {inp["path"]: f"输入{i}" for i, inp in enumerate(row["inputs"], 1)} formula = _formula(metric, labels) + definition = options.definitions.get(row["metric_id"]) or "" jobs: dict[str, dict[str, Any]] = {} for inp in row["inputs"]: snap, _ = sources.snapshot(inp["artifact"]) + hidden = (masks.get(inp["artifact"]) or frozenset()) | await _constant_columns(snap or {}, sources) + # 列名是原流程的取舍:只有一项来自查询、或者写了口径说明时用不着;几项又没口径说明的,看不出取舍才给 + hint = inp["column"] if len(row["inputs"]) > 1 and not definition and _neutral(inp["column"]) else None jobs[inp["key"]] = { "key": inp["key"], "metric_id": row["metric_id"], "metric": row["name"], "unit": row["unit"], - "definition": options.definitions.get(row["metric_id"]) or "", "formula": formula, - "label": labels[inp["path"]], "column": inp["column"], "source": inp["source"], - "dims": _dims(snap or {}, inp["row"], inp["column"], masks.get(inp["artifact"]) or frozenset()), + "definition": definition, "formula": formula, "label": labels[inp["path"]], "hint": hint, + "source": inp["source"], "dims": _dims(snap or {}, inp["row"], inp["column"], hidden), } return row, jobs @@ -534,30 +575,80 @@ def _formula(metric: dict[str, Any], labels: dict[str, str]) -> str: return ast.unparse(ast.fix_missing_locations(swapped)) -def _dims(snapshot: dict[str, Any], row: int, column: str, hidden: frozenset[str]) -> list[str]: - """原流程那一格所在行的其他列里不是数的那些(地区、日期这类维度):结果分了组时,盲算 agent 要知道算的是哪一组。 - 数不给(同一行别的指标值会带偏它),遮罩列不给。""" +def _row_pairs(snapshot: Mapping[str, Any], row: int, column: str) -> list[tuple[str, Any]]: + """原流程那一格所在行的其他列里不是数的那些:(列名, 取值)。数不算(同一行别的指标值)。""" rows = snapshot.get("rows") or [] - if not 0 <= row < len(rows): + if not isinstance(row, int) or not 0 <= row < len(rows): return [] record = rows[row] columns = [str(c) for c in snapshot.get("columns") or []] pairs = list(record.items()) if isinstance(record, dict) else list(zip(columns, record)) - out: list[str] = [] + out: list[tuple[str, Any]] = [] for name, value in pairs: name = str(name) - if name == column or name.lower() in hidden or value is None: + if name == column or value is None: continue kind = column_kind(snapshot, name) if kind == "number" or (kind is None and (is_number(value) or numeric_text(value) is not None)): continue - text = str(value).replace("\n", " ") - out.append(f"{name} 为「{text[:_DIM_CHARS]}」") + out.append((name, value)) + return out + + +def _dims(snapshot: Mapping[str, Any], row: int, column: str, hidden: frozenset[str]) -> list[str]: + """结果分了组时,交给盲算 agent 的这一行的分组取值(地区、日期这类维度):它要知道算的是哪一组。只给取值、不给 + 列名(原流程按哪一列分的组正是要它自己判断的),遮罩列、原 SQL 里写死的文字列(hidden,小写)不给。""" + out: list[str] = [] + for name, value in _row_pairs(snapshot, row, column): + if name.lower() in hidden: + continue + out.append(str(value).replace("\n", " ")[:_DIM_CHARS]) if len(out) >= _DIMS: break return out +def _dim_text(value: str) -> str: + """一个分组取值给盲算看的说法:日期、月份说「2026-09-02 这一天」,别的加「」。""" + if _DAY.match(value): + return BLIND_DAY_PROMPT.format(value=value) + if _MONTH.match(value): + return BLIND_MONTH_PROMPT.format(value=value) + return f"「{value}」" + + +def _neutral(column: str) -> bool: + """原流程结果的列名看不出取舍(按哪天归属、排没排除、去没去重):可以交给盲算区分计算式里的几项。""" + lowered = column.lower() + return bool(column.strip()) and not any(word in lowered for word in _LOADED_NAME) + + +async def _constant_columns(snapshot: Mapping[str, Any], sources: _Sources) -> frozenset[str]: + """原 SQL 最外层写死的文字列('下单日' AS basis)的列名(小写):那是原流程自己的标注,不是分组。解析不了为空。""" + import sqlglot + from sqlglot import exp + + from app.data.sqlcheck import dialect_of + + sql = snapshot.get("sql") + name = snapshot.get("source") + if not isinstance(sql, str) or not sql.strip(): + return frozenset() + found = await sources.source(name) if isinstance(name, str) and name else None + read = dialect_of(found.kind) if found is not None else None + try: + tree = sqlglot.parse_one(sql, read=read) + except Exception: # noqa: BLE001 - 解析不了就当没有:交给盲算的只是取值,最坏多给一个 + return frozenset() + out: set[str] = set() + for select in [tree] if isinstance(tree, exp.Select) else list(tree.find_all(exp.Select))[:1]: + for item in select.expressions: + inner = item.this if isinstance(item, exp.Alias) else item + if isinstance(inner, (exp.Literal, exp.Boolean, exp.Null)): + out.add(_output_name(item, read).lower()) + return frozenset(out) + + async def _catalog_text(sources: _Sources, name: str) -> str: """交给盲算 agent 的数据目录:表清单(表名、中文名、粒度)和有目录的表的业务说明(业务日期、有效记录条件、去重、 字段含义、码值、关联关系)。太长的截断,其余请它用 db_schema 查。""" @@ -591,18 +682,25 @@ def _clip(text: str, limit: int) -> str: def _task_text(job: dict[str, Any], catalog: str) -> str: + dims = "、".join(_dim_text(v) for v in job["dims"]) return BLIND_TASK_PROMPT.format( name=job["metric"], unit=BLIND_UNIT_PROMPT.format(unit=job["unit"]) if job["unit"] else "", definition=job["definition"] or BLIND_NO_DEFINITION_PROMPT, formula=job["formula"], label=job["label"], - whole=BLIND_WHOLE_PROMPT if job["formula"].strip() == job["label"] else "", column=job["column"], - dims=BLIND_DIMS_PROMPT.format(dims="、".join(job["dims"])) if job["dims"] else "", + whole=BLIND_WHOLE_PROMPT if job["formula"].strip() == job["label"] else "", + hint=BLIND_HINT_PROMPT.format(column=job["hint"]) if job.get("hint") else "", + dims=BLIND_DIMS_PROMPT.format(dims=dims) if dims else "", source=job["source"], catalog=catalog, inputs=BLIND_INPUTS_PROMPT.format(inputs=";".join(job["inputs"])) if job.get("inputs") else "") async def _blind(ctx: NodeContext, jobs: dict[str, dict[str, Any]], options: _Options, settings: Mapping[str, Any], producers: set[str]) -> tuple[dict[str, Any], str | None, bool, dict[str, Any]]: - """按先后逐个盲算:(各格子的结果, 盲算模型, 是否和产出原数据的模型相同, 用量)。producers 是产出原数据的模型。""" + """盲算:(各格子的结果, 盲算模型, 是否和产出原数据的模型相同, 用量)。producers 是产出原数据的模型。 + + 先单独做第一个:核查模型本身用不了(没配好、接入出错)时,后面的一样用不了,照这一次的原因记,不再一个个去试。 + 其余的同时进行,最多 BLIND_CONCURRENCY 个:串行时几个慢的输入(每个最多 timeout_s 加收尾的宽限)就能把运行这一段 + 的时长上限耗光,整段盲算没有 checkpoint,接着运行时全部重做、再花一次钱。结果按格子写回,日志按任务的先后发, + 和完成的先后无关。""" from app.api.settings import verify_model_spec from app.engine.verify_agent import run_verify_agent from app.tools.datasource import run_versions @@ -614,38 +712,56 @@ async def _blind(ctx: NodeContext, jobs: dict[str, dict[str, Any]], options: _Op spec = await verify_model_spec(session, ctx.config, settings=dict(settings)) sources = _Sources() catalogs: dict[str, str] = {} - results: dict[str, Any] = {} - model: str | None = None - same = False - broken: dict[str, Any] | None = None - for key, job in jobs.items(): - if broken is not None: - # 核查模型本身用不了(没配好、接入出错):后面的盲算一样用不了,照这一次的原因记,不再一个个去试 - results[key] = broken - continue + for job in jobs.values(): if job["source"] not in catalogs: catalogs[job["source"]] = await _catalog_text(sources, job["source"]) - outcome = await run_verify_agent( + outcomes: dict[str, Any] = {} + + async def one(key: str, job: dict[str, Any]) -> None: + outcomes[key] = await run_verify_agent( spec=spec, system=BLIND_SYSTEM, task=_task_text(job, catalogs[job["source"]]), sources=[job["source"]], output_schema=BLIND_SCHEMA, max_queries=options.max_queries, timeout_s=options.timeout_s, daily_max_usd=options.daily_max_usd, emit=ctx.emit, run_id=ctx.run.run_id, node_id=ctx.node.id, data_versions=run_versions(ctx.run)) + + keys = list(jobs) + await one(keys[0], jobs[keys[0]]) + first = outcomes[keys[0]] + if len(keys) > 1 and not (first.error and not first.model): + gate = asyncio.Semaphore(BLIND_CONCURRENCY) + + async def gated(key: str) -> None: + async with gate: + await one(key, jobs[key]) + + await asyncio.gather(*(gated(key) for key in keys[1:])) + results: dict[str, Any] = {} + model: str | None = None + same = False + for key in keys: + outcome = outcomes.get(key) + if outcome is None: + results[key] = dict(results[keys[0]]) + continue results[key] = _slim(outcome) - if outcome.error and not outcome.model: - broken = results[key] for k in usage: usage[k] += outcome.usage.get(k) or 0 if outcome.model and model is None: model = outcome.model - if model in producers: - same = True - ctx.emit(EventType.LOG, level="warn", code="dual_check_same_model", model=model, - message=f"盲算模型与产出原数据的模型相同(「{model}」),难以发现原流程自身的错误。" - "请在「设置 → 偏好设置 → 核查」中选择另一个核查模型," - f"或在节点的「{DUAL_CHECK_FIELD_LABEL['model']}」中指定") - if outcome.limit in LIMIT_CODES: - ctx.emit(EventType.LOG, level="warn", code="dual_check_limit", limit=outcome.limit, metric=job["metric_id"], - message=f"指标「{job['metric']}」的盲算{_limit_text(outcome.limit, options)},按无法复算处理") + if model is not None and model in producers: + same = True + ctx.emit(EventType.LOG, level="warn", code="dual_check_same_model", model=model, + message=f"盲算模型与产出原数据的模型相同(「{model}」),难以发现原流程自身的错误。" + "请在「设置 → 偏好设置 → 核查」中选择另一个核查模型," + f"或在节点的「{DUAL_CHECK_FIELD_LABEL['model']}」中指定") + for key in keys: + found = results[key] + if found.get("limit") in LIMIT_CODES and not _settled(found): + # 几个指标共用这一格:每个指标都按无法复算记,各发一条,时间线上不会只说先出现的那个 + for metric in jobs[key]["metrics"]: + ctx.emit(EventType.LOG, level="warn", code="dual_check_limit", limit=found["limit"], + metric=metric["id"], + message=f"指标「{metric['name']}」的盲算{_limit_text(found['limit'], options)},按无法复算处理") usage["cost_usd"] = round(usage["cost_usd"], 6) return results, model, same, usage @@ -704,6 +820,13 @@ def _cell_masked(cell: dict[str, Any]) -> bool: return isinstance(recorded, list) and any(isinstance(c, str) and c.lower() == column for c in recorded) +def _settled(found: Mapping[str, Any]) -> bool: + """触了查询次数的上限,但交回的那一格是真查出来的:照常比对。verify_agent 查满 max_queries 次就记触顶(哪怕最后 + 一次正是答案),按无法复算处理的话,「每项最多查询次数」设成 1 时盲算永远核不成。时长、金额触顶的是半路被掐断, + 照旧按无法复算。""" + return found.get("limit") == "max_queries" and found.get("value") is not None + + def _limit_text(limit: str | None, options: _Options) -> str: """盲算为什么停下,接在「盲算」后面读:「盲算已达每项最多查询次数(6 次)」。""" if limit == "max_queries" and options.max_queries is not None: @@ -744,7 +867,7 @@ def _compare(row: dict[str, Any], metric: dict[str, Any], results: dict[str, Any if found is None: row.update(status=UNCHECKED, reason="盲算没有执行") return - if found.get("limit"): + if found.get("limit") and not _settled(found): row.update(status=UNCHECKED, reason="盲算" + _limit_text(found["limit"], options)) return if found.get("value") is None: @@ -780,11 +903,14 @@ def _unchecked_gaps(rows: list[dict[str, Any]]) -> list[str]: # -------------------------------------------------------------------------- -async def _review(ctx: NodeContext, checked: dict[str, Any]) -> dict[str, Any]: +async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], options: _Options) -> dict[str, Any]: """不一致的指标逐个说清差在哪,完整对照落成工件,组好审批的 payload。返回 {payload, facts, card_artifact}。 + 两条 SQL 先收窄到这一格再比(_narrowed):原流程一条查询算好几列、分组后取一行时,整条拿去比会混进「选出的列」 + 「分组」这类和这一格无关的差别,审批卡上只列前几条,审核人看到的多半是噪声,只差一个方面的也就写不进目录。 facts 是每个不一致指标写数据目录要用的东西(dual_check_facts.derive 的候选,少一个 choice):只有一个输入对不上时是 - 那一处完整的差异条目(带两边的写法和涉及的表、列),几个输入都对不上时只说「不止一处」。 + 那一处完整的差异条目(带两边比较的写法和涉及的表、列),几个输入都对不上时只说「不止一处」;scope 是这个指标自己的 + 范围(口径说明、所在分组、运行输入、分组的列),有效记录条件据此排除指标自己的筛选范围。 """ rows = [r for r in checked["rows"] if r["status"] == DIFFERENT] results = checked["results"] @@ -793,16 +919,31 @@ async def _review(ctx: NodeContext, checked: dict[str, Any]) -> dict[str, Any]: metrics: list[dict[str, Any]] = [] summary: list[dict[str, Any]] = [] facts: dict[str, dict[str, Any]] = {} + inputs = _run_inputs(state) for row in rows: per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + compared: list[tuple[str, str]] = [] + scope_texts = [options.definitions.get(row["metric_id"]) or "", *inputs] + scope_columns: set[str] = set() for inp in row["inputs"]: - if inp["key"] not in row["differing"]: - continue if inp["source"] not in infos: infos[inp["source"]] = await _source_info(sources, inp["source"]) + kind = infos[inp["source"]].get("kind") blind = results.get(inp["key"]) or {} - diff = await _explain(inp["sql"], blind.get("sql"), infos[inp["source"]]) + original_snap, _ = sources.snapshot(inp["artifact"]) + pairs = _row_pairs(original_snap or {}, inp["row"], inp["column"]) + scope_texts += [str(v) for _, v in pairs] + scope_columns |= {name.lower() for name, _ in pairs} | _group_columns(inp["sql"], kind) \ + | _group_columns(blind.get("sql"), kind) + if inp["key"] not in row["differing"]: + continue + blind_snap, _ = sources.snapshot(str(blind["artifact"])) if blind.get("artifact") else (None, None) + a_sql = _narrowed(inp["sql"], inp["column"], _record(original_snap, inp["row"]), kind) or inp["sql"] + b_sql = _narrowed(blind.get("sql"), blind.get("column"), _record(blind_snap, blind.get("row")), kind) \ + or blind.get("sql") + diff = await _explain(a_sql, b_sql, infos[inp["source"]]) per_input.append((inp, blind, diff)) + compared.append((a_sql, str(b_sql or ""))) merged = _merge(per_input) metrics.append({ "metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], "decimals": row["decimals"], @@ -826,13 +967,14 @@ async def _review(ctx: NodeContext, checked: dict[str, Any]) -> dict[str, Any]: "fallback": not any(i.get("aspect") not in ("other", "parse") for i in merged["items"]), }) first = per_input[0][0] if per_input else (row["inputs"][0] if row["inputs"] else {}) - single = per_input[0] if len(per_input) == 1 else None + single = compared[0] if len(per_input) == 1 else None facts[row["metric_id"]] = { "metric_id": row["metric_id"], "metric": row["name"], "source": first.get("source"), "dialect": (infos.get(str(first.get("source"))) or {}).get("kind"), - "sql": {"a": single[0]["sql"], "b": single[1].get("sql")} if single else {}, + "sql": {"a": single[0], "b": single[1]} if single else {}, "diff": {"equivalent": merged["equivalent"], "fallback": merged["fallback"] or single is None, "items": merged["items"]}, + "scope": {"texts": [t for t in dict.fromkeys(scope_texts) if t], "columns": sorted(scope_columns)}, } content = {"metrics": metrics, "model": checked.get("model"), "same_model": bool(checked.get("same_model"))} try: @@ -889,6 +1031,147 @@ async def _explain(original: Any, blind: Any, info: dict[str, Any]) -> dict[str, return out +def _record(snapshot: Mapping[str, Any] | None, row: Any) -> dict[str, Any] | None: + """快照里第 row 行,写成 {列名: 值};没有这一行为 None。""" + rows = (snapshot or {}).get("rows") or [] + if isinstance(row, bool) or not isinstance(row, int) or not 0 <= row < len(rows): + return None + record = rows[row] + if isinstance(record, dict): + return {str(k): v for k, v in record.items()} + columns = [str(c) for c in (snapshot or {}).get("columns") or []] + return dict(zip(columns, record)) if isinstance(record, (list, tuple)) else None + + +def _output_name(item: Any, read: str | None) -> str: + """最外层 SELECT 一项的结果列名:有别名用别名,裸列用列名,别的是驱动按原文起的名。""" + from sqlglot import exp + + if isinstance(item, exp.Alias): + return item.alias + if isinstance(item, exp.Column): + return item.name + return item.sql(dialect=read) + + +def _same_column(a: str, b: str) -> bool: + return "".join(a.split()).lower() == "".join(b.split()).lower() + + +def _group_projection(key: Any, projections: list[Any]) -> int | None: + """分组键是最外层 SELECT 的第几项:GROUP BY 1、GROUP BY 原样的表达式、GROUP BY 别名都认,认不出为 None。""" + from sqlglot import exp + + if isinstance(key, exp.Literal) and not key.is_string and str(key.this).isdigit(): + index = int(key.this) - 1 + return index if 0 <= index < len(projections) else None + for i, item in enumerate(projections): + if (item.this if isinstance(item, exp.Alias) else item) == key: + return i + if isinstance(key, exp.Column) and not key.table: + hits = [i for i, item in enumerate(projections) if isinstance(item, exp.Alias) + and item.alias.lower() == key.name.lower()] + return hits[0] if len(hits) == 1 else None + return None + + +def _sql_literal(value: Any) -> Any: + """快照里的取值写回 SQL 常量;写不回(容器、非有限数)为 None。""" + from sqlglot import exp + + if isinstance(value, bool): + return exp.Boolean(this=value) + if isinstance(value, (int, float)): + return exp.Literal.number(value) if math.isfinite(value) else None + if isinstance(value, str): + return exp.Literal.string(value) + return None + + +def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str | None) -> str | None: + """把一条查询收窄到这一格:最外层 SELECT 只留算出这一列的那一项;分组的去掉分组,按这一行的分组值补上等值条件 + (华东那一行 → WHERE … AND region = '华东'),排序、取行数跟着去掉。 + + 差异说明拿收窄后的两条比,才只说这一格是怎么算出来的。认不准的(SELECT *、DISTINCT、窗口函数、集合运算、分组键 + 对不上结果列、这一行没有分组值)返回 None,调用方照整条比。""" + import sqlglot + from sqlglot import exp + + from app.data.sqlcheck import dialect_of + + if not isinstance(sql, str) or not sql.strip() or not isinstance(column, str) or not column.strip(): + return None + read = dialect_of(kind) + try: + tree = sqlglot.parse_one(sql, read=read) + except Exception: # noqa: BLE001 - 解析不了:照整条比,比较本身会说清 + return None + if not isinstance(tree, exp.Select) or tree.args.get("distinct"): + return None + projections = list(tree.expressions) + if not projections or any(isinstance(p, exp.Star) or (isinstance(p, exp.Column) and isinstance(p.this, exp.Star)) + or p.find(exp.Window) for p in projections): + return None + hits = [i for i, p in enumerate(projections) if _same_column(_output_name(p, read), column)] + if len(hits) != 1: + return None + target = projections[hits[0]] + group = tree.args.get("group") + if group is not None: + if record is None or any(group.args.get(k) for k in ("rollup", "cube", "grouping_sets", "totals")): + return None + values = {k.lower(): v for k, v in record.items()} + conditions = [] + for key in group.expressions: + index = _group_projection(key, projections) + name = _output_name(projections[index], read).lower() if index is not None else None + if name is None or name not in values: + return None + expr = projections[index].this if isinstance(projections[index], exp.Alias) else projections[index] + if values[name] is None: + conditions.append(exp.Is(this=expr.copy(), expression=exp.Null())) + continue + literal = _sql_literal(values[name]) + if literal is None: + return None + conditions.append(exp.EQ(this=expr.copy(), expression=literal)) + for arg in ("group", "order", "limit", "offset"): + tree.set(arg, None) + tree = tree.where(*conditions, copy=False) + elif tree.args.get("limit") is None and tree.args.get("offset") is None: + # 不取前几行时,排序不影响算出来的数 + tree.set("order", None) + tree.set("expressions", [target]) + try: + return tree.sql(dialect=read) + except Exception: # noqa: BLE001 + return None + + +def _group_columns(sql: Any, kind: str | None) -> set[str]: + """一条查询分组用到的列(小写):有效记录条件不收分组列上的条件(那是指标自己的筛选范围)。""" + import sqlglot + from sqlglot import exp + + from app.data.sqlcheck import dialect_of + + if not isinstance(sql, str) or not sql.strip(): + return set() + read = dialect_of(kind) + try: + tree = sqlglot.parse_one(sql, read=read) + except Exception: # noqa: BLE001 + return set() + out: set[str] = set() + for group in tree.find_all(exp.Group): + select = group.parent if isinstance(group.parent, exp.Select) else None + for key in group.expressions: + index = _group_projection(key, list(select.expressions)) if select is not None else None + node = select.expressions[index] if index is not None else key + out |= {col.name.lower() for col in node.find_all(exp.Column) if col.name} + return out + + def _merge(per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]) -> dict[str, Any]: """一个指标几个对不上的输入的差异合成一份:一个输入时就是它的;几个时每条前面写明是哪一列的输入。""" if not per_input: @@ -914,14 +1197,15 @@ def _merge(per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]] def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tuple[dict[str, str], str]: """审核人的回复 → ({指标 id: a | b | neither}, 备注)。回复的 value 是 {choices, note};某个指标没选、选了认不出的 - 按「两边都不对」:不替人认可任何一边。明确驳回(approved 为否)的按人工驳回让节点失败,和人工审批节点的填写模式 - 一样:这一步没有驳回的出口。""" + 不进 choices:照实记成「没有作出选择」,不替人说「两边都不对」,也不替人认可任何一边(缺口照样记、出具照样降档)。 + 通用的输入卡送来的是一句话,所有指标都按没选记。明确驳回(approved 为否)的按人工驳回让节点失败,和人工审批节点的 + 填写模式一样:这一步没有驳回的出口。""" decision = read_decision(response) if isinstance(response, dict) and decision.rejected: raise NodeError(ctx.node.id, f"人工驳回:{decision.note or '未说明原因'}") value = decision.value if isinstance(decision.value, dict) else response if isinstance(response, dict) else {} raw = value.get("choices") if isinstance(value.get("choices"), dict) else {} - choices = {r["metric_id"]: raw[r["metric_id"]] if raw.get(r["metric_id"]) in CHOICES else "neither" for r in rows} + choices = {r["metric_id"]: raw[r["metric_id"]] for r in rows if raw.get(r["metric_id"]) in CHOICES} note = value.get("note") if isinstance(value.get("note"), str) and value.get("note").strip() else decision.note return choices, str(note or "").strip() @@ -929,12 +1213,12 @@ def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tup def _gaps(rows: list[dict[str, Any]], choices: dict[str, str]) -> list[str]: out: list[str] = [] for row in rows: + choice = choices.get(row["metric_id"]) if row["status"] == UNCHECKED: out.append(GAP_UNCHECKED.format(name=row["name"], reason=row.get("reason") or "原因不明")) - elif row["status"] == DIFFERENT and choices.get(row["metric_id"]) == "b": - out.append(GAP_BLIND.format(name=row["name"])) - elif row["status"] == DIFFERENT and choices.get(row["metric_id"]) != "a": - out.append(GAP_NEITHER.format(name=row["name"])) + elif row["status"] == DIFFERENT and choice != "a": + text = GAP_BLIND if choice == "b" else GAP_NEITHER if choice == "neither" else GAP_UNANSWERED + out.append(text.format(name=row["name"])) return out @@ -942,7 +1226,8 @@ _CHOICE_TEXT = {"a": "采用原流程", "b": "采用盲算", "neither": "两边 def _resolved_text(rows: list[dict[str, Any]], choices: dict[str, str]) -> str: - parts = ";".join(f"「{r['name']}」{_CHOICE_TEXT[choices[r['metric_id']]]}" for r in rows[:8]) + parts = ";".join(f"「{r['name']}」{_CHOICE_TEXT.get(choices.get(r['metric_id']), '没有作出选择')}" + for r in rows[:8]) return f"审核人已裁决 {len(rows)} 个不一致的指标:{parts}" + ("等" if len(rows) > 8 else "") @@ -974,6 +1259,9 @@ def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, A artifact: str | None = None, catalog: dict[str, Any] | None = None) -> dict[str, Any]: rows = checked["rows"] picked = choices or {} + counts = {"agreed": sum(1 for r in rows if r["status"] == SAME), + "disagreed": sum(1 for r in rows if r["status"] == DIFFERENT), + "unchecked": sum(1 for r in rows if r["status"] == UNCHECKED)} metrics = [{ "metric_id": r["metric_id"], "name": r["name"], "unit": r["unit"], "status": r["status"], "original_rendered": r["original"]["rendered"], "blind_rendered": r["blind"]["rendered"], @@ -981,7 +1269,7 @@ def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, A **({"reason": r["reason"]} if r.get("reason") else {}), **({"choice": picked[r["metric_id"]]} if r["metric_id"] in picked else {}), } for r in rows] - result: dict[str, Any] = {"status": status, "metrics": metrics, "model": checked.get("model"), + result: dict[str, Any] = {"status": status, "counts": counts, "metrics": metrics, "model": checked.get("model"), "same_model": bool(checked.get("same_model"))} if artifact: result["card_artifact"] = artifact @@ -993,7 +1281,7 @@ def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, A result["issuance_gaps"] = gaps entry: dict[str, Any] = { "kind": "dual_check", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), "card": card_id, - "status": status, + "status": status, "counts": counts, "metrics": [{"id": m["metric_id"], "status": m["status"], **({"choice": m["choice"]} if "choice" in m else {})} for m in metrics], **({"metric_set": card["artifact"]} if card and isinstance(card.get("artifact"), str) else {}), @@ -1006,4 +1294,4 @@ def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, A return updates -__all__ = ["AGREED", "CHOICES", "RESOLVED", "SKIPPED", "run_dual_check"] +__all__ = ["AGREED", "CHOICES", "NONE_CHECKED", "PARTIAL", "RESOLVED", "SKIPPED", "run_dual_check"] diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 306f5a2a..caf383cb 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -455,16 +455,29 @@ def dual_check_card(spec: GraphSpec, node: GraphNode) -> tuple[str | None, str | def _check_dual_check(node: GraphNode, spec: GraphSpec, result: ValidationResult) -> None: """双盲复算的配置:口径卡必须是它上游的口径卡;复算的指标、口径说明的键必须是那张卡上的指标 ID;开关、 - 误差、处置的取值。指标定义从别的工作流引用来的口径卡(caliber_from),画图时不知道有哪些指标,不查指标 ID。""" + 误差、处置的取值;不能配失败重试。指标定义从别的工作流引用来的口径卡(caliber_from),画图时不知道有哪些指标, + 不查指标 ID。""" cfg = node.config label = DUAL_CHECK_FIELD_LABEL def add(message: str, field: str, code: str) -> None: result.add(message, node_id=node.id, field=field, code=code) - if cfg.get("enabled") not in (None, True, False): - add(f"「{label['enabled']}」只能选跟随设置、开启或关闭,当前为「{cfg.get('enabled')}」", "enabled", + switch = cfg.get("enabled") + # 用 is 比:0 == False、1 == True,用 in 比会放过去,执行时却按「跟随设置」走(执行器认的是 True / False 本身) + if not (switch is None or switch is True or switch is False): + add(f"「{label['enabled']}」只能选跟随设置、开启或关闭,当前为「{switch}」", "enabled", "dual_check.option_invalid") + try: + retries = int(cfg.get("retries", 0) or 0) # 和 compiler 取重试次数的写法一样 + except (TypeError, ValueError): + retries = 0 + if retries > 0: + # 失败都是确定的(审核人没采用原流程、口径卡配错):重试时 @task 的调用序号往后走、对不上缓存,盲算整轮 + # 再做一遍、再花一次钱,还要审核人再选一次 + add(f"「双盲复算」节点不支持「{field_label('retries')}」:它的失败是确定的(审核人没有采用原流程的结果、" + "口径卡配置有误),重试只会把盲算再做一遍并再次请审核人选择。请把失败重试次数设为 0", "retries", + "dual_check.retries") if cfg.get("on_reject") not in (None, "", *DUAL_CHECK_ON_REJECT): allowed = "或".join(f"「{v}」" for v in DUAL_CHECK_ON_REJECT_LABEL.values()) add(f"「{label['on_reject']}」只能选{allowed},当前为「{cfg.get('on_reject')}」", "on_reject", diff --git a/backend/tests/fixtures/dual_check_shop.py b/backend/tests/fixtures/dual_check_shop.py index 215b09ac..7ac6bff7 100644 --- a/backend/tests/fixtures/dual_check_shop.py +++ b/backend/tests/fixtures/dual_check_shop.py @@ -7,6 +7,8 @@ - 按支付日、不排除退款:1、2、3、5 号单,380.0 - 按下单日、不排除退款:2、3、4、5 号单,360.0 - 付款客户数(按支付日、排除退款):按行计数 3,去重计数 2 + +另有渠道(channel)和测试单标记(is_test,都是 0)两列:写数据目录的测试拿它们凑「指标自己的筛选范围」和标记列。 """ from __future__ import annotations @@ -23,11 +25,11 @@ from app.db.base import SessionLocal from app.db.models import DataSource ROWS = [ - (1, "华东", 101, 100.0, "paid", "2026-08-31", "2026-09-01"), - (2, "华东", 102, 200.0, "paid", "2026-09-02", "2026-09-02"), - (3, "华南", 101, 50.0, "refunded", "2026-09-03", "2026-09-03"), - (4, "华南", 103, 80.0, "paid", "2026-09-07", "2026-09-08"), - (5, "华北", 102, 30.0, "paid", "2026-09-05", "2026-09-05"), + (1, "华东", 101, 100.0, "paid", "2026-08-31", "2026-09-01", "online", 0), + (2, "华东", 102, 200.0, "paid", "2026-09-02", "2026-09-02", "offline", 0), + (3, "华南", 101, 50.0, "refunded", "2026-09-03", "2026-09-03", "online", 0), + (4, "华南", 103, 80.0, "paid", "2026-09-07", "2026-09-08", "online", 0), + (5, "华北", 102, 30.0, "paid", "2026-09-05", "2026-09-05", "offline", 0), ] WEEK = "BETWEEN '2026-09-01' AND '2026-09-07'" #: 口径说明要的写法:按支付日、排除退款 @@ -52,8 +54,8 @@ def build(path: Path) -> Path: db = sqlite3.connect(path) db.executescript( "CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, customer_id INTEGER, amount REAL, status TEXT, " - "order_date TEXT, paid_date TEXT);") - db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", ROWS) + "order_date TEXT, paid_date TEXT, channel TEXT, is_test INTEGER);") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ROWS) db.commit() db.close() return path @@ -80,18 +82,22 @@ def _text(messages) -> str: class Blind: """盲算模型的剧本,也管图里别的模型调用。 - plans:{任务里出现的指标名: (SQL, 要交的列) 或 [SQL, …]、列}。盲算循环里依次查这几条 SQL,收尾时交最后一条查询的 - 第 0 行那一列(row 可改)。没对上指标名的盲算交 null。不是盲算的调用(报告撰写)返回 writer 的正文并记下收到的消息。 - calls 记下每次盲算调用(step / final),tasks 记下盲算收到的任务原文。 + plans:{任务里出现的指标名: (SQL, 要交的列) 或 [SQL, …]、列}。键写成「指标名|输入2」时只认计算式里那一项的盲算 + (一个指标有几个输入来自查询时用)。盲算循环里依次查这几条 SQL,收尾时交最后一条查询的第 0 行那一列(row 可改)。 + 没对上指标名的盲算交 null。不是盲算的调用(报告撰写)返回 writer 的正文并记下收到的消息。 + calls 记下每次盲算调用(step / final),tasks 记下盲算收到的任务原文(几个盲算同时进行时也是每个任务一条)。 + on_query:盲算第一次查库之前调用一次(比如往库里插一行,造出「两次取数之间数据变了」)。 """ - def __init__(self, monkeypatch, source: str, plans: dict, *, writer: str = "本周销售额见口径卡。", row: int = 0): + def __init__(self, monkeypatch, source: str, plans: dict, *, writer: str = "本周销售额见口径卡。", row: int = 0, + on_query=None): from app.providers import mock_model self.source = source self.plans = plans self.writer = writer self.row = row + self.on_query = on_query self.calls: list[str] = [] self.tasks: list[str] = [] self.writer_prompts: list[str] = [] @@ -114,11 +120,14 @@ class Blind: blind.calls.append("step") # 盲算的任务是第一条用户消息;同一次盲算的几轮收到的是同一条,只记一次 task_text = next((str(m.content) for m in messages if m.type == "human"), "") - if not blind.tasks or blind.tasks[-1] != task_text: + if task_text not in blind.tasks: blind.tasks.append(task_text) done = sum(isinstance(m, ToolMessage) for m in messages) sqls = blind.sqls(plan) if plan is not None else [] if done < len(sqls): + if blind.on_query is not None: + hook, blind.on_query = blind.on_query, None + hook() call = {"name": f"db_query__{blind.source}", "args": {"sql": sqls[done]}, "id": f"blind-{uuid.uuid4().hex[:6]}"} return AIMessage(content="查一下。", tool_calls=[call]) @@ -129,8 +138,9 @@ class Blind: monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) def plan_of(self, text: str): - for name, plan in self.plans.items(): - if f"指标:{name}" in text: + for key, plan in self.plans.items(): + name, _, label = key.partition("|") + if f"指标:{name}" in text and (not label or f"计算式里的 {label}" in text): return plan return None diff --git a/backend/tests/test_dual_check_config.py b/backend/tests/test_dual_check_config.py index c88696bb..2da4c559 100644 --- a/backend/tests/test_dual_check_config.py +++ b/backend/tests/test_dual_check_config.py @@ -1,9 +1,9 @@ """双盲复算的配置、发起时的设置快照、助手的节点说明。 - 画布校验(engine/schema.py):口径卡必须是上游的口径卡(没写时上游只能有一张),复算的指标、口径说明的键必须是 - 那张卡上的指标 ID,开关、误差、处置的取值;报错写检查器上的中文叫法 -- 发起(engine/launch.py):图里有双盲复算或反方审稿节点时把核查设置快照进 options["verify"],没有时 options 不变、 - run.started 和以前逐字相同;调用方给了的照它的 + 那张卡上的指标 ID,开关(只认 true / false,0、1 不算)、误差、处置的取值,不能配失败重试;报错写检查器上的中文叫法 +- 发起(engine/launch.py):图里(含子工作流)有双盲复算或反方审稿节点时把核查设置快照进 options["verify"],没有时 + options 不变、run.started 和以前逐字相同;调用方给了的照它的 - 助手的节点说明(api/copilot.py 的 NODE_REFERENCE)有 dual_check 一条:什么时候加、各配置项 表名、数字都是虚构的。 @@ -16,7 +16,7 @@ import pytest from sqlalchemy import delete, select from app.db.base import SessionLocal -from app.db.models import Run, RunEvent, Setting +from app.db.models import Run, RunEvent, Setting, Workflow, WorkflowVersion from app.engine.runner import run_manager from app.engine.schema import GraphSpec, validate_graph @@ -97,6 +97,20 @@ def test_option_values_are_checked(): assert "「允许的相对误差」需要填写不小于 0 的数" in found["tolerance"] +@pytest.mark.parametrize("value", [0, 1, 0.0]) +def test_zero_and_one_are_not_a_switch(value): + """0 == False、1 == True:用 in 比会放过去,执行时却按「跟随设置」走(执行器认的是 True / False 本身)。""" + [(where, text)] = problems(spec(CARD, dual={"enabled": value})) + assert where == "enabled" and "「启用双盲复算」" in text + + +def test_retries_are_refused(): + """双盲复算失败都是确定的(审核人没采用原流程、口径卡不对):重试只会把盲算再做一遍、再问审核人一次。""" + [(where, text)] = problems(spec(CARD, dual={"retries": 1})) + assert where == "retries" and "「失败重试次数」" in text and "双盲复算" in text + assert problems(spec(CARD, dual={"retries": 0})) == [] + + # -------------------------------------------------------------------------- # 发起:设置快照 # -------------------------------------------------------------------------- @@ -164,6 +178,33 @@ async def test_a_challenge_node_also_snapshots_the_settings(engine): assert set(out) == {"verify"} and out["verify"]["challenge"]["enabled"] is True +async def test_a_verify_node_inside_a_sub_workflow_also_snapshots_the_settings(engine): + """子工作流里的双盲复算同样在运行中途读设置:按运行时的取法找子工作流(固定了版本的读那一版),任何一层有就快照。""" + from app.engine.launch import _with_verify_snapshot + + inner_v1 = {"nodes": [node("start", "input"), node("out", "output", fields=[])], "edges": []} + inner_v2 = {"nodes": [node("start", "input"), node("review", "dual_check")], "edges": []} + async with SessionLocal() as session: + wf = Workflow(name="子流程-复核", graph=inner_v1, version=2) + session.add(wf) + await session.flush() + session.add_all([WorkflowVersion(workflow_id=wf.id, version=1, graph=inner_v1), + WorkflowVersion(workflow_id=wf.id, version=2, graph=inner_v2)]) + await session.commit() + wid = wf.id + + def outer(**config) -> dict: + return {"nodes": [node("start", "input"), node("sub", "subgraph", workflow_id=wid, **config)], "edges": []} + + async with SessionLocal() as session: + pinned = await _with_verify_snapshot(session, outer(workflow_version=2), None) + old_pin = await _with_verify_snapshot(session, outer(workflow_version=1), None) + current = await _with_verify_snapshot(session, outer(), None) + assert set(pinned) == {"verify"} and pinned["verify"]["dual_check"]["enabled"] is True + # 固定在没有核查节点的那一版、或者读工作流现在的图(也没有):options 原样不动 + assert old_pin is None and current is None + + async def test_without_verify_nodes_options_and_run_started_are_unchanged(engine): from app.engine.launch import launch_run diff --git a/backend/tests/test_dual_check_executor.py b/backend/tests/test_dual_check_executor.py index 7a1f3736..be7029d5 100644 --- a/backend/tests/test_dual_check_executor.py +++ b/backend/tests/test_dual_check_executor.py @@ -158,3 +158,52 @@ async def test_unknown_metric_ids_fail_the_node(shop): ctx = context(graph) with pytest.raises(NodeError, match="「复算的指标」中的「refund」不是口径卡上的指标 ID"): await run_dual_check({"nodes": {"card": card_of(metric(art))}, "evidence": [], "trail": []}, ctx) + + +async def test_constant_columns_of_the_original_query_are_not_shown(monkeypatch, shop): + """原 SQL 里写死的文字列('下单日' AS basis)是原流程的取舍,不是分组:不交给盲算。""" + sql = "SELECT '下单日' AS basis, '华东' AS region, SUM(amount) AS gmv FROM orders WHERE region = '华东'" + art = await snapshot(shop, columns=("basis", "region", "gmv"), rows=(("下单日", "华东", 330.0),), sql=sql) + blind = Blind(monkeypatch, shop, {"销售额": (RIGHT, "gmv")}) + ctx = context(AGENT_GRAPH) + item = metric(art, path="cell(nodes.fetch, 0, 'gmv')", via="tool_cell", node_id="fetch", + locator={"row": 0, "column": "gmv"}) + state = {"nodes": {"agent": {"model": "other"}, "card": card_of(item)}, "evidence": [], "trail": []} + await run_dual_check(state, ctx) + [task] = blind.tasks + assert "下单日" not in task and "basis" not in task and "华东" not in task + + +async def test_blind_checks_run_side_by_side_after_the_first(monkeypatch, shop): + """几个盲算同时进行(有上限),先单独试第一个:核查模型用不了时不必个个去试。结果按格子对回各自的指标。""" + import asyncio + + from app.engine import verify_agent + from app.engine.nodes import dual_check + + art = await snapshot(shop, columns=("gmv",), rows=tuple((float(100 + i),) for i in range(5))) + running = {"now": 0, "peak": 0, "order": []} + + async def fake(**kwargs): + running["now"] += 1 + running["peak"] = max(running["peak"], running["now"]) + running["order"].append(kwargs["task"]) + await asyncio.sleep(0.05) + running["now"] -= 1 + return verify_agent.VerifyOutcome(result=None, cells=[], queries=[], rationale=None, + usage={"calls": 1, "cost_usd": 0.01}, error="没有交结果", model="mock-fast") + + monkeypatch.setattr(verify_agent, "run_verify_agent", fake) + metrics = [] + for i in range(5): + item = metric(art, locator={"row": i, "column": "gmv"}, value=float(100 + i)) + metrics.append({**item, "id": f"m{i}", "name": f"第 {i} 组销售额", "value": float(100 + i), + "rendered": f"{100 + i}.0元", "substituted": f"{100 + i}.0"}) + ctx = context(AGENT_GRAPH) + state = {"nodes": {"agent": {"model": "other"}, "card": card_of(*metrics)}, "evidence": [], "trail": []} + updates = await run_dual_check(state, ctx) + out = updates["nodes"]["dual"] + assert [m["status"] for m in out["metrics"]] == ["unchecked"] * 5 + assert 1 < running["peak"] <= dual_check.BLIND_CONCURRENCY + assert "第 0 组销售额" in running["order"][0] + assert updates["usage"]["calls"] == 5 and updates["usage"]["cost_usd"] == 0.05 diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 6ecd32a2..9f263441 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -1,10 +1,13 @@ """双盲复算的选择写进数据目录(engine/dual_check_facts.py)。 -- 只差一个方面、又对得上目录事实时才写:时间列 → 业务日期,一边多一个只涉及一张表的筛选条件 → 有效记录条件, - 聚合只差去不去重 → 去重规则;按被采纳那一边的写法记 -- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法:不写,照实说原因 -- 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖, - 推断的不同说法照改 +- 只差一个方面、又对得上目录事实时才写:两边按不同的时间列筛 → 业务日期,一边多一个排除无效记录的条件 → 有效记录 + 条件,聚合只差去不去重、被采纳的一边去重 → 去重规则;按被采纳那一边的写法记 +- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写, + 照实说原因 +- 指标自己的筛选范围(只看华东、只看线上)不是有效记录条件:取值出现在指标名称、口径说明、所在分组、运行输入里的, + 条件用到分组的列的,不是在排除退款、作废、测试这类无效记录的,都不写 +- 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖; + 目录里已有的有效记录条件被采纳的一边也用着的,合起来写,没用着的不动;同一次裁决里互相矛盾的选择都不写 差异条目一律由 sql_semantics.explain_sql_diff 真算出来,和节点交给审核人看的是同一份。表名、数字都是虚构的。 """ @@ -22,22 +25,43 @@ from tests.fixtures.dual_check_shop import ( BUYERS_DISTINCT, BY_ORDER_DATE, RIGHT, + WEEK, WITH_REFUNDS, add_source, build, ) from tests.fixtures.sources import drop_source -SCHEMA = {"tables": {"orders": {"name": "orders", "columns": [ - {"name": "id", "type": "INTEGER"}, {"name": "region", "type": "TEXT"}, {"name": "customer_id", "type": "INTEGER"}, - {"name": "amount", "type": "REAL"}, {"name": "status", "type": "TEXT"}, {"name": "order_date", "type": "TEXT"}, - {"name": "paid_date", "type": "TEXT"}], "primary_key": ["id"]}}} +SCHEMA = {"tables": { + "orders": {"name": "orders", "columns": [ + {"name": "id", "type": "INTEGER"}, {"name": "region", "type": "TEXT"}, + {"name": "customer_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}, + {"name": "status", "type": "TEXT"}, {"name": "order_date", "type": "TEXT"}, + {"name": "paid_date", "type": "TEXT"}, {"name": "channel", "type": "TEXT"}, + {"name": "is_test", "type": "INTEGER"}], "primary_key": ["id"]}, + "customers": {"name": "customers", "columns": [ + {"name": "id", "type": "INTEGER"}, {"name": "level", "type": "TEXT"}, {"name": "status", "type": "TEXT"}], + "primary_key": ["id"]}, +}} +#: 不带时间条件、只排除退款:和 RIGHT 只差「按不按时间筛」 +NO_TIME = "SELECT SUM(amount) AS gmv FROM orders WHERE status <> 'refunded'" +COUNT_WEEK = f"SELECT COUNT(*) AS n FROM orders WHERE paid_date {WEEK}" +SUM_WEEK = f"SELECT SUM(amount) AS gmv FROM orders WHERE paid_date {WEEK}" -def candidate(original: str, blind: str, choice: str, *, metric: str = "销售额", source: str = "shop") -> dict: +def candidate(original: str, blind: str, choice: str, *, metric: str = "销售额", source: str = "shop", + scope: dict | None = None) -> dict: diff = explain_sql_diff(original, blind, "sqlite", schema=SCHEMA, labels=("原流程", "盲算")).as_dict() - return {"metric": metric, "choice": choice, "source": source, "dialect": "sqlite", - "sql": {"a": original, "b": blind}, "diff": diff} + out = {"metric": metric, "choice": choice, "source": source, "dialect": "sqlite", + "sql": {"a": original, "b": blind}, "diff": diff} + if scope is not None: + out["scope"] = scope + return out + + +def extra(condition: str, base: str = SUM_WEEK) -> str: + """base 多一个条件。""" + return base.replace(" WHERE ", f" WHERE {condition} AND ", 1) # -------------------------------------------------------------------------- @@ -52,12 +76,24 @@ def test_a_different_time_column_becomes_the_business_date(choice, column): assert (fact.table, fact.item, fact.value, fact.choice) == ("orders", "business_date", {"column": column}, choice) +def test_a_time_filter_on_one_side_only_is_not_a_business_date(): + """一边按支付日筛、另一边根本没按时间筛:审核人判断的是要不要限定时间,没在两列之间选过。""" + picked = candidate(RIGHT, NO_TIME, "a") + assert [i["aspect"] for i in picked["diff"]["items"]] == ["time"] + fact, why = derive(picked, SCHEMA) + assert fact is None and "只有被采纳的原流程按时间筛选" in why and "不是按哪一列归属" in why + fact, why = derive(candidate(RIGHT, NO_TIME, "b"), SCHEMA) + assert fact is None and "被采纳的盲算没有按时间筛选" in why + + def test_an_extra_filter_on_the_adopted_side_becomes_the_valid_filter(): fact, why = derive(candidate(WITH_REFUNDS, RIGHT, "b"), SCHEMA) assert why is None and (fact.item, fact.value) == ("valid_filter", "status <> 'refunded'") # 原流程多出这个条件、审核人采纳原流程:同样记这个条件 fact, why = derive(candidate(RIGHT, WITH_REFUNDS, "a"), SCHEMA) assert why is None and (fact.item, fact.value) == ("valid_filter", "status <> 'refunded'") + # 被采纳那一边的写法跟着记下来,有效记录条件合并时要看它还用了哪些条件 + assert fact.sql == RIGHT and fact.dialect == "sqlite" def test_the_adopted_side_without_the_extra_filter_has_nothing_to_record(): @@ -65,6 +101,63 @@ def test_the_adopted_side_without_the_extra_filter_has_nothing_to_record(): assert fact is None and "被采纳的盲算没有这个条件" in why and "销售额" in why +@pytest.mark.parametrize("original, blind, choice, metric", [ + # 原流程按指标名只看华东,盲算漏了:审核人自然选原流程 + (extra("region = '华东'", RIGHT), RIGHT, "a", "华东销售额"), + # 盲算按指标名只数线上的单 + (COUNT_WEEK, extra("channel = 'online'", COUNT_WEEK), "b", "线上订单数"), + # 只保留一部分取值的写法,状态列上也一样 + (COUNT_WEEK, extra("status IN ('paid')", COUNT_WEEK), "b", "已支付订单数"), + (SUM_WEEK, extra("(region = '华东' OR region = '华南')"), "b", "南方销售额"), + # 排除的写法,排除的却不是无效记录 + (COUNT_WEEK, extra("channel <> 'offline'", COUNT_WEEK), "b", "线上订单数"), + (SUM_WEEK, extra("region <> '华东'"), "b", "非华东销售额"), + (SUM_WEEK, extra("status <> 'paid'"), "b", "未支付订单金额"), + (SUM_WEEK, extra("customer_id IS NOT NULL"), "b", "会员销售额"), +]) +def test_a_metric_scope_filter_is_not_a_valid_filter(original, blind, choice, metric): + picked = candidate(original, blind, choice, metric=metric) + assert [i["aspect"] for i in picked["diff"]["items"]] == ["filter"] + fact, why = derive(picked, SCHEMA) + assert fact is None and "可能是指标自己的筛选范围" in why and metric in why + + +def test_the_metric_scope_is_read_from_the_names_groups_and_inputs(): + """排除的写法、排除的也像无效记录,但取值出现在口径说明、所在分组、运行输入里,或者用的是分组的列:拿不准,不写。""" + blind = extra("channel <> 'test'") + assert derive(candidate(SUM_WEEK, blind, "b"), SCHEMA)[0] is not None + for scope in ({"texts": ["只看正式渠道:不含 test 渠道的订单"]}, {"texts": ["channel = test"]}, + {"columns": ["channel"]}): + fact, why = derive(candidate(SUM_WEEK, blind, "b", scope=scope), SCHEMA) + assert fact is None and "可能是指标自己的筛选范围" in why, scope + + +@pytest.mark.parametrize("condition, written", [ + ("is_test = 0", "is_test = 0"), + ("is_test <> 1", "is_test <> 1"), + ("status NOT IN ('refunded', 'void')", "NOT status IN ('refunded', 'void')"), + ("status <> '已作废'", "status <> '已作废'"), +]) +def test_conditions_that_drop_invalid_rows_are_valid_filters(condition, written): + fact, why = derive(candidate(SUM_WEEK, extra(condition), "b"), SCHEMA) + assert why is None and (fact.item, fact.value) == ("valid_filter", written) + + +@pytest.mark.parametrize("condition", ["is_test = 1", "is_test <> 0", "status <> 9"]) +def test_conditions_that_keep_invalid_rows_or_unknown_codes_are_not(condition): + fact, why = derive(candidate(SUM_WEEK, extra(condition), "b"), SCHEMA) + assert fact is None and "可能是指标自己的筛选范围" in why + + +def test_a_code_the_catalog_calls_void_counts_as_an_invalid_row(): + notes = {"orders": {"columns": {"status": {"codes": catalog.make_item({"9": "作废", "1": "已支付"}, "human")}}}} + fact, why = derive(candidate(SUM_WEEK, extra("status <> 9"), "b"), SCHEMA, notes) + assert why is None and fact.value == "status <> 9" + # 码值表里写的是正常的状态:排除它就是指标自己的范围 + fact, why = derive(candidate(SUM_WEEK, extra("status <> 1"), "b"), SCHEMA, notes) + assert fact is None and "可能是指标自己的筛选范围" in why + + def test_table_names_are_dropped_from_the_recorded_filter(): qualified = RIGHT.replace("FROM orders WHERE status", "FROM orders o WHERE o.status") \ .replace("paid_date", "o.paid_date") @@ -72,10 +165,24 @@ def test_table_names_are_dropped_from_the_recorded_filter(): assert why is None and fact.value == "status <> 'refunded'" -@pytest.mark.parametrize("choice, text", [("b", "对 customer_id 计数时去重"), ("a", "对 customer_id 计数时不去重")]) -def test_counting_with_or_without_distinct_becomes_the_dedup_rule(choice, text): - fact, why = derive(candidate(BUYERS, BUYERS_DISTINCT, choice, metric="付款客户数"), SCHEMA) - assert why is None and (fact.table, fact.item, fact.value) == ("orders", "dedup", text) +def test_a_condition_across_two_tables_is_not_recorded(): + joined = (f"SELECT SUM(o.amount) AS gmv FROM orders o JOIN customers c ON o.customer_id = c.id " + f"WHERE o.paid_date {WEEK}") + picked = candidate(joined, f"{joined} AND o.status <> c.status", "b") + assert [i["aspect"] for i in picked["diff"]["items"]] == ["filter"] + fact, why = derive(picked, SCHEMA) + assert fact is None and "不是只涉及一张表的列" in why + + +def test_counting_distinct_becomes_the_dedup_rule(): + fact, why = derive(candidate(BUYERS, BUYERS_DISTINCT, "b", metric="付款客户数"), SCHEMA) + assert why is None and (fact.table, fact.item, fact.value) == ("orders", "dedup", "对 customer_id 计数时去重") + + +def test_counting_without_distinct_is_not_a_dedup_rule(): + """采纳了不去重的一边:那是这个指标自己的算法(数行),写成表的「不去重」会把别的指标带偏。""" + fact, why = derive(candidate(BUYERS, BUYERS_DISTINCT, "a", metric="付款笔数"), SCHEMA) + assert fact is None and "被采纳的原流程没有去重" in why def test_more_than_one_difference_is_not_recorded(): @@ -165,9 +272,48 @@ async def test_a_confirmed_different_item_is_kept(shop): assert entry.version == 1 and entry.notes["business_date"]["value"]["column"] == "order_date" -async def test_an_inferred_different_item_is_replaced(shop): +async def test_an_inferred_condition_both_sides_use_is_extended(shop): + """目录里推断的「is_test = 0」两边都按它筛了,盲算另外排除了退款:合起来写,原有的条件不丢。""" + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("is_test = 0", "llm")}) + base = f"SELECT SUM(amount) AS gmv FROM orders WHERE is_test = 0 AND paid_date {WEEK}" + out = await record_choices([candidate(base, extra("status <> 'refunded'", base), "b", source=name)], + run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}], out + item = (await entry_of(source_id)).notes["valid_filter"] + assert item["value"] == "is_test = 0 AND status <> 'refunded'" + assert (item["source"], item["status"]) == ("human", "confirmed") + + +async def test_a_confirmed_condition_is_not_extended(shop): + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("is_test = 0", "human", "confirmed")}) + base = f"SELECT SUM(amount) AS gmv FROM orders WHERE is_test = 0 AND paid_date {WEEK}" + out = await record_choices([candidate(base, extra("status <> 'refunded'", base), "b", source=name)], + run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [] + reason = out["skipped"][0]["reason"] + assert "已有人工确认过的说法「is_test = 0」" in reason and "status <> 'refunded'" in reason and "未改动" in reason + entry = await entry_of(source_id) + assert entry.version == 1 and entry.notes["valid_filter"]["value"] == "is_test = 0" + + +async def test_an_inferred_condition_the_adopted_side_does_not_use_is_kept(shop): + """目录里推断的「status = 'paid'」两边都没用:审核人的选择说明不了它对不对,不静默替换。""" name, source_id = shop await seed(source_id, {"valid_filter": catalog.make_item("status = 'paid'", "llm")}) + out = await record_choices([candidate(WITH_REFUNDS, RIGHT, "b", source=name)], run_id="run-1", + node_title="复核", actor=None) + assert out["written"] == [] + reason = out["skipped"][0]["reason"] + assert "「status = 'paid'」" in reason and "被采纳的盲算没有按它筛选" in reason and "未改动" in reason + entry = await entry_of(source_id) + assert entry.version == 1 and entry.notes["valid_filter"]["value"] == "status = 'paid'" + + +async def test_an_inferred_same_condition_is_confirmed(shop): + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("status <> 'refunded'", "llm")}) out = await record_choices([candidate(WITH_REFUNDS, RIGHT, "b", source=name)], run_id="run-1", node_title="复核", actor=None) assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}] @@ -175,6 +321,15 @@ async def test_an_inferred_different_item_is_replaced(shop): assert item["value"] == "status <> 'refunded'" and (item["source"], item["status"]) == ("human", "confirmed") +async def test_an_inferred_different_dedup_rule_is_kept(shop): + name, source_id = shop + await seed(source_id, {"dedup": catalog.make_item("同一订单可能有多行,按 id 去重", "llm")}) + out = await record_choices([candidate(BUYERS, BUYERS_DISTINCT, "b", metric="付款客户数", source=name)], + run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [] and "按 id 去重" in out["skipped"][0]["reason"] + assert (await entry_of(source_id)).notes["dedup"]["value"] == "同一订单可能有多行,按 id 去重" + + async def test_the_same_column_inferred_is_confirmed_and_keeps_its_rule(shop): """目录里推断的业务日期正是这一列(还写了规则、时区):确认它,规则、时区原样留着。""" name, source_id = shop @@ -203,6 +358,19 @@ async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_do assert (await entry_of(source_id)).notes["business_date"]["value"] == {"column": "order_date"} +async def test_contradictory_choices_in_one_decision_write_nothing(shop): + """同一次裁决里,一个指标采纳了按下单日、另一个采纳了按支付日:写哪个都是替人拿主意,都不写。""" + name, source_id = shop + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "a", metric="销售额", source=name), + candidate(BY_ORDER_DATE, RIGHT, "b", metric="复核销售额", source=name)], + run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [] + [skipped] = out["skipped"] + assert "「销售额」" in skipped["reason"] and "「复核销售额」" in skipped["reason"] + assert "order_date" in skipped["reason"] and "paid_date" in skipped["reason"] and "互相矛盾" in skipped["reason"] + assert await entry_of(source_id) is None + + async def test_a_missing_source_or_an_unrecordable_choice_is_reported(shop): name, _ = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source="no_such_source"), diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 09392a85..c68837cf 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -2,15 +2,21 @@ (tests/fixtures/dual_check_shop.py),盲算模型是 MockChatModel 剧本。 - 一致:不挂起,产出 agreed,日志 dual_check_agreed;盲算的查询发 tool.end(role: verify),台账只有一条 dual_check, - 用量计入运行;盲算 agent 的任务里没有原 SQL -- 不一致:挂起,审批 payload 和对照工件按约定;a / b / neither 三种选择的产出和缺口,on_reject 为 fail 时失败 -- 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖 + 用量计入运行;盲算 agent 的任务里没有原 SQL,也没有原流程结果的列名 +- 一个都没核成是 unchecked、只核成一部分是 partial,日志 dual_check_unchecked 说清哪几个、为什么 +- 不一致:挂起,审批 payload 和对照工件按约定;a / b / neither 三种选择的产出和缺口,没选的照实记,驳回、on_reject + 为 fail 时失败 +- 差异说明只比这一格所在的那一列(一条查询算好几列、分组后取一行的,收窄了再比) +- 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖,指标自己的 + 筛选范围不写,两条 SQL 规范化后一致的不写 - 全局关闭直通、节点覆盖;设置按发起时的快照;重放时盲算不再执行、事件不重复;报告的写作目录不含盲算的查询 -- 触顶、分组结果的维度、两个指标用到同一格只盲算一次 +- 触顶、分组结果的维度(只给取值、遮罩列不给)、两个指标用到同一格只盲算一次、遮罩列上的盲算结果不采用 """ from __future__ import annotations import asyncio +import json +import sqlite3 import pytest from httpx import ASGITransport, AsyncClient @@ -26,6 +32,7 @@ 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, @@ -70,6 +77,14 @@ async def shop(tmp_path): 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: @@ -159,6 +174,7 @@ async def test_agreeing_check_passes_without_asking(monkeypatch, shop): 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"] @@ -174,18 +190,21 @@ async def test_agreeing_check_passes_without_asking(monkeypatch, shop): assert kinds == ["dual_check"] entry = finished.data["evidence"][0] assert entry["card"] == "card" and entry["status"] == "agreed" - assert entry["metrics"] == [{"id": "gmv", "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、取数节点 + # 盲算 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 要知道算的是哪一组(同一行的维度列),但看不到同一行别的数;统计期这类参数在运行输入里。""" + """结果分了组:盲算 agent 要知道算的是哪一组(同一行维度列的取值),但看不到同一行别的数、原流程是按哪一列分的组; + 统计期这类参数在运行输入里。""" name, _ = shop metric = {**GMV, "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} blind = Blind(monkeypatch, name, {"华东销售额": (BY_REGION, "gmv")}) @@ -194,12 +213,27 @@ async def test_grouped_rows_tell_the_blind_agent_which_group(monkeypatch, shop): run = await start(spec, {"week_start": "2026-09-01", "week_end": "2026-09-07"}) assert run.status == "succeeded", run.error [task] = blind.tasks - assert "region 为「华东」" in task and "(未提供" in task and "300" not in task + 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_two_metrics_using_one_cell_are_checked_once(monkeypatch, shop): name, _ = shop share = {"id": "gmv_k", "name": "销售额(千元)", "unit": "千元", "decimals": 2, "format": "thousands", @@ -213,20 +247,72 @@ async def test_two_metrics_using_one_cell_are_checked_once(monkeypatch, shop): async def test_hitting_a_limit_counts_as_unchecked(monkeypatch, shop): + """查询次数用完了还没查到要交的那一格:按无法复算计入缺口;一个指标都没核成,产出是 unchecked,不是 agreed。""" name, _ = shop - Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + Blind(monkeypatch, name, {"销售额": ([BLIND_TOTAL, 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) assert [(d["limit"], d["metric"]) for d in logs(evs, "dual_check_limit")] == [("max_queries", "gmv")] out = dual_output(run, evs) - assert out["status"] == "agreed" and out["metrics"][0]["status"] == "unchecked" + assert out["status"] == "unchecked" and out["metrics"][0]["status"] == "unchecked" + assert out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 1} assert out["issuance_gaps"] == ["指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] + [said] = logs(evs, "dual_check_unchecked") + assert said["metrics"] == [{"id": "gmv", "name": "销售额", "reason": "盲算已达每项最多查询次数(1 次)"}] + 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"] == ["双盲复算「复核销售额」:指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] +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 @@ -244,10 +330,13 @@ async def test_an_unusable_verify_model_is_tried_once(monkeypatch, shop): 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 - out = dual_output(run, await events(run.id)) - assert [m["status"] for m in out["metrics"]] == ["unchecked", "unchecked"] + 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"] # -------------------------------------------------------------------------- @@ -348,14 +437,33 @@ async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client): assert not logs(evs, "dual_check_catalog") and await notes_of(source_id) is None -async def test_a_missing_choice_counts_as_neither(monkeypatch, shop, client): - name, _ = shop +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"}) - out = dual_output(done, await events(run.id)) - assert out["choices"] == {"gmv": "neither"} - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人判断两边都不对"] + 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_fails_the_node(monkeypatch, shop, client): + 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")) + assert done.status == "failed" and "人工驳回" in (done.error or "") and "口径要重新对" in done.error async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client): @@ -446,6 +554,185 @@ async def test_a_confirmed_catalog_entry_is_not_overwritten(monkeypatch, shop, c assert entry.version == 1 and entry.notes["business_date"]["value"] == {"column": "order_date"} +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] + 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_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_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 + + # -------------------------------------------------------------------------- # 设置:全局开关、节点覆盖、发起时的快照 # -------------------------------------------------------------------------- From f7d1a2ecbaafe1786b559862d6d72f4fdb64a2f4 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 16:43:14 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E6=95=B4=E6=94=B9=EF=BC=88=E7=AC=AC=202=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=89=EF=BC=9A=E9=81=AE=E7=BD=A9=E7=9A=84=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=88=97=E4=B8=8D=E6=94=B6=E7=AA=84=EF=BC=8C=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E6=97=B6=E5=8E=BB=E6=8E=89=20HAVING=EF=BC=8C=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=A1=E5=B8=A6=E4=B8=8A=E5=A4=84=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E5=86=99=E7=9B=AE=E5=BD=95=E7=9A=84=E9=A2=84=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=AC=A1=E8=A3=81=E5=86=B3=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E6=9D=A1=E4=BB=B6=E5=90=88?= =?UTF-8?q?=E8=B5=B7=E6=9D=A5=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点(nodes/dual_check.py) - 差异说明收窄到这一格时,分组键的结果列或它用到的列设了遮罩,就不收窄、照整条比。原流程、盲算各按自己那份快照 的遮罩(查询当时记下的加数据源现在设的)判断。以前按这一行的分组值补等值条件,「region = '华东'」这样的遮罩列 取值会跟着差异说明进审批 payload、human.requested 事件、对照工件和 dual_check_catalog 日志 - 按行收窄时连 HAVING 一起去掉:这一行既然在结果里,它所在的组就满足 HAVING。留着会多出一条「分组后的筛选」, 还会占掉「只差一个方面」的名额,真正的差异(比如时间列)就写不进目录 - 审批 payload 在约定的几项之外带上实际生效的 on_reject。摘要里每个指标另带 catalog:选 a、选 b 时,按发起审批 时的目录各会往数据目录里写什么({write: true, source, table, item, value}),不写的给出原因({write: false, reason})。审核人要看得出不采用原流程会不会让运行失败、某个选择会不会被记成人工确认的目录项 (on_reject 以前只能从图快照里读,子工作流里的节点读不到) 写数据目录(dual_check_facts.py) - 写入的判断拆成不读库的 _plan:真写(_write)和审批卡上的预告(preview)用同一套判断,不会说一套写一套 - 同一次裁决里落到同一项上的几个说法合成一项(_merged): - 说法一样的只写一次,备注列出每个指标 - 有效记录条件用 AND 合成一句。derive 只收排除无效记录的条件,「排除退款」和「排除测试单」并不矛盾,以前两条 都被丢掉,原因还写成「互相矛盾」 - 业务日期、去重规则说法不同的仍然都不写,原因改为「给出了不同的说法」 - 合并后再和目录里已有的推断条件合起来写时,要求每个被采纳的一边都按已有条件筛过;有一个没按它筛,就不动并说明 是哪个指标 测试 - 遮罩分组列(原流程侧、盲算侧)的取值不出现在 payload、对照工件、节点产出和节点事件里 - HAVING 收窄后只剩时间列一处差异,并写进目录 - payload 的 on_reject、摘要里的预告(一致、已确认不覆盖、指标自己的范围几种情形) - 有效记录条件合并(含和已有推断条件合写、有一个被采纳的一边没用已有条件时不动),同一说法只写一次 - 补上复核指出的三处接线缺口,各有一条图级用例: - 带取舍的列名(gmv_by_order_date、paid_orders)不交给盲算 - 取值在运行输入里的排除条件不写目录 - 分组列上的 is_test = 0 不写目录 - 13 处变异(遮罩判断两侧、HAVING、上面三处接线、合并、预告、on_reject 等)去掉后,相关测试都会变红 --- backend/app/engine/dual_check_facts.py | 254 +++++++++++++++++-------- backend/app/engine/nodes/dual_check.py | 64 ++++--- backend/tests/test_dual_check_facts.py | 103 +++++++++- backend/tests/test_dual_check_node.py | 150 ++++++++++++++- 4 files changed, 456 insertions(+), 115 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index c1ad8ae1..ecf2b1eb 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -23,10 +23,17 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不 **怎么写。** 照对话提案的规矩核对(plan_patch:列在不在表结构里、取值格式),记为人工填写、已确认,备注写明来自哪次 运行、哪个节点、哪个指标。目录里这一项已经是人工确认过的另一种说法时不覆盖,照实交回原因;已经是同样的说法就什么都 -不写(再写一遍也不升版本)。有效记录条件是一整句条件:目录里已有的条件被采纳的一边也按它筛了的,和新条件合起来写 -(已确认的不改,照实说);被采纳的一边没按它筛的,两者的关系拿不准,不动。推断的去重规则说的未必是同一件事,也不 -替换。同一次裁决里几个指标对同一项给出不同说法的,一个都不写。整个写入由节点放在 ask() 之后的一个 @task 里:审批 -恢复重放、接着运行时不会再写一遍。 +不写(再写一遍也不升版本)。有效记录条件是一整句条件:目录里已有的条件被采纳的一边(几个指标并成一项时是每一个) +也按它筛了的,和新条件合起来写(已确认的不改,照实说);有一个没按它筛的,两者的关系拿不准,不动。推断的去重规则 +说的未必是同一件事,也不替换。整个写入由节点放在 ask() 之后的一个 @task 里:审批恢复重放、接着运行时不会再写一遍。 + +**同一次裁决里几个指标落到同一项上。** 说法一样的写一次,备注里列出每个指标。有效记录条件是能用 AND 叠加的排除条件 +(这里只收排除无效记录的),几个指标各自采纳的条件都有人的选择撑着,合成一句写——「排除退款」和「排除测试单」并不 +矛盾。业务日期一张表只有一个,去重规则说的未必是同一件事,说法不同就一个都不写。 + +**预告。** 审批卡上要让审核人看得出选了之后会往目录里写什么(preview):按发起审批时的目录,选原流程、选盲算各会 +写什么、不写的为什么。和真写是同一套判断(derive 认事实、_plan 对目录),所以只在两处可能和最后写的不一样:审批停下 +期间目录被人改了,或者同一次裁决里几个指标落到同一项上(上一段)。 catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 """ @@ -35,7 +42,7 @@ from __future__ import annotations import logging import re from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from sqlalchemy import select @@ -79,7 +86,8 @@ _SCOPE_DIGITS = 2 @dataclass(frozen=True) class Fact: """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明;sql、dialect 是被采纳 - 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。""" + 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。also 是同一次裁决里落到同一项上、 + 并进这一项的其余几个指标(各自的指标、选择、写法),value 是合起来之后的说法。""" source: str table: str @@ -89,6 +97,18 @@ class Fact: choice: str sql: str = "" dialect: str | None = None + also: tuple[Fact, ...] = () + + +def _members(fact: Fact) -> tuple[Fact, ...]: + """这一项背后的每个指标的选择:并过的连同并进来的。""" + return (fact, *fact.also) + + +def _names(fact: Fact) -> str: + """说明里的指标叫法:「销售额」,几个指标并成一项时「销售额」、「订单金额」。""" + names = list(dict.fromkeys(m.metric for m in _members(fact))) + return "、".join(f"「{n}」" for n in names[:4]) + (f"等 {len(names)} 个" if len(names) > 4 else "") def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, @@ -402,28 +422,45 @@ def _conditions_of(sql: str, read: str | None) -> set[str] | None: return {_normalized(part, read) for where in tree.find_all(exp.Where) for part in _and_parts(where.this)} +def _and_join(base: str | None, values: list[str], dialect: str | None) -> str | None: + """几句条件用 AND 接成一句:已经有的(base 里的、前面接过的,按 _normalized 比)不再接;最外层是 OR 的加括号, + 不然后面接的条件只并到 OR 的后一半上。每句保留原来的写法。有一句解析不了返回 None。""" + from sqlglot import exp + + read = _read(dialect) + texts: list[str] = [] + seen: set[str] = set() + for text in [*([base] if base else []), *values]: + tree = _parse(str(text), read) + if tree is None: + return None + keys = {_normalized(p, read) for p in _and_parts(tree)} + if keys <= seen: + continue + seen |= keys + texts.append(f"({text})" if isinstance(tree, exp.Or) else str(text)) + return " AND ".join(texts) + + def _combined_filter(existing: str, fact: Fact) -> tuple[str | None, str | None]: """目录里已有的有效记录条件和这次的新条件合起来:(合起来的整句条件, None) 或 (None, 为什么不动)。 有效记录条件是一整句(copilot 提案也要求「原有条件也要带上」)。只写新条件会把两边都按着筛的原有条件静默替换掉; - 原有条件被采纳的一边没按它筛,审核人的选择说明不了它对不对,也不能替人删掉。""" + 原有条件有一个被采纳的一边没按它筛,审核人的选择说明不了它对不对(合起来写就等于替人确认了它),也不能替人删掉。""" label = catalog.TABLE_FIELD_LABEL["valid_filter"] read = _read(fact.dialect) - have_tree, new_tree = _parse(existing, read), _parse(str(fact.value), read) - if have_tree is None or new_tree is None: + have_tree = _parse(existing, read) + combined = _and_join(existing, [str(m.value) for m in _members(fact)], fact.dialect) \ + if have_tree is not None else None + if combined is None: return None, f"「{fact.table}」已有的{label}「{existing}」无法解析,与这次的条件合不起来,未改动" - have = [_normalized(p, read) for p in _and_parts(have_tree)] - used = _conditions_of(fact.sql, read) or set() - if not set(have) <= used: - return None, (f"「{fact.table}」已有的{label}「{existing}」,被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" - "没有按它筛选,两者的关系无法确定,未改动。如需更改,请到数据目录中修改") - if {_normalized(p, read) for p in _and_parts(new_tree)} <= set(have): - return existing, None - from sqlglot import exp - - # 原有条件最外层是 OR 的,接 AND 之前要加括号,不然新条件只并到 OR 的后一半上 - head = f"({existing})" if isinstance(have_tree, exp.Or) else existing - return f"{head} AND {fact.value}", None + have = {_normalized(p, read) for p in _and_parts(have_tree)} + for member in _members(fact): + if not have <= (_conditions_of(member.sql, read) or set()): + return None, (f"「{fact.table}」已有的{label}「{existing}」,指标「{member.metric}」被采纳的" + f"{SIDE_LABEL.get(member.choice, member.choice)}没有按它筛选,两者的关系无法确定,未改动。" + "如需更改,请到数据目录中修改") + return combined, None def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any], @@ -491,9 +528,13 @@ def _aggregate_pair(a: Any, b: Any, dialect: Any) -> tuple[str, tuple[bool, bool def note_of(fact: Fact, *, run_id: str, node_title: str) -> str: - """目录项上的备注:来自哪次运行、哪个节点、哪个指标、采纳了哪一边。""" - return (f"双盲复算(运行 {run_id},节点「{node_title}」):审核人在指标「{fact.metric}」上" - f"采用了{SIDE_LABEL.get(fact.choice, fact.choice)}的写法") + """目录项上的备注:来自哪次运行、哪个节点、哪些指标、各采纳了哪一边。""" + parts = [] + for side, label in SIDE_LABEL.items(): + names = list(dict.fromkeys(m.metric for m in _members(fact) if m.choice == side)) + if names: + parts.append(f"在指标{'、'.join(f'「{n}」' for n in names)}上采用了{label}的写法") + return f"双盲复算(运行 {run_id},节点「{node_title}」):审核人" + ",".join(parts) def _value_text(item: str, value: Any) -> str: @@ -509,21 +550,38 @@ def _same_fact(item: str, a: Any, b: Any) -> bool: return isinstance(a, str) and isinstance(b, str) and " ".join(a.split()) == " ".join(b.split()) -def _contradiction(facts: list[Fact]) -> str: +def _differing(facts: list[Fact]) -> str: """同一次裁决里对同一项给出的不同说法,说清是谁、各说了什么。""" first = facts[0] label = catalog.TABLE_FIELD_LABEL.get(first.item, first.item) said = ";".join(f"指标「{f.metric}」采纳的是「{_value_text(f.item, f.value)}」" for f in facts[:4]) - return (f"同一次裁决里对「{first.table}」的{label}给出的选择互相矛盾({said}),无法确定哪一个是数据事实," + return (f"同一次裁决里对「{first.table}」的{label}给出了不同的说法({said}),无法确定哪一个是数据事实," "都未写入数据目录") +def _merged(members: list[Fact]) -> tuple[Fact | None, str | None]: + """同一次裁决里落到同一项上的几个说法合成一项:(合起来的那一项, None) 或 (None, 为什么都不写)。 + + 说法都一样的写一次。有效记录条件用 AND 合成一句(derive 只收排除无效记录的条件,几句叠起来不会互相打架)。 + 业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就都不写。""" + first, rest = members[0], tuple(members[1:]) + if not rest: + return first, None + if first.item == "valid_filter": + value = _and_join(None, [str(m.value) for m in members], first.dialect) + return (replace(first, value=value, also=rest), None) if value is not None else (None, _differing(members)) + if all(_same_fact(first.item, m.value, first.value) for m in rest): + return replace(first, also=rest), None + return None, _differing(members) + + async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str, actor: str | None) -> dict[str, list[dict[str, Any]]]: """把审核人的选择写进数据目录:{written: [{source, table, item}], skipped: [{reason}]}。永远不抛:数据源没了、 目录写不进去,都落在 skipped 里照实说。 - 先把每个候选认成目录项,再按(数据源、表、项)归拢:同一项上说法互相矛盾的整组不写,剩下的逐个写。""" + 先把每个候选认成目录项,再按(数据源、表、项)归拢成一项(_merged:说法一样的、有效记录条件合起来写,说法 + 不同的整组不写),逐项写。""" skipped: list[dict[str, Any]] = [] facts: list[Fact] = [] for candidate in candidates: @@ -549,15 +607,15 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no groups.setdefault((fact.source, fact.table, fact.item), []).append(fact) written: list[dict[str, Any]] = [] for members in groups.values(): - if any(not _same_fact(f.item, f.value, members[0].value) for f in members[1:]): - skipped.append({"reason": _contradiction(members)}) + fact, why = _merged(members) + if fact is None: + skipped.append({"reason": why}) continue - for fact in members: - done, why = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) - if done: - written.append({"source": fact.source, "table": fact.table, "item": fact.item}) - else: - skipped.append({"reason": why}) + done, why = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) + if done: + written.append({"source": fact.source, "table": fact.table, "item": fact.item}) + else: + skipped.append({"reason": why}) return {"written": written, "skipped": skipped} @@ -566,69 +624,103 @@ async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None async with SessionLocal() as session: row = (await session.execute(select(DataSource).where(DataSource.name == fact.source))).scalar_one_or_none() if row is None: - return False, f"数据源「{fact.source}」已不存在,指标「{fact.metric}」的选择未写入数据目录" + return False, f"数据源「{fact.source}」已不存在,指标{_names(fact)}的选择未写入数据目录" return await _write(session, row, fact, note=note_of(fact, run_id=run_id, node_title=node_title), actor=actor) except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记 logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", fact.source) - return False, f"指标「{fact.metric}」的选择写入数据目录时出错,未写入" + return False, f"指标{_names(fact)}的选择写入数据目录时出错,未写入" + + +def _plan(notes: Mapping[str, Any], fact: Fact, tables: Mapping[str, Any] | None) -> tuple[Any, str | None]: + """按目录里这张表现在的说法,算出这一项要写成什么:(要写的取值, None) 或 (None, 为什么不写)。不读库、不写库: + 真写(_write)和审批卡上的预告(preview)用的是这同一套判断。""" + label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) + names = _names(fact) + stored = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None + if stored is not None and stored.get("status") == "rejected" and _same_fact(fact.item, stored.get("value"), + fact.value): + # 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意 + return None, (f"「{fact.table}」的{label}「{_value_text(fact.item, fact.value)}」曾被人工驳回," + f"与这次指标{names}的选择相反,未写入。如需更改,请到数据目录中修改") + live = stored if stored is not None and stored.get("status") != "rejected" else None + value = fact.value + if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \ + and live["value"].get("column") == value.get("column"): + # 业务日期按哪一列是这次选出来的事实;目录里同一列另写了规则、时区的,原样留着 + value = live["value"] + elif fact.item == "valid_filter" and isinstance(live, dict) and isinstance(live.get("value"), str) \ + and live["value"].strip() and not _same_fact(fact.item, live["value"], value): + combined, why = _combined_filter(live["value"].strip(), fact) + if combined is None: + return None, why + if live.get("status") == "confirmed" and not _same_fact(fact.item, combined, live["value"]): + adopted = f"被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" if not fact.also else "这次几个指标被采纳的写法" + return None, (f"「{fact.table}」的{label}已有人工确认过的说法「{live['value'].strip()}」,{adopted}" + f"另外多出条件「{fact.value}」,未改动。如需补上,请到数据目录中修改") + value = combined + elif fact.item == "dedup" and isinstance(live, dict) and live.get("status") != "confirmed" \ + and not _same_fact(fact.item, live.get("value"), value): + # 推断的去重规则说的未必是同一件事(按哪一列去重、哪种汇总要去重),替换掉就把它丢了 + return None, (f"「{fact.table}」的{label}已有推断的说法「{_value_text(fact.item, live.get('value'))}」," + f"与这次指标{names}采纳的「{value}」不同,无法确定两者的关系,未覆盖。" + "如需更改,请到数据目录中修改") + plan = catalog.plan_patch(notes, [{"path": fact.item, "value": value}], table=fact.table, tables=tables) + if plan.problems: + return None, f"「{fact.table}」的{label}未写入数据目录:{plan.problems[0]}" + change = plan.changes[0] + if change.state == "same": + return None, f"「{fact.table}」的{label}已是人工确认过的「{_value_text(fact.item, value)}」,无需重复写入" + if change.before_status == "confirmed": + return None, (f"「{fact.table}」的{label}已有人工确认过的说法「{_value_text(fact.item, change.before)}」," + f"与这次指标{names}采纳的「{_value_text(fact.item, value)}」不同,未覆盖。" + "如需更改,请到数据目录中修改") + return change.after, None + + +def preview(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, + notes: Mapping[str, Mapping[str, Any]] | None = None) -> dict[str, dict[str, Any]]: + """审批卡上的预告:审核人在这个指标上选原流程(a)、选盲算(b),按目前的数据目录各会往目录里写什么。 + + candidate 同 derive(choice 不用给);notes 是这个数据源的目录({表名: notes})。返回 {a: …, b: …},每边是 + {write: true, source, table, item, value}(value 是真要写进去的说法:和目录里已有的有效记录条件合起来的,是合起来 + 之后的整句)或 {write: false, reason}。和恢复后真写是同一套判断,只在模块说明里说的两处可能不一样。永远不抛。""" + notes = notes or {} + tables = schema_cache.get("tables") if isinstance(schema_cache, Mapping) else None + out: dict[str, dict[str, Any]] = {} + for side in SIDE_LABEL: + try: + fact, why = derive({**candidate, "choice": side}, schema_cache, notes) + value = None + if fact is not None: + value, why = _plan(notes.get(fact.table) or {}, fact, tables) + except Exception: # noqa: BLE001 - 预告算不出来不能拦住审批:照实说,以写入后的结果为准 + logger.exception("双盲复算预告数据目录的写入失败(指标 %s)", candidate.get("metric")) + fact, why = None, f"指标「{candidate.get('metric')}」的选择核对数据目录时出错,无法预告是否写入" + out[side] = {"write": True, "source": fact.source, "table": fact.table, "item": fact.item, "value": value} \ + if fact is not None and why is None else {"write": False, "reason": why} + return out async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: str | None) -> tuple[bool, str | None]: - """写一项。(写了没有, 没写的原因)。""" + """写一项。(写了没有, 没写的原因)。写什么、写不写由 _plan 按读到的目录定;版本对不上(别人刚改过)重读再来。""" label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) - side = SIDE_LABEL.get(fact.choice, fact.choice) tables = (row.schema_cache or {}).get("tables") if isinstance(row.schema_cache, dict) else None for _ in range(_RETRIES): entry = await catalog.read_entry(session, row.id, fact.table) notes = entry.notes if entry else {} version = entry.version if entry else 0 - stored = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None - if stored is not None and stored.get("status") == "rejected" and _same_fact(fact.item, stored.get("value"), - fact.value): - # 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意 - return False, (f"「{fact.table}」的{label}「{_value_text(fact.item, fact.value)}」曾被人工驳回," - f"与这次指标「{fact.metric}」的选择相反,未写入。如需更改,请到数据目录中修改") - live = stored if stored is not None and stored.get("status") != "rejected" else None - value = fact.value - if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \ - and live["value"].get("column") == value.get("column"): - # 业务日期按哪一列是这次选出来的事实;目录里同一列另写了规则、时区的,原样留着 - value = live["value"] - elif fact.item == "valid_filter" and isinstance(live, dict) and isinstance(live.get("value"), str) \ - and live["value"].strip() and not _same_fact(fact.item, live["value"], value): - combined, why = _combined_filter(live["value"].strip(), fact) - if combined is None: - return False, why - if live.get("status") == "confirmed" and not _same_fact(fact.item, combined, live["value"]): - return False, (f"「{fact.table}」的{label}已有人工确认过的说法「{live['value'].strip()}」,被采纳的" - f"{side}另外多一个条件「{fact.value}」,未改动。如需补上,请到数据目录中修改") - value = combined - elif fact.item == "dedup" and isinstance(live, dict) and live.get("status") != "confirmed" \ - and not _same_fact(fact.item, live.get("value"), value): - # 推断的去重规则说的未必是同一件事(按哪一列去重、哪种汇总要去重),替换掉就把它丢了 - return False, (f"「{fact.table}」的{label}已有推断的说法「{_value_text(fact.item, live.get('value'))}」," - f"与这次指标「{fact.metric}」采纳的「{value}」不同,无法确定两者的关系,未覆盖。" - "如需更改,请到数据目录中修改") - changes = [{"path": fact.item, "value": value, "reason": note}] - plan = catalog.plan_patch(notes, changes, table=fact.table, tables=tables) - if plan.problems: - return False, f"「{fact.table}」的{label}未写入数据目录:{plan.problems[0]}" - change = plan.changes[0] - if change.state == "same": - return False, (f"「{fact.table}」的{label}已是人工确认过的「{_value_text(fact.item, value)}」," - "无需重复写入") - if change.before_status == "confirmed": - return False, (f"「{fact.table}」的{label}已有人工确认过的说法「{_value_text(fact.item, change.before)}」," - f"与这次指标「{fact.metric}」采纳的「{_value_text(fact.item, value)}」不同,未覆盖。" - "如需更改,请到数据目录中修改") + value, why = _plan(notes, fact, tables) + if why is not None: + return False, why at = catalog.now_iso() try: - merged = catalog.apply_patch(notes, changes, table=fact.table, tables=tables, at=at) + merged = catalog.apply_patch(notes, [{"path": fact.item, "value": value, "reason": note}], + table=fact.table, tables=tables, at=at) except catalog.CatalogInvalid as e: return False, f"「{fact.table}」的{label}未写入数据目录:{e}" # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处 - merged[fact.item] = catalog.make_item(change.after, "human", "confirmed", note=note, at=at) + merged[fact.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at) try: await catalog.write_entry(session, row.id, fact.table, merged, if_version=version, actor=actor) except catalog.CatalogConflict: @@ -640,4 +732,4 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: return False, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认" -__all__ = ["FACT_ITEMS", "Fact", "derive", "note_of", "record_choices"] +__all__ = ["FACT_ITEMS", "Fact", "derive", "note_of", "preview", "record_choices"] diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 25e2a736..8d30c94c 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -14,11 +14,14 @@ 里看不出日期、口径的取舍时才给,好让它分得清哪一项是哪一项。 **一致就放行,不一致交给人。** 不一致时逐个对不上的输入说清两条 SQL 差在哪(data/sql_semantics.explain_sql_diff): -两边先收窄到这一格(只留算出这一列的那一项;分组的按这一行的分组值改成等值条件),一条查询算好几列、分组后取一行时 -才不会混进「选出的列」「分组」这类和这一格无关的差别。完整对照落成一件工件(审批 payload 经 runner._safe 会截断, +两边先收窄到这一格(只留算出这一列的那一项;分组的按这一行的分组值改成等值条件,HAVING 一并去掉),一条查询算好几列、 +分组后取一行时才不会混进「选出的列」「分组」这类和这一格无关的差别。分组的列设了遮罩时不收窄:这一行的分组值写进 +SQL,就跟着差异说明进了审批卡、对照工件和目录日志。完整对照落成一件工件(审批 payload 经 runner._safe 会截断, 长 SQL 放不下),用 replay.ask 挂起。审核人逐个指标选采用原流程(a)、采用盲算(b)或两边都不对(neither):a 没有 缺口;b、neither、没选的写进产出的 issuance_gaps,成果节点的出具契约据此降档(on_reject 为 fail 时节点直接失败)。 两边只差一个方面、又对得上数据目录事实的,选择写进数据目录(engine/dual_check_facts.py,指标自己的筛选范围不写)。 +选了会怎样要在卡上看得出:payload 在约定的几项之外带上实际生效的 on_reject,摘要里每个指标带 catalog——选 a、选 b +按发起审批时的目录各会往目录里写什么、不写的为什么(dual_check_facts.preview,和真写同一套判断)。 盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口,发 dual_check_unchecked。 **产出的状态。** 全部一致是 agreed;没有不一致、但有无法复算的:一个都没核成是 unchecked,核成了一部分是 partial; @@ -54,6 +57,7 @@ from app.db.models import DataSource from app.engine import recompute from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError +from app.engine.dual_check_facts import preview, record_choices from app.engine.evidence import build_catalog, is_number, next_exec from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cell_value, column_kind, numeric_text, \ parse_expression @@ -907,10 +911,14 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], """不一致的指标逐个说清差在哪,完整对照落成工件,组好审批的 payload。返回 {payload, facts, card_artifact}。 两条 SQL 先收窄到这一格再比(_narrowed):原流程一条查询算好几列、分组后取一行时,整条拿去比会混进「选出的列」 - 「分组」这类和这一格无关的差别,审批卡上只列前几条,审核人看到的多半是噪声,只差一个方面的也就写不进目录。 + 「分组」这类和这一格无关的差别,审批卡上只列前几条,审核人看到的多半是噪声,只差一个方面的也就写不进目录。两边 + 各按自己那份快照要遮的列收窄(原流程的、盲算的快照各记着查询当时的遮罩,再加数据源现在设的)。 facts 是每个不一致指标写数据目录要用的东西(dual_check_facts.derive 的候选,少一个 choice):只有一个输入对不上时是 那一处完整的差异条目(带两边比较的写法和涉及的表、列),几个输入都对不上时只说「不止一处」;scope 是这个指标自己的 - 范围(口径说明、所在分组、运行输入、分组的列),有效记录条件据此排除指标自己的筛选范围。 + 范围(口径说明、所在分组、运行输入、分组的列),有效记录条件据此排除指标自己的筛选范围。scope 只在节点里用、不进 + payload 和工件(所在分组的取值里可能有遮罩列的)。 + payload 在约定的几项之外带 on_reject(实际生效的处置),摘要里每个指标带 catalog(选 a、选 b 各会往数据目录里写 + 什么,dual_check_facts.preview):审核人在卡上要看得出没采用原流程会不会让运行失败、选了会不会记成人工确认的目录项。 """ rows = [r for r in checked["rows"] if r["status"] == DIFFERENT] results = checked["results"] @@ -938,9 +946,11 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], if inp["key"] not in row["differing"]: continue blind_snap, _ = sources.snapshot(str(blind["artifact"])) if blind.get("artifact") else (None, None) - a_sql = _narrowed(inp["sql"], inp["column"], _record(original_snap, inp["row"]), kind) or inp["sql"] - b_sql = _narrowed(blind.get("sql"), blind.get("column"), _record(blind_snap, blind.get("row")), kind) \ - or blind.get("sql") + blind_hidden = await sources.hidden(str(blind["artifact"])) if blind.get("artifact") else frozenset() + a_sql = _narrowed(inp["sql"], inp["column"], _record(original_snap, inp["row"]), kind, + await sources.hidden(inp["artifact"])) or inp["sql"] + b_sql = _narrowed(blind.get("sql"), blind.get("column"), _record(blind_snap, blind.get("row")), kind, + blind_hidden) or blind.get("sql") diff = await _explain(a_sql, b_sql, infos[inp["source"]]) per_input.append((inp, blind, diff)) compared.append((a_sql, str(b_sql or ""))) @@ -960,22 +970,24 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], "items": [{"aspect": i.get("aspect"), "text": i.get("text")} for i in merged["items"]], "fallback": merged["fallback"], "text_diff": merged["text_diff"]}, }) - summary.append({ - "metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], - "original_rendered": row["original"]["rendered"], "blind_rendered": row["blind"]["rendered"], - "delta_pct": row["delta_pct"], "diff": [str(i.get("text")) for i in merged["items"][:SUMMARY_DIFFS]], - "fallback": not any(i.get("aspect") not in ("other", "parse") for i in merged["items"]), - }) first = per_input[0][0] if per_input else (row["inputs"][0] if row["inputs"] else {}) single = compared[0] if len(per_input) == 1 else None + info = infos.get(str(first.get("source"))) or {} facts[row["metric_id"]] = { "metric_id": row["metric_id"], "metric": row["name"], "source": first.get("source"), - "dialect": (infos.get(str(first.get("source"))) or {}).get("kind"), + "dialect": info.get("kind"), "sql": {"a": single[0], "b": single[1]} if single else {}, "diff": {"equivalent": merged["equivalent"], "fallback": merged["fallback"] or single is None, "items": merged["items"]}, "scope": {"texts": [t for t in dict.fromkeys(scope_texts) if t], "columns": sorted(scope_columns)}, } + summary.append({ + "metric_id": row["metric_id"], "name": row["name"], "unit": row["unit"], + "original_rendered": row["original"]["rendered"], "blind_rendered": row["blind"]["rendered"], + "delta_pct": row["delta_pct"], "diff": [str(i.get("text")) for i in merged["items"][:SUMMARY_DIFFS]], + "fallback": not any(i.get("aspect") not in ("other", "parse") for i in merged["items"]), + "catalog": preview(facts[row["metric_id"]], info.get("schema"), info.get("notes")), + }) content = {"metrics": metrics, "model": checked.get("model"), "same_model": bool(checked.get("same_model"))} try: artifact = await artifact_store.put_json(content, kind="dual_check", run_id=ctx.run.run_id, @@ -985,7 +997,7 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], payload = { "kind": "dual_check", "node_id": ctx.node.id, "mode": "input", "title": f"双盲复算:{len(rows)} 个指标两边结果不一致,请选择采用哪一边的结果", - "card_artifact": artifact, "summary": summary, + "card_artifact": artifact, "summary": summary, "on_reject": options.on_reject, } names = "、".join(f"「{r['name']}」" for r in rows[:5]) + (f"等 {len(rows)} 个" if len(rows) > 5 else "") ctx.emit(EventType.LOG, level="warn", code="dual_check_disagreed", @@ -995,17 +1007,18 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], async def _source_info(sources: _Sources, name: str) -> dict[str, Any]: - """比较两条 SQL 要用的:方言、表结构、列的中文名(数据目录里的)。数据源没了就都不给,照样比原文。""" + """比较两条 SQL、预告目录写入要用的:方言、表结构、列的中文名和整份数据目录。数据源没了就都不给,照样比原文。""" from app.data import catalog from app.data.sql_semantics import catalog_labels row = await sources.source(name) if row is None: - return {"kind": None, "schema": None, "names": {}} + return {"kind": None, "schema": None, "names": {}, "notes": {}} async with SessionLocal() as session: entries = await catalog.read_catalog(session, row.id) + notes = {table: entry.notes for table, entry in entries.items()} return {"kind": row.kind, "schema": row.schema_cache if isinstance(row.schema_cache, dict) else None, - "names": catalog_labels({table: entry.notes for table, entry in entries.items()})} + "names": catalog_labels(notes), "notes": notes} async def _explain(original: Any, blind: Any, info: dict[str, Any]) -> dict[str, Any]: @@ -1088,12 +1101,16 @@ def _sql_literal(value: Any) -> Any: return None -def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str | None) -> str | None: +def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str | None, + hidden: frozenset[str] = frozenset()) -> str | None: """把一条查询收窄到这一格:最外层 SELECT 只留算出这一列的那一项;分组的去掉分组,按这一行的分组值补上等值条件 - (华东那一行 → WHERE … AND region = '华东'),排序、取行数跟着去掉。 + (华东那一行 → WHERE … AND region = '华东'),HAVING、排序、取行数跟着去掉——这一行既然在结果里,它所在的组就 + 满足 HAVING,留着只会多出一条和这一格无关的差异。 差异说明拿收窄后的两条比,才只说这一格是怎么算出来的。认不准的(SELECT *、DISTINCT、窗口函数、集合运算、分组键 - 对不上结果列、这一行没有分组值)返回 None,调用方照整条比。""" + 对不上结果列、这一行没有分组值)返回 None,调用方照整条比。hidden 是这份快照要遮的列(小写):分组键的结果列、 + 或者它用到的列在里面时也返回 None——分组值写进 SQL 就跟着差异说明进了审批卡、对照工件和目录日志,遮罩列的值 + 不能出现在那些地方。""" import sqlglot from sqlglot import exp @@ -1128,6 +1145,8 @@ def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str if name is None or name not in values: return None expr = projections[index].this if isinstance(projections[index], exp.Alias) else projections[index] + if name in hidden or any(col.name.lower() in hidden for col in expr.find_all(exp.Column)): + return None if values[name] is None: conditions.append(exp.Is(this=expr.copy(), expression=exp.Null())) continue @@ -1135,7 +1154,7 @@ def _narrowed(sql: Any, column: Any, record: Mapping[str, Any] | None, kind: str if literal is None: return None conditions.append(exp.EQ(this=expr.copy(), expression=literal)) - for arg in ("group", "order", "limit", "offset"): + for arg in ("group", "having", "order", "limit", "offset"): tree.set(arg, None) tree = tree.where(*conditions, copy=False) elif tree.args.get("limit") is None and tree.args.get("offset") is None: @@ -1233,7 +1252,6 @@ def _resolved_text(rows: list[dict[str, Any]], choices: dict[str, str]) -> str: async def _catalog(ctx: NodeContext, candidates: list[dict[str, Any]]) -> dict[str, Any]: from app.data.catalog import TABLE_FIELD_LABEL - from app.engine.dual_check_facts import record_choices result = await record_choices(candidates, run_id=ctx.run.run_id, node_title=ctx.node.title, actor=ctx.actor()) written, skipped = result["written"], result["skipped"] diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 9f263441..d75bafe6 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -7,7 +7,10 @@ - 指标自己的筛选范围(只看华东、只看线上)不是有效记录条件:取值出现在指标名称、口径说明、所在分组、运行输入里的, 条件用到分组的列的,不是在排除退款、作废、测试这类无效记录的,都不写 - 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖; - 目录里已有的有效记录条件被采纳的一边也用着的,合起来写,没用着的不动;同一次裁决里互相矛盾的选择都不写 + 目录里已有的有效记录条件被采纳的一边也用着的,合起来写,没用着的不动 +- 同一次裁决里几个指标落到同一项上:说法一样的写一次;有效记录条件(都在排除无效记录)用 AND 合成一句写;业务日期 + 说法不同的都不写 +- 审批卡上的预告(preview):选 a、选 b 各会写什么、不写的为什么,按目录里已有的说法算,和真写同一套判断 差异条目一律由 sql_semantics.explain_sql_diff 真算出来,和节点交给审核人看的是同一份。表名、数字都是虚构的。 """ @@ -18,7 +21,7 @@ import pytest from app.data import catalog from app.data.sql_semantics import explain_sql_diff from app.db.base import SessionLocal -from app.engine.dual_check_facts import derive, record_choices +from app.engine.dual_check_facts import derive, preview, record_choices from tests.fixtures.dual_check_shop import ( BOTH_WRONG, BUYERS, @@ -237,6 +240,13 @@ async def seed(source_id: str, notes: dict) -> None: await catalog.write_entry(session, source_id, "orders", notes, if_version=0, actor="数据组") +async def seed_again(source_id: str, notes: dict) -> None: + """把目录这张表整份改回 notes(在现有版本上写)。""" + async with SessionLocal() as session: + entry = await catalog.read_entry(session, source_id, "orders") + await catalog.write_entry(session, source_id, "orders", notes, if_version=entry.version, actor="数据组") + + async def test_a_choice_is_written_as_a_confirmed_human_item_with_its_origin(shop): name, source_id = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source=name)], run_id="run-42", @@ -359,7 +369,8 @@ async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_do async def test_contradictory_choices_in_one_decision_write_nothing(shop): - """同一次裁决里,一个指标采纳了按下单日、另一个采纳了按支付日:写哪个都是替人拿主意,都不写。""" + """同一次裁决里,一个指标采纳了按下单日、另一个采纳了按支付日:一张表只有一个业务日期,写哪个都是替人拿主意, + 都不写。""" name, source_id = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "a", metric="销售额", source=name), candidate(BY_ORDER_DATE, RIGHT, "b", metric="复核销售额", source=name)], @@ -367,10 +378,94 @@ async def test_contradictory_choices_in_one_decision_write_nothing(shop): assert out["written"] == [] [skipped] = out["skipped"] assert "「销售额」" in skipped["reason"] and "「复核销售额」" in skipped["reason"] - assert "order_date" in skipped["reason"] and "paid_date" in skipped["reason"] and "互相矛盾" in skipped["reason"] + assert "order_date" in skipped["reason"] and "paid_date" in skipped["reason"] + assert "给出了不同的说法" in skipped["reason"] and "无法确定哪一个是数据事实" in skipped["reason"] assert await entry_of(source_id) is None +async def test_complementary_valid_filters_in_one_decision_are_combined(shop): + """同一次裁决里,一个指标采纳了排除退款、另一个采纳了排除测试单:两条都在排除无效记录,各有人的选择撑着, + 不互相矛盾,用 AND 合成一句写。""" + name, source_id = shop + out = await record_choices([ + candidate(SUM_WEEK, extra("status <> 'refunded'"), "b", metric="销售额", source=name), + candidate(SUM_WEEK, extra("is_test = 0"), "b", metric="订单金额", source=name), + # 第三个指标采纳的是和第一个一样的条件:合并时不重复 + candidate(COUNT_WEEK, extra("status <> 'refunded'", COUNT_WEEK), "b", metric="订单数", source=name), + ], run_id="run-1", node_title="复核", actor=None) + assert out == {"written": [{"source": name, "table": "orders", "item": "valid_filter"}], "skipped": []} + entry = await entry_of(source_id) + item = entry.notes["valid_filter"] + assert item["value"] == "status <> 'refunded' AND is_test = 0" and entry.version == 1 + assert (item["source"], item["status"]) == ("human", "confirmed") + assert all(f"「{m}」" in item["note"] for m in ("销售额", "订单金额", "订单数")) and "run-1" in item["note"] + + +async def test_combined_filters_extend_an_inferred_condition_every_adopted_side_uses(shop): + """目录里推断的「channel <> 'test'」:几个被采纳的一边都按它筛了,合起来写;有一个没按它筛的,关系拿不准,不动。""" + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("channel <> 'test'", "llm")}) + base = extra("channel <> 'test'") + both = [candidate(base, extra("status <> 'refunded'", base), "b", metric="销售额", source=name), + candidate(base, extra("is_test = 0", base), "b", metric="订单金额", source=name)] + out = await record_choices(both, run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}], out + assert (await entry_of(source_id)).notes["valid_filter"]["value"] == \ + "channel <> 'test' AND status <> 'refunded' AND is_test = 0" + + await seed_again(source_id, {"valid_filter": catalog.make_item("channel <> 'test'", "llm")}) + one_without = [both[0], candidate(SUM_WEEK, extra("is_test = 0"), "b", metric="订单金额", source=name)] + out = await record_choices(one_without, run_id="run-2", node_title="复核", actor=None) + assert out["written"] == [] + reason = out["skipped"][0]["reason"] + assert "「channel <> 'test'」" in reason and "指标「订单金额」被采纳的盲算没有按它筛选" in reason and "未改动" in reason + assert (await entry_of(source_id)).notes["valid_filter"]["value"] == "channel <> 'test'" + + +async def test_the_same_choice_from_two_metrics_is_written_once(shop): + name, source_id = shop + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", metric="销售额", source=name), + candidate(BY_ORDER_DATE, RIGHT, "b", metric="订单金额", source=name)], + run_id="run-1", node_title="复核", actor=None) + assert out == {"written": [{"source": name, "table": "orders", "item": "business_date"}], "skipped": []} + entry = await entry_of(source_id) + assert entry.version == 1 and "「销售额」" in entry.notes["business_date"]["note"] + assert "「订单金额」" in entry.notes["business_date"]["note"] + + +# -------------------------------------------------------------------------- +# 审批卡上的预告:选哪一边各会写什么(和真写同一套判断) +# -------------------------------------------------------------------------- + + +def test_the_preview_says_what_each_choice_would_write(): + picked = candidate(BY_ORDER_DATE, RIGHT, "a") + assert preview(picked, SCHEMA, {}) == { + "a": {"write": True, "source": "shop", "table": "orders", "item": "business_date", + "value": {"column": "order_date"}}, + "b": {"write": True, "source": "shop", "table": "orders", "item": "business_date", + "value": {"column": "paid_date"}}, + } + # 只有被采纳的一边有条件时才写:选另一边照实说为什么不写 + said = preview(candidate(WITH_REFUNDS, RIGHT, "a"), SCHEMA, {}) + assert said["a"]["write"] is False and "被采纳的原流程没有这个条件" in said["a"]["reason"] + assert said["b"] == {"write": True, "source": "shop", "table": "orders", "item": "valid_filter", + "value": "status <> 'refunded'"} + + +def test_the_preview_reads_the_catalog_as_it_is(): + """目录里已有的说法照写入时的规矩算:确认过的不覆盖、同样的说法不重写、推断的有效记录条件合起来写。""" + confirmed = {"orders": {"business_date": catalog.make_item({"column": "order_date"}, "human", "confirmed")}} + said = preview(candidate(BY_ORDER_DATE, RIGHT, "a"), SCHEMA, confirmed) + assert said["a"]["write"] is False and "无需重复写入" in said["a"]["reason"] + assert said["b"]["write"] is False and "已有人工确认过的说法「order_date」" in said["b"]["reason"] + + inferred = {"orders": {"valid_filter": catalog.make_item("is_test = 0", "llm")}} + base = f"SELECT SUM(amount) AS gmv FROM orders WHERE is_test = 0 AND paid_date {WEEK}" + said = preview(candidate(base, extra("status <> 'refunded'", base), "a"), SCHEMA, inferred) + assert said["b"]["write"] is True and said["b"]["value"] == "is_test = 0 AND status <> 'refunded'" + + async def test_a_missing_source_or_an_unrecordable_choice_is_reported(shop): name, _ = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", source="no_such_source"), diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index c68837cf..5ed4c487 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -4,13 +4,15 @@ - 一致:不挂起,产出 agreed,日志 dual_check_agreed;盲算的查询发 tool.end(role: verify),台账只有一条 dual_check, 用量计入运行;盲算 agent 的任务里没有原 SQL,也没有原流程结果的列名 - 一个都没核成是 unchecked、只核成一部分是 partial,日志 dual_check_unchecked 说清哪几个、为什么 -- 不一致:挂起,审批 payload 和对照工件按约定;a / b / neither 三种选择的产出和缺口,没选的照实记,驳回、on_reject - 为 fail 时失败 -- 差异说明只比这一格所在的那一列(一条查询算好几列、分组后取一行的,收窄了再比) +- 不一致:挂起,审批 payload 和对照工件按约定,payload 另带实际生效的 on_reject、摘要里每个指标带选 a、选 b 各会往 + 数据目录里写什么的预告;a / b / neither 三种选择的产出和缺口,没选的照实记,驳回、on_reject 为 fail 时失败 +- 差异说明只比这一格所在的那一列(一条查询算好几列、分组后取一行的,收窄了再比,HAVING 一并去掉);分组的列设了 + 遮罩时不收窄,遮罩列的取值不进审批卡、对照工件、产出和事件 - 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖,指标自己的 - 筛选范围不写,两条 SQL 规范化后一致的不写 + 筛选范围(取值在运行输入里、用到分组的列)不写,两条 SQL 规范化后一致的不写 - 全局关闭直通、节点覆盖;设置按发起时的快照;重放时盲算不再执行、事件不重复;报告的写作目录不含盲算的查询 -- 触顶、分组结果的维度(只给取值、遮罩列不给)、两个指标用到同一格只盲算一次、遮罩列上的盲算结果不采用 +- 触顶、分组结果的维度(只给取值、遮罩列不给)、带着取舍的列名不交给盲算、两个指标用到同一格只盲算一次、遮罩列上的 + 盲算结果不采用 """ from __future__ import annotations @@ -234,6 +236,23 @@ async def test_the_blind_agent_is_not_told_which_date_column_the_original_groupe 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", @@ -352,16 +371,25 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, approval = await pending(run.id) assert approval.mode == "input" and approval.node_id == "dual" payload = approval.payload - assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary"} + # 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败 + assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject"} 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"} + "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 @@ -470,6 +498,8 @@ 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) @@ -546,6 +576,10 @@ async def test_a_confirmed_catalog_entry_is_not_overwritten(monkeypatch, shop, c 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"] done = await decide(client, run.id, {"gmv": "b"}) [wrote] = logs(await events(done.id), "dual_check_catalog") assert wrote["written"] == [] @@ -564,6 +598,9 @@ async def test_a_metric_scope_filter_is_not_written_as_a_valid_filter(monkeypatc 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") @@ -631,6 +668,61 @@ async def test_a_grouped_query_is_compared_on_the_row_it_was_taken_from(monkeypa 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 @@ -733,6 +825,50 @@ async def test_masked_group_columns_are_not_shown_to_the_blind_agent(monkeypatch 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 = '华东'」就进了审批卡、对照工件、事件和目录日志。遮罩的分组列上不收窄,照整条比。""" + name, _ = masked_shop + first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} + Blind(monkeypatch, name, {"首组销售额": (RIGHT, "gmv")}) + run = await start(graph(name, BY_REGION, metrics=[first], dual={"definitions": {}})) + assert run.status == "interrupted", run.error + payload = (await pending(run.id)).payload + 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) + # 照整条比:原流程分组、盲算没分组都列出来,不止差一个方面,选了也不写目录 + assert len(payload["summary"][0]["diff"]) > 1 + [wrote] = logs(evs, "dual_check_catalog") + assert wrote["written"] == [] and "不止差一个方面" in wrote["skipped"][0]["reason"] + + +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)) + + +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 + + # -------------------------------------------------------------------------- # 设置:全局开关、节点覆盖、发起时的快照 # -------------------------------------------------------------------------- From 233afd16ee2bc8a7ef9847fb1e775fe0d85995ba Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 17:23:17 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E6=95=B4=E6=94=B9=EF=BC=88=E7=AC=AC=203=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=89=EF=BC=9A=E9=A9=B3=E5=9B=9E=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E6=9D=A1=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=80=9F=E7=9D=80=E5=90=8C=E4=B8=80=E6=AC=A1=E8=A3=81?= =?UTF-8?q?=E5=86=B3=E9=87=8C=E7=9A=84=E5=90=88=E5=B9=B6=E5=86=99=E5=9B=9E?= =?UTF-8?q?=EF=BC=8C=E5=90=88=E8=B5=B7=E6=9D=A5=E5=86=99=E6=97=B6=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E6=AE=B5=E5=8F=AA=E5=86=99=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E6=94=B9=E5=90=8D=E5=90=8E=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=9A=84=E9=81=AE=E7=BD=A9=E5=88=97=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一次裁决里几个指标落到有效记录条件上、要合成一句时,驳回检查原来只拿合起来的整句和驳回的说法比:「status <> 'refunded'」和「is_test = 0」AND 在一起就不再等于驳回的那句,被写成人工确认,和审批卡上「不写」的预告正相反。现在合并前按每个指标自己的说法过一遍驳回检查(_screen):带回驳回说法的指标拿出来不写,原因和它的预告是同一句话,其余的照写,备注只列真写进去的指标;合起来以后又包含了驳回的整句的也不写。有效记录条件的驳回检查改按段比,!= 和 <>、列名大小写、先后不同都认得出。 - 合起来的条件再和目录里已有的条件合写时,原来拿并好的整句去接,和已有条件部分重叠的整句原样接上,写出「status <> 'refunded' AND status <> 'refunded' AND is_test = 0」。现在接的是每个指标各自的条件,_and_join 只接还没出现过的段;已有条件是人工确认过的,原因里只说另外多出的那几段。最外层是 OR 的条件只在接了别的条件时加括号。 - Fact.also(其余几个指标)改成 members(并进来的每一个,各带自己的说法):并好的整句不再顶掉第一个指标自己的说法。 - 补用例:合并时驳回的条件拿出来、其余照写(facts 级和图级,真写和预告一字不差);驳回的整句由几个指标拼回来时不写;驳回时写法不同也认得出;合写不重复、已确认时只说多出的段;_and_join 的段级去重和括号;遮罩列改了名再分组(region AS area)时不收窄。 --- backend/app/engine/dual_check_facts.py | 226 +++++++++++++++++-------- backend/tests/test_dual_check_facts.py | 109 +++++++++++- backend/tests/test_dual_check_node.py | 56 +++++- 3 files changed, 319 insertions(+), 72 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index ecf2b1eb..6bde1842 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -23,17 +23,23 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不 **怎么写。** 照对话提案的规矩核对(plan_patch:列在不在表结构里、取值格式),记为人工填写、已确认,备注写明来自哪次 运行、哪个节点、哪个指标。目录里这一项已经是人工确认过的另一种说法时不覆盖,照实交回原因;已经是同样的说法就什么都 -不写(再写一遍也不升版本)。有效记录条件是一整句条件:目录里已有的条件被采纳的一边(几个指标并成一项时是每一个) -也按它筛了的,和新条件合起来写(已确认的不改,照实说);有一个没按它筛的,两者的关系拿不准,不动。推断的去重规则 -说的未必是同一件事,也不替换。整个写入由节点放在 ask() 之后的一个 @task 里:审批恢复重放、接着运行时不会再写一遍。 +不写(再写一遍也不升版本);这一项被人工驳回过、要写的又带回了驳回的说法(有效记录条件按段比:驳回的那句每一段都在 +要写的条件里),两次人工判断对不上,不写。有效记录条件是一整句条件:目录里已有的条件被采纳的一边(几个指标并成一项 +时是每一个)也按它筛了的,和新条件合起来写,已有的段只写一次(已确认的不改,照实说多出了哪几段);有一个没按它筛 +的,两者的关系拿不准,不动。推断的去重规则说的未必是同一件事,也不替换。整个写入由节点放在 ask() 之后的一个 @task +里:审批恢复重放、接着运行时不会再写一遍。 **同一次裁决里几个指标落到同一项上。** 说法一样的写一次,备注里列出每个指标。有效记录条件是能用 AND 叠加的排除条件 (这里只收排除无效记录的),几个指标各自采纳的条件都有人的选择撑着,合成一句写——「排除退款」和「排除测试单」并不 -矛盾。业务日期一张表只有一个,去重规则说的未必是同一件事,说法不同就一个都不写。 +矛盾。业务日期一张表只有一个,去重规则说的未必是同一件事,说法不同就一个都不写。合起来之前先按每个指标自己的说法 +过一遍驳回检查:自己采纳的说法曾被人工驳回的指标拿出来不写、照实说原因,其余的照写;合起来以后又包含了驳回的整句的, +也不写。 **预告。** 审批卡上要让审核人看得出选了之后会往目录里写什么(preview):按发起审批时的目录,选原流程、选盲算各会 -写什么、不写的为什么。和真写是同一套判断(derive 认事实、_plan 对目录),所以只在两处可能和最后写的不一样:审批停下 -期间目录被人改了,或者同一次裁决里几个指标落到同一项上(上一段)。 +写什么、不写的为什么。和真写是同一套判断(derive 认事实、_plan 对目录、_revives 认驳回),所以只在两处可能和最后写 +的不一样:审批停下期间目录被人改了;同一次裁决里几个指标落到同一项上(上一段)——写进去的是合起来的说法,预告说会 +写的也可能因为说法互相矛盾、和已有条件的关系拿不准、合起来又成了驳回过的说法而不写。反过来不会:预告说不写的, +不会因为和别的指标并成一项被写进去。 catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 """ @@ -41,7 +47,7 @@ from __future__ import annotations import logging import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from typing import Any @@ -86,8 +92,9 @@ _SCOPE_DIGITS = 2 @dataclass(frozen=True) class Fact: """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明;sql、dialect 是被采纳 - 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。also 是同一次裁决里落到同一项上、 - 并进这一项的其余几个指标(各自的指标、选择、写法),value 是合起来之后的说法。""" + 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。同一次裁决里几个指标落到同一项上 + 时并成一项:members 是并进来的每个指标各自的那一项(各带自己的说法,审批卡上各自的预告就是按它算的),value 是 + 合起来之后的说法;单个指标的 members 为空。""" source: str table: str @@ -97,17 +104,17 @@ class Fact: choice: str sql: str = "" dialect: str | None = None - also: tuple[Fact, ...] = () + members: tuple[Fact, ...] = () def _members(fact: Fact) -> tuple[Fact, ...]: - """这一项背后的每个指标的选择:并过的连同并进来的。""" - return (fact, *fact.also) + """这一项背后的每个指标的选择:并成一项的是并进来的每一个(各带自己的说法),单个指标的是它自己。""" + return fact.members or (fact,) -def _names(fact: Fact) -> str: - """说明里的指标叫法:「销售额」,几个指标并成一项时「销售额」、「订单金额」。""" - names = list(dict.fromkeys(m.metric for m in _members(fact))) +def _names(members: Sequence[Fact]) -> str: + """说明里的指标叫法:「销售额」,几个指标时「销售额」、「订单金额」。""" + names = list(dict.fromkeys(m.metric for m in members)) return "、".join(f"「{n}」" for n in names[:4]) + (f"等 {len(names)} 个" if len(names) > 4 else "") @@ -422,39 +429,57 @@ def _conditions_of(sql: str, read: str | None) -> set[str] | None: return {_normalized(part, read) for where in tree.find_all(exp.Where) for part in _and_parts(where.this)} -def _and_join(base: str | None, values: list[str], dialect: str | None) -> str | None: - """几句条件用 AND 接成一句:已经有的(base 里的、前面接过的,按 _normalized 比)不再接;最外层是 OR 的加括号, - 不然后面接的条件只并到 OR 的后一半上。每句保留原来的写法。有一句解析不了返回 None。""" +def _keys(condition: Any, read: str | None) -> set[str] | None: + """一句条件按 AND 拆开后每一段的比较用写法;不是文字、解析不了返回 None。""" + tree = _parse(condition, read) if isinstance(condition, str) and condition.strip() else None + return {_normalized(part, read) for part in _and_parts(tree)} if tree is not None else None + + +def _and_join(base: str | None, values: list[str], dialect: str | None, *, keep_base: bool = True) -> str | None: + """几句条件用 AND 接成一句:已经有的段(base 里的、前面接过的,按 _normalized 比)不再接。一句里每一段都是新的, + 原样接上(保留原来的写法);只有几段是新的,只接那几段(按方言重新写)——整句接上会把已有的段再写一遍。 + keep_base 为假时 base 只用来认哪些段已经有了、不写进结果(说「另外多出哪些条件」用),什么都没多出时为空串。 + 不止一段时,最外层是 OR 的加括号,不然后面接的条件只并到 OR 的后一半上。有一句解析不了返回 None。""" from sqlglot import exp read = _read(dialect) - texts: list[str] = [] + pieces: list[tuple[str, bool]] = [] # (写法, 最外层是不是 OR) seen: set[str] = set() - for text in [*([base] if base else []), *values]: + for i, text in enumerate([*([base] if base else []), *values]): tree = _parse(str(text), read) if tree is None: return None - keys = {_normalized(p, read) for p in _and_parts(tree)} - if keys <= seen: + parts = _and_parts(tree) + fresh = [] + for part in parts: + key = _normalized(part, read) + if key not in seen: + seen.add(key) + fresh.append(part) + if not fresh or (base and i == 0 and not keep_base): continue - seen |= keys - texts.append(f"({text})" if isinstance(tree, exp.Or) else str(text)) - return " AND ".join(texts) + if len(fresh) == len(parts): + pieces.append((str(text).strip(), isinstance(tree, exp.Or))) + else: + pieces += [(part.sql(dialect=read), isinstance(part, exp.Or)) for part in fresh] + if len(pieces) == 1: + return pieces[0][0] + return " AND ".join(f"({text})" if is_or else text for text, is_or in pieces) def _combined_filter(existing: str, fact: Fact) -> tuple[str | None, str | None]: """目录里已有的有效记录条件和这次的新条件合起来:(合起来的整句条件, None) 或 (None, 为什么不动)。 有效记录条件是一整句(copilot 提案也要求「原有条件也要带上」)。只写新条件会把两边都按着筛的原有条件静默替换掉; - 原有条件有一个被采纳的一边没按它筛,审核人的选择说明不了它对不对(合起来写就等于替人确认了它),也不能替人删掉。""" + 原有条件有一个被采纳的一边没按它筛,审核人的选择说明不了它对不对(合起来写就等于替人确认了它),也不能替人删掉。 + 接的是每个指标各自的条件(不是并好的整句):哪个指标的条件和已有的重了,那一段都只写一次。""" label = catalog.TABLE_FIELD_LABEL["valid_filter"] read = _read(fact.dialect) - have_tree = _parse(existing, read) + have = _keys(existing, read) combined = _and_join(existing, [str(m.value) for m in _members(fact)], fact.dialect) \ - if have_tree is not None else None - if combined is None: + if have is not None else None + if have is None or combined is None: return None, f"「{fact.table}」已有的{label}「{existing}」无法解析,与这次的条件合不起来,未改动" - have = {_normalized(p, read) for p in _and_parts(have_tree)} for member in _members(fact): if not have <= (_conditions_of(member.sql, read) or set()): return None, (f"「{fact.table}」已有的{label}「{existing}」,指标「{member.metric}」被采纳的" @@ -560,28 +585,82 @@ def _differing(facts: list[Fact]) -> str: def _merged(members: list[Fact]) -> tuple[Fact | None, str | None]: - """同一次裁决里落到同一项上的几个说法合成一项:(合起来的那一项, None) 或 (None, 为什么都不写)。 + """同一次裁决里落到同一项上的几个说法合成一项:(合起来的那一项, None) 或 (None, 为什么都不写)。只有一个时就是它。 说法都一样的写一次。有效记录条件用 AND 合成一句(derive 只收排除无效记录的条件,几句叠起来不会互相打架)。 - 业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就都不写。""" - first, rest = members[0], tuple(members[1:]) - if not rest: + 业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就都不写。每个指标各自的那一项原样留在 members 里: + 驳回检查(_screen)、和目录里已有条件的合并(_combined_filter)都要按各自的说法算。""" + first = members[0] + if len(members) == 1: return first, None if first.item == "valid_filter": value = _and_join(None, [str(m.value) for m in members], first.dialect) - return (replace(first, value=value, also=rest), None) if value is not None else (None, _differing(members)) - if all(_same_fact(first.item, m.value, first.value) for m in rest): - return replace(first, also=rest), None + return (replace(first, value=value, members=tuple(members)), None) if value is not None \ + else (None, _differing(members)) + if all(_same_fact(first.item, m.value, first.value) for m in members[1:]): + return replace(first, members=tuple(members)), None return None, _differing(members) +def _rejected(notes: Mapping[str, Any], item: str) -> tuple[bool, Any]: + """目录里这一项是不是被人工驳回的:(是不是, 驳回的说法)。""" + stored = notes.get(item) + if isinstance(stored, Mapping) and stored.get("status") == "rejected": + return True, stored.get("value") + return False, None + + +def _revives(fact: Fact, rejected: Any) -> bool: + """要写的说法是不是又带回了曾被人工驳回的说法。业务日期、去重规则:同一个说法。有效记录条件:驳回的那句的每一段 + 都在要写的条件里(按 _normalized 比,!= 和 <>、列名大小写、先后不同都认得出;有一边解析不了按原文比)——驳回的 + 那句只是要写的一部分也算,写进去就等于替人把驳回过的条件又确认了一遍。""" + if fact.item != "valid_filter": + return _same_fact(fact.item, rejected, fact.value) + read = _read(fact.dialect) + mine, theirs = _keys(fact.value, read), _keys(rejected, read) + if mine is None or theirs is None: + return _same_fact(fact.item, rejected, fact.value) + return bool(theirs) and theirs <= mine + + +def _rejected_text(fact: Fact, rejected: Any, members: Sequence[Fact]) -> str: + """驳回检查拦下时的说法:驳回的是哪一句(照目录里记的原文),哪几个指标的选择又带回了它。审批卡上的预告和真写 + 拦下时用的是同一句话。""" + label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) + return (f"「{fact.table}」的{label}「{_value_text(fact.item, rejected)}」曾被人工驳回,与这次指标{_names(members)}" + "的选择相反,未写入。如需更改,请到数据目录中修改") + + +def _screen(notes: Mapping[str, Any], fact: Fact) -> tuple[Fact | None, list[str]]: + """几个指标并成的一项,先拿掉自己采纳的说法曾被人工驳回的指标:(剩下的重新并成的一项, 拿掉的为什么不写),一个 + 都不剩时前者为 None。单个指标的、这一项没被驳回过的原样返回,由 _plan 照常判断。 + + 审批卡上每个指标的预告是按它自己的说法单独算的(preview → _plan),在那里被驳回检查拦下、卡上写着「不写」的, + 并进来以后也得拦下。只拿合起来的整句去比,「status <> 'refunded'」和别的条件 AND 在一起就不再等于驳回的那句, + 会被写成人工确认,和卡上说的正相反。拿掉的指标照实说原因(和它的预告同一句话),其余的照写,也和各自的预告一致。""" + rejected, said = _rejected(notes, fact.item) + if not rejected or not fact.members: + return fact, [] + out: list[Fact] = [] + kept: list[Fact] = [] + for member in fact.members: + (out if _revives(member, said) else kept).append(member) + if not out: + return fact, [] + reasons = [_rejected_text(fact, said, out)] + if not kept: + return None, reasons + narrowed, why = _merged(kept) + return narrowed, reasons if why is None else [*reasons, why] + + async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str, actor: str | None) -> dict[str, list[dict[str, Any]]]: """把审核人的选择写进数据目录:{written: [{source, table, item}], skipped: [{reason}]}。永远不抛:数据源没了、 目录写不进去,都落在 skipped 里照实说。 先把每个候选认成目录项,再按(数据源、表、项)归拢成一项(_merged:说法一样的、有效记录条件合起来写,说法 - 不同的整组不写),逐项写。""" + 不同的整组不写),逐项写(_write:按读到的目录先拿掉各自带回驳回说法的指标,其余的照写)。""" skipped: list[dict[str, Any]] = [] facts: list[Fact] = [] for candidate in candidates: @@ -611,39 +690,39 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no if fact is None: skipped.append({"reason": why}) continue - done, why = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) + done, reasons = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) if done: written.append({"source": fact.source, "table": fact.table, "item": fact.item}) - else: - skipped.append({"reason": why}) + skipped += [{"reason": r} for r in reasons] return {"written": written, "skipped": skipped} -async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[bool, str | None]: +async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[bool, list[str]]: + """写一项:(写了没有, 没写的原因)。几个指标并成的一项可能写了、同时有几个指标被拿出来没写,原因照样交回。""" try: async with SessionLocal() as session: row = (await session.execute(select(DataSource).where(DataSource.name == fact.source))).scalar_one_or_none() if row is None: - return False, f"数据源「{fact.source}」已不存在,指标{_names(fact)}的选择未写入数据目录" - return await _write(session, row, fact, note=note_of(fact, run_id=run_id, node_title=node_title), - actor=actor) + return False, [f"数据源「{fact.source}」已不存在,指标{_names(_members(fact))}的选择未写入数据目录"] + return await _write(session, row, fact, run_id=run_id, node_title=node_title, actor=actor) except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记 logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", fact.source) - return False, f"指标{_names(fact)}的选择写入数据目录时出错,未写入" + return False, [f"指标{_names(_members(fact))}的选择写入数据目录时出错,未写入"] def _plan(notes: Mapping[str, Any], fact: Fact, tables: Mapping[str, Any] | None) -> tuple[Any, str | None]: """按目录里这张表现在的说法,算出这一项要写成什么:(要写的取值, None) 或 (None, 为什么不写)。不读库、不写库: - 真写(_write)和审批卡上的预告(preview)用的是这同一套判断。""" + 真写(_write)和审批卡上的预告(preview)用的是这同一套判断。几个指标并成的一项,先由 _screen 拿掉各自带回驳回 + 说法的指标再交到这里。""" label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) - names = _names(fact) + names = _names(_members(fact)) stored = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None - if stored is not None and stored.get("status") == "rejected" and _same_fact(fact.item, stored.get("value"), - fact.value): - # 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意 - return None, (f"「{fact.table}」的{label}「{_value_text(fact.item, fact.value)}」曾被人工驳回," - f"与这次指标{names}的选择相反,未写入。如需更改,请到数据目录中修改") - live = stored if stored is not None and stored.get("status") != "rejected" else None + rejected, said = _rejected(notes, fact.item) + if rejected and _revives(fact, said): + # 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意。并成的一项各自的说法都不是驳回的那句、 + # 合起来却又包含了它,也在这里拦下 + return None, _rejected_text(fact, said, _members(fact)) + live = stored if stored is not None and not rejected else None value = fact.value if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \ and live["value"].get("column") == value.get("column"): @@ -655,9 +734,13 @@ def _plan(notes: Mapping[str, Any], fact: Fact, tables: Mapping[str, Any] | None if combined is None: return None, why if live.get("status") == "confirmed" and not _same_fact(fact.item, combined, live["value"]): - adopted = f"被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" if not fact.also else "这次几个指标被采纳的写法" + # 只说已确认的说法里没有的那几段:已有的段算成「多出」,审核人会以为连它也被改了 + extra = _and_join(live["value"].strip(), [str(m.value) for m in _members(fact)], fact.dialect, + keep_base=False) + adopted = f"被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" if not fact.members \ + else "这次几个指标被采纳的写法" return None, (f"「{fact.table}」的{label}已有人工确认过的说法「{live['value'].strip()}」,{adopted}" - f"另外多出条件「{fact.value}」,未改动。如需补上,请到数据目录中修改") + f"另外多出条件「{extra or fact.value}」,未改动。如需补上,请到数据目录中修改") value = combined elif fact.item == "dedup" and isinstance(live, dict) and live.get("status") != "confirmed" \ and not _same_fact(fact.item, live.get("value"), value): @@ -702,34 +785,41 @@ def preview(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None return out -async def _write(session: Any, row: DataSource, fact: Fact, *, note: str, actor: str | None) -> tuple[bool, str | None]: - """写一项。(写了没有, 没写的原因)。写什么、写不写由 _plan 按读到的目录定;版本对不上(别人刚改过)重读再来。""" +async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node_title: str, + actor: str | None) -> tuple[bool, list[str]]: + """写一项:(写了没有, 没写的原因)。几个指标并成的一项先按读到的目录拿掉各自带回驳回说法的指标(_screen),写什么、 + 写不写再由 _plan 定;备注只列真写进去的指标。版本对不上(别人刚改过)重读再来。""" label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) tables = (row.schema_cache or {}).get("tables") if isinstance(row.schema_cache, dict) else None + reasons: list[str] = [] for _ in range(_RETRIES): entry = await catalog.read_entry(session, row.id, fact.table) notes = entry.notes if entry else {} version = entry.version if entry else 0 - value, why = _plan(notes, fact, tables) + kept, reasons = _screen(notes, fact) + if kept is None: + return False, reasons + value, why = _plan(notes, kept, tables) if why is not None: - return False, why + return False, [*reasons, why] + note = note_of(kept, run_id=run_id, node_title=node_title) at = catalog.now_iso() try: - merged = catalog.apply_patch(notes, [{"path": fact.item, "value": value, "reason": note}], - table=fact.table, tables=tables, at=at) + merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value, "reason": note}], + table=kept.table, tables=tables, at=at) except catalog.CatalogInvalid as e: - return False, f"「{fact.table}」的{label}未写入数据目录:{e}" + return False, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处 - merged[fact.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at) + merged[kept.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at) try: - await catalog.write_entry(session, row.id, fact.table, merged, if_version=version, actor=actor) + await catalog.write_entry(session, row.id, kept.table, merged, if_version=version, actor=actor) except catalog.CatalogConflict: await session.rollback() continue except catalog.CatalogInvalid as e: - return False, f"「{fact.table}」的{label}未写入数据目录:{e}" - return True, None - return False, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认" + return False, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] + return True, reasons + return False, [*reasons, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认"] __all__ = ["FACT_ITEMS", "Fact", "derive", "note_of", "preview", "record_choices"] diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index d75bafe6..6f8067f8 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -7,9 +7,10 @@ - 指标自己的筛选范围(只看华东、只看线上)不是有效记录条件:取值出现在指标名称、口径说明、所在分组、运行输入里的, 条件用到分组的列的,不是在排除退款、作废、测试这类无效记录的,都不写 - 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖; - 目录里已有的有效记录条件被采纳的一边也用着的,合起来写,没用着的不动 + 目录里已有的有效记录条件被采纳的一边也用着的,合起来写(已有的段只写一次),没用着的不动;驳回过的说法不写回 + (!= 和 <>、列名大小写、先后不同也认得出) - 同一次裁决里几个指标落到同一项上:说法一样的写一次;有效记录条件(都在排除无效记录)用 AND 合成一句写;业务日期 - 说法不同的都不写 + 说法不同的都不写;自己的说法驳回过的指标拿出来不写、其余照写(和各自的预告一致),合起来又成了驳回的整句也不写 - 审批卡上的预告(preview):选 a、选 b 各会写什么、不写的为什么,按目录里已有的说法算,和真写同一套判断 差异条目一律由 sql_semantics.explain_sql_diff 真算出来,和节点交给审核人看的是同一份。表名、数字都是虚构的。 @@ -422,6 +423,110 @@ async def test_combined_filters_extend_an_inferred_condition_every_adopted_side_ assert (await entry_of(source_id)).notes["valid_filter"]["value"] == "channel <> 'test'" +async def test_a_rejected_condition_is_not_written_back_through_a_combined_decision(shop): + """目录里「status <> 'refunded'」被人工驳回过。同一次裁决里「销售额」「订单数」采纳了排除退款、「订单金额」采纳了 + 排除测试单:审批卡上预告前两个不写(曾被人工驳回)、后一个写「is_test = 0」,真写也照这样。驳回过的条件不能借着 + 合并又写成人工确认,拿出来的指标照实说原因。""" + name, source_id = shop + rejected = {"valid_filter": catalog.make_item("status <> 'refunded'", "llm", "rejected")} + await seed(source_id, rejected) + refunds = candidate(SUM_WEEK, extra("status <> 'refunded'"), "b", metric="销售额", source=name) + tests = candidate(SUM_WEEK, extra("is_test = 0"), "b", metric="订单金额", source=name) + counted = candidate(COUNT_WEEK, extra("status <> 'refunded'", COUNT_WEEK), "b", metric="订单数", source=name) + notes = {"orders": (await entry_of(source_id)).notes} + assert preview(refunds, SCHEMA, notes)["b"]["write"] is False + assert preview(tests, SCHEMA, notes)["b"]["value"] == "is_test = 0" + out = await record_choices([refunds, tests, counted], run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}], out + [skipped] = out["skipped"] + assert "「status <> 'refunded'」曾被人工驳回" in skipped["reason"] and "「销售额」、「订单数」" in skipped["reason"] + assert "订单金额" not in skipped["reason"] + item = (await entry_of(source_id)).notes["valid_filter"] + assert item["value"] == "is_test = 0" and (item["source"], item["status"]) == ("human", "confirmed") + assert "「订单金额」" in item["note"] and "销售额" not in item["note"] and "订单数" not in item["note"] + + # 并成一项的每个指标采纳的都是驳回过的说法:一个都不写,一条原因说清是哪几个指标 + await seed_again(source_id, rejected) + out = await record_choices([refunds, counted], run_id="run-2", node_title="复核", actor=None) + assert out["written"] == [] and len(out["skipped"]) == 1, out + assert "曾被人工驳回" in out["skipped"][0]["reason"] and "「销售额」、「订单数」" in out["skipped"][0]["reason"] + assert (await entry_of(source_id)).notes["valid_filter"]["status"] == "rejected" + + +async def test_a_rejected_statement_is_not_rebuilt_from_several_metrics(shop): + """驳回的是「is_test = 0 AND status != 'refunded'」整句:单看每个指标都不是这句(预告各写各的),合起来却正是这句 + (!= 和 <>、先后不同也是同一个说法)。两次人工判断对不上,都不写;再并进一个别的条件,驳回的整句仍在里面,照样不写。""" + name, source_id = shop + rejected = catalog.make_item("is_test = 0 AND status != 'refunded'", "llm", "rejected") + await seed(source_id, {"valid_filter": rejected}) + refunds = candidate(SUM_WEEK, extra("status <> 'refunded'"), "b", metric="销售额", source=name) + tests = candidate(SUM_WEEK, extra("is_test = 0"), "b", metric="订单金额", source=name) + channel = candidate(COUNT_WEEK, extra("channel <> 'test'", COUNT_WEEK), "b", metric="订单数", source=name) + notes = {"orders": (await entry_of(source_id)).notes} + assert all(preview(c, SCHEMA, notes)["b"]["write"] is True for c in (refunds, tests, channel)) + for picked, names in (([refunds, tests], "「销售额」、「订单金额」"), + ([refunds, tests, channel], "「销售额」、「订单金额」、「订单数」")): + out = await record_choices(picked, run_id="run-1", node_title="复核", actor=None) + assert out["written"] == [], out + [skipped] = out["skipped"] + assert "曾被人工驳回" in skipped["reason"] and names in skipped["reason"] + entry = await entry_of(source_id) + assert entry.version == 1 and entry.notes["valid_filter"]["status"] == "rejected" + + +@pytest.mark.parametrize("rejected", ["status != 'refunded'", "STATUS <> 'refunded'"]) +async def test_a_rejected_condition_is_recognised_however_it_was_written(shop, rejected): + """驳回时写的是 != 、列名大写:和这次要写的「status <> 'refunded'」是同一个说法,预告和真写都不写。""" + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item(rejected, "llm", "rejected")}) + picked = candidate(WITH_REFUNDS, RIGHT, "b", source=name) + said = preview(picked, SCHEMA, {"orders": (await entry_of(source_id)).notes}) + assert said["b"]["write"] is False and f"「{rejected}」曾被人工驳回" in said["b"]["reason"] + out = await record_choices([picked], run_id="run-1", node_title="复核", actor=None) + assert out == {"written": [], "skipped": [{"reason": said["b"]["reason"]}]} + assert (await entry_of(source_id)).version == 1 + + +async def test_combined_filters_do_not_repeat_a_condition_already_in_the_catalog(shop): + """目录里推断的「status <> 'refunded'」:「销售额」采纳了带着它的盲算,「订单金额」两边都按它筛、盲算另外排除了 + 测试单。合起来写时已有的条件只出现一次(正是「订单金额」的预告);已有的条件是人工确认过的,原因里只说另外多出 + 的那一段。""" + name, source_id = shop + await seed(source_id, {"valid_filter": catalog.make_item("status <> 'refunded'", "llm")}) + base = extra("status <> 'refunded'") + picked = [candidate(SUM_WEEK, base, "b", metric="销售额", source=name), + candidate(base, extra("is_test = 0", base), "b", metric="订单金额", source=name)] + said = preview(picked[1], SCHEMA, {"orders": (await entry_of(source_id)).notes}) + out = await record_choices(picked, run_id="run-1", node_title="复核", actor=None) + assert out == {"written": [{"source": name, "table": "orders", "item": "valid_filter"}], "skipped": []} + item = (await entry_of(source_id)).notes["valid_filter"] + assert item["value"] == said["b"]["value"] == "status <> 'refunded' AND is_test = 0" + + await seed_again(source_id, {"valid_filter": catalog.make_item("status <> 'refunded'", "human", "confirmed")}) + out = await record_choices(picked, run_id="run-2", node_title="复核", actor=None) + assert out["written"] == [] + reason = out["skipped"][0]["reason"] + assert "已有人工确认过的说法「status <> 'refunded'」" in reason and "另外多出条件「is_test = 0」" in reason + assert (await entry_of(source_id)).notes["valid_filter"]["value"] == "status <> 'refunded'" + + +def test_conditions_are_joined_without_repeating_a_part(): + """几句条件接成一句:已经有的段不再接(整句接上会把已有的段再写一遍);只说多出的时不带原有的;最外层是 OR 的 + 只在接了别的条件时加括号。""" + from app.engine.dual_check_facts import _and_join + + assert _and_join("status <> 'refunded'", ["status <> 'refunded' AND is_test = 0"], "sqlite") == \ + "status <> 'refunded' AND is_test = 0" + assert _and_join("status <> 'refunded'", ["STATUS != 'refunded'", "is_test = 0", "is_test = 0"], "sqlite") == \ + "status <> 'refunded' AND is_test = 0" + assert _and_join("status <> 'refunded'", ["status <> 'refunded' AND is_test = 0"], "sqlite", + keep_base=False) == "is_test = 0" + assert _and_join("status = 'void' OR is_test = 1", ["(status = 'void' OR is_test = 1)"], "sqlite") == \ + "status = 'void' OR is_test = 1" + assert _and_join("status = 'void' OR is_test = 1", ["is_test = 0"], "sqlite") == \ + "(status = 'void' OR is_test = 1) AND is_test = 0" + + async def test_the_same_choice_from_two_metrics_is_written_once(shop): name, source_id = shop out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "b", metric="销售额", source=name), diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 5ed4c487..1aa09683 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -7,9 +7,10 @@ - 不一致:挂起,审批 payload 和对照工件按约定,payload 另带实际生效的 on_reject、摘要里每个指标带选 a、选 b 各会往 数据目录里写什么的预告;a / b / neither 三种选择的产出和缺口,没选的照实记,驳回、on_reject 为 fail 时失败 - 差异说明只比这一格所在的那一列(一条查询算好几列、分组后取一行的,收窄了再比,HAVING 一并去掉);分组的列设了 - 遮罩时不收窄,遮罩列的取值不进审批卡、对照工件、产出和事件 + 遮罩时(改了名再分组的也算)不收窄,遮罩列的取值不进审批卡、对照工件、产出和事件 - 只差一个方面时写数据目录(业务日期、有效记录条件),差了不止一处不写,人工确认过的不同说法不覆盖,指标自己的 - 筛选范围(取值在运行输入里、用到分组的列)不写,两条 SQL 规范化后一致的不写 + 筛选范围(取值在运行输入里、用到分组的列)不写,两条 SQL 规范化后一致的不写;人工驳回过的条件不会借着和别的 + 指标并成一项写回去,真写的和审批卡上的预告一致 - 全局关闭直通、节点覆盖;设置按发起时的快照;重放时盲算不再执行、事件不重复;报告的写作目录不含盲算的查询 - 触顶、分组结果的维度(只给取值、遮罩列不给)、带着取舍的列名不交给盲算、两个指标用到同一格只盲算一次、遮罩列上的 盲算结果不采用 @@ -588,6 +589,37 @@ async def test_a_confirmed_catalog_entry_is_not_overwritten(monkeypatch, shop, c 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 + said = {s["metric_id"]: s["catalog"]["b"] for s in (await pending(run.id)).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" + done = await decide(client, run.id, {"gmv": "b", "total": "b"}) + 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"}] + assert [s["reason"] for s in wrote["skipped"]] == [said["gmv"]["reason"]] + 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"] + + async def test_a_metric_scope_filter_is_not_written_as_a_valid_filter(monkeypatch, shop, client): """「华东销售额」原流程按指标名只看华东,盲算漏了地区:审核人选原流程,但 region = '华东' 是这个指标自己的筛选 范围,写成表的有效记录条件,以后所有查订单表的工作流(包括下一次盲算)都会只看华东。""" @@ -845,6 +877,26 @@ async def test_masked_group_values_stay_out_of_the_card_and_the_catalog_log(monk 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(monkeypatch, name, {"首组销售额": (RIGHT, "gmv")}) + run = await start(graph(name, renamed, metrics=[first], dual={"definitions": {}})) + assert run.status == "interrupted", run.error + payload = (await pending(run.id)).payload + assert payload["summary"][0]["original_rendered"].startswith("300") + 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)) + # 照整条比:原流程分组、盲算没分组都列出来 + assert len(payload["summary"][0]["diff"]) > 1 + + async def test_a_masked_group_column_of_the_blind_query_is_not_narrowed_either(monkeypatch, masked_shop, client): """盲算自己按遮罩列分组、交了华东那一行:同样不拿这一行的分组值改写盲算的 SQL。""" name, _ = masked_shop