From a86991b57f8a0519dab1c5d87be2ecb102369dda Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 20 Oct 2026 10:33:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=EF=BC=9A?= =?UTF-8?q?=E7=9B=B2=E7=AE=97=E8=A7=A6=E9=A1=B6=E3=80=81=E6=8C=89=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=A4=8D=E7=AE=97=E8=AE=B0=E6=97=B6=EF=BC=88=5Flimit?= =?UTF-8?q?=5Ftext=20=E9=82=A3=E4=B8=80=E8=B7=AF=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=8A=8A=E7=9B=B2=E7=AE=97=E4=BA=A4=E5=9B=9E=E7=9A=84=E7=90=86?= =?UTF-8?q?=E7=94=B1=EF=BC=88=E6=B8=B2=E6=9F=93=E5=90=8E=E7=9A=84=E6=AD=A3?= =?UTF-8?q?=E6=96=87=EF=BC=8C=E5=B7=B2=E7=BB=8F=E8=BF=87=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=EF=BC=89=E6=88=AA=E4=B8=80=E6=AE=B5=E5=B8=A6=E8=BF=9B=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=EF=BC=88=E3=80=8C=E7=9B=B2=E7=AE=97=E5=B7=B2=E8=BE=BE?= =?UTF-8?q?=E6=AF=8F=E9=A1=B9=E6=9C=80=E5=A4=9A=E6=9F=A5=E8=AF=A2=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=EF=BC=881=20=E6=AC=A1=EF=BC=89=EF=BC=9B=E7=9B=B2?= =?UTF-8?q?=E7=AE=97=E4=BA=A4=E5=9B=9E=E7=9A=84=E7=90=86=E7=94=B1=EF=BC=9A?= =?UTF-8?q?=E2=80=A6=E3=80=8D=EF=BC=89=EF=BC=8C=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E3=80=81=E7=BC=BA=E5=8F=A3=E8=AF=B4=E6=98=8E=E4=B8=8A=E7=9C=8B?= =?UTF-8?q?=E5=BE=97=E5=88=B0=E5=AE=83=E6=9F=A5=E5=88=B0=E4=BA=86=E4=BB=80?= =?UTF-8?q?=E4=B9=88=EF=BC=8C=E6=B2=A1=E6=9C=89=E7=90=86=E7=94=B1=E7=9A=84?= =?UTF-8?q?=E7=85=A7=E6=97=A7=E3=80=82=E5=8E=9F=E5=80=BC=E6=89=80=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E6=A0=BC=E5=AD=90=E8=90=BD=E5=9C=A8=E3=80=8C=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=8C=BA=E9=97=B4=E9=87=8C=E8=BF=99=E5=BC=A0=E8=A1=A8?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=95=B0=E6=8D=AE=E3=80=8D=E7=9A=84=E5=88=97?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E4=B8=8D=E6=B4=BE=E7=9B=B2=E7=AE=97=E3=80=81?= =?UTF-8?q?=E6=8C=89=E6=97=A0=E6=B3=95=E5=A4=8D=E7=AE=97=E8=AE=B0=EF=BC=9B?= =?UTF-8?q?=E7=9B=B2=E7=AE=97=E4=BA=A4=E5=9B=9E=E7=9A=84=E6=A0=BC=E5=AD=90?= =?UTF-8?q?=E8=90=BD=E5=9C=A8=E8=BF=99=E6=A0=B7=E7=9A=84=E5=88=97=E4=B8=8A?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=88=A4=E4=B8=80=E8=87=B4=E3=80=81=E6=8C=89?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=A4=8D=E7=AE=97=E8=AE=B0=EF=BC=9B=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E9=83=BD=E5=86=99=E3=80=8C=E6=9F=A5=E8=AF=A2=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=E9=87=8C=E8=BF=99=E5=BC=A0=E8=A1=A8=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=88=E2=80=A6=EF=BC=89=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/dual_check.py | 36 +++++++++++++++++- backend/tests/test_dual_check_node.py | 51 +++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 642cd847..9ce7e610 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -69,6 +69,7 @@ from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import DataSource from app.data import catalog_view +from app.data.coverage import NO_DATA_SHORT, no_data_of from app.engine import deadline, recompute from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError, NodeRejected @@ -635,6 +636,10 @@ async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[st hidden = await sources.hidden(artifact) if column.lower() in hidden: return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算" + if (gap := no_data_of(snap, column)) is not None: + # 原值所在的那一格落在「查询区间里这张表没有数据」的列上(data/coverage.py):那个 0 不代表真的是 0,盲算查到 0 + # 也说明不了一致,不白派一次盲算,按无法复算记 + return {}, f"{NO_DATA_SHORT}(输入 {item['path']}:{gap.get('message') or ''})" value = cell_value(item.get("value")) if not _number(value): return {}, f"输入 {item['path']} 不是数值" @@ -1003,6 +1008,30 @@ def _settled(found: Mapping[str, Any]) -> bool: return found.get("limit") == "max_queries" and found.get("value") is not None +#: 无法复算的原因里带上盲算交回的理由时最多截多少字(理由是渲染后的正文,已经过遮罩) +RATIONALE_CHARS = 160 + + +def _rationale_tail(found: Mapping[str, Any]) -> str: + """盲算触顶、按无法复算记时(_limit_text 那一路),它交回的理由截一段接在原因后面:审批卡、缺口说明上看得到它查到了 + 什么。没有理由的交回空串(原因照旧)。""" + text = " ".join(str(found.get("rationale") or "").split()) + if not text: + return "" + return f";盲算交回的理由:{text[:RATIONALE_CHARS]}{'…' if len(text) > RATIONALE_CHARS else ''}" + + +def _no_data_cell(artifact: Any, column: Any, load: Callable[[str], Any]) -> dict[str, Any] | None: + """这一格所在的列「查询区间里这张表没有数据」(查询快照的 no_data,data/coverage.py):那一条,不是为 None。""" + if not isinstance(artifact, str) or not artifact or not isinstance(column, str): + return None + try: + snapshot = load(artifact) + except (ValueError, OSError): + return None + return no_data_of(snapshot, column) + + def _limit_text(limit: str | None, options: _Options) -> str: """盲算为什么停下,接在「盲算」后面读:「盲算已达每项最多查询次数(6 次)」。""" if limit == "max_queries" and options.max_queries is not None: @@ -1027,12 +1056,17 @@ def _compare(row: dict[str, Any], metric: dict[str, Any], results: dict[str, Any row.update(status=UNCHECKED, reason="盲算没有执行") return if found.get("limit") and not _settled(found): - row.update(status=UNCHECKED, reason="盲算" + _limit_text(found["limit"], options)) + row.update(status=UNCHECKED, reason="盲算" + _limit_text(found["limit"], options) + _rationale_tail(found)) return if found.get("value") is None: row.update(status=UNCHECKED, reason=found.get("reason") or "盲算没有交回可用的数值", **{k: found[k] for k in ("cause", "run_seconds") if k in found}) # 运行时长上限将至(_blind) return + if gap := _no_data_cell(found.get("artifact"), found.get("column"), load): + # 盲算交回的那一格落在「查询区间里这张表没有数据」的列上(data/coverage.py):那个 0 不代表真的是 0,拿它和原值 + # 比一致不一致都说明不了什么,按无法复算记 + row.update(status=UNCHECKED, reason=f"{NO_DATA_SHORT}(盲算查到的那一格:{gap.get('message') or ''})") + return values[inp["path"]] = found["value"] outcome = recompute.recompute(metric, recompute.Changes(inputs=values), recompute.Sources( load=load, outputs=outputs, masked=masks, queries=queries)) diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index f7f2d1f0..f99553b3 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -271,9 +271,11 @@ 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。""" + """查询次数用完了还没查到要交的那一格:按无法复算计入缺口;一个指标都没核成,产出是 unchecked,不是 agreed。 + 盲算交回的理由截一段带进原因:审批卡、缺口说明上看得到它查到了什么。""" name, _ = shop - Blind(monkeypatch, name, {"销售额": ([BLIND_TOTAL, RIGHT], "gmv")}) + Blind(monkeypatch, name, {"销售额": ([BLIND_TOTAL, RIGHT], "gmv")}, + rationale="只查到了不含退款的合计,还没来得及按支付日核对。") run = await start(graph(name, RIGHT), options={"verify": {"dual_check": {"max_queries": 1}}}) assert run.status == "succeeded", run.error evs = await events(run.id) @@ -281,15 +283,16 @@ async def test_hitting_a_limit_counts_as_unchecked(monkeypatch, shop): out = dual_output(run, evs) assert out["status"] == "unchecked" and out["metrics"][0]["status"] == "unchecked" assert out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 1} - assert out["issuance_gaps"] == ["指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] + reason = "盲算已达每项最多查询次数(1 次);盲算交回的理由:只查到了不含退款的合计,还没来得及按支付日核对。" + assert out["issuance_gaps"] == [f"指标「销售额」无法复算:{reason}"] [said] = logs(evs, "dual_check_unchecked") - assert said["metrics"] == [{"id": "gmv", "name": "销售额", "reason": "盲算已达每项最多查询次数(1 次)"}] + assert said["metrics"] == [{"id": "gmv", "name": "销售额", "reason": reason}] assert not logs(evs, "dual_check_agreed") assert not [e for e in evs if e.type == "human.requested"] [finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"] assert finished.data["evidence"][0]["status"] == "unchecked" assert run.output["_issuance"]["tier"] == "degraded" - assert run.output["_issuance"]["gaps"] == ["双盲复算「复核销售额」:指标「销售额」无法复算:盲算已达每项最多查询次数(1 次)"] + assert run.output["_issuance"]["gaps"] == [f"双盲复算「复核销售额」:指标「销售额」无法复算:{reason}"] async def test_an_answer_on_the_last_allowed_query_still_counts(monkeypatch, shop): @@ -1575,3 +1578,41 @@ async def test_the_report_catalog_leaves_out_the_blind_queries(monkeypatch, shop if e.type == "tool.end" and e.node_id == "dual" and e.data.get("query_artifact")] ledger = [entry for e in evs if e.type == "node.finished" for entry in e.data.get("evidence") or []] assert blind_queries and not any(entry.get("artifact") in blind_queries for entry in ledger) + + +# -------------------------------------------------------------------------- +# 查询区间里这张表没有数据(data/coverage.py):不判一致,按无法复算记 +# -------------------------------------------------------------------------- + +#: 订单表只有 2026 年的数:2030 年那一周表里没有数据,COUNT 是 0 +EMPTY_WEEK = "BETWEEN '2030-01-07' AND '2030-01-13'" +NO_ORDERS = f"SELECT COUNT(*) AS orders FROM orders WHERE status <> 'refunded' AND paid_date {EMPTY_WEEK}" +ORDERS = {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"} + + +async def test_an_original_cell_without_data_is_not_checked(monkeypatch, shop): + """原值所在的格子落在 no_data 的列上:那个 0 不代表真的是 0,盲算查到 0 也说明不了一致。不派盲算,按无法复算记。""" + name, _ = shop + blind = Blind(monkeypatch, name, {"订单数": (NO_ORDERS, "orders")}) + run = await start(graph(name, NO_ORDERS, metrics=[ORDERS], dual={"definitions": {"orders": "已支付订单数"}})) + assert run.status == "succeeded", run.error + evs = await events(run.id) + out = dual_output(run, evs) + [row] = out["metrics"] + assert row["status"] == "unchecked" and row["reason"].startswith("查询区间里这张表没有数据(输入 cell(nodes.fetch") + assert "2030-01-07 到 2030-01-13" in row["reason"] + assert "step" not in blind.calls, "原值就没有数据:不白派一次盲算" + assert out["counts"] == {"agreed": 0, "disagreed": 0, "unchecked": 1} + + +async def test_a_blind_cell_without_data_is_not_agreed(monkeypatch, shop): + """盲算交回的格子落在 no_data 的列上(它查了一段表里没有数据的区间):不判一致,按无法复算记。""" + name, _ = shop + buyers = {"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"} + original = f"SELECT COUNT(*) AS orders FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}" + Blind(monkeypatch, name, {"订单数": (NO_ORDERS.replace("AS orders", "AS n"), "n")}) + run = await start(graph(name, original, metrics=[buyers], dual={"definitions": {"orders": "已支付订单数"}})) + assert run.status == "succeeded", run.error + [row] = dual_output(run, await events(run.id))["metrics"] + assert row["status"] == "unchecked", row + assert row["reason"].startswith("查询区间里这张表没有数据(盲算查到的那一格:「n」查的是 2030-01-07 到 2030-01-13")