From e353706498b892a98e2e781fd0da334c2a92a04d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:31:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=86=99?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=EF=BC=9A=E4=BA=BA=E5=B7=A5=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E5=90=8C=E4=B8=80=E8=AF=B4=E6=B3=95=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=86=99=E5=9B=9E=EF=BC=9B=E8=8A=82=E7=82=B9=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E8=A1=A5=E4=B8=8A=E7=9B=B2=E7=AE=97=20agent=20?= =?UTF-8?q?=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"),