From e62d6d2af4c7f2c9c4b25f4d2440baa7bbd462dd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:45:25 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E8=AE=A4=E5=BE=97=E6=A0=B8=E6=9F=A5=E7=B1=BB=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BB=A3=E7=A0=81=EF=BC=9A=E5=8F=8C=E7=9B=B2=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=E3=80=81=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=B8=E5=90=8C=E5=90=8C=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E4=B8=80=E6=A0=B7=E5=8F=AA=E8=AE=B0=E8=BD=A8=E8=BF=B9=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=8F=AB=E5=A4=8D=E6=A0=B8=E6=A8=A1=E5=9E=8B=EF=BC=9B?= =?UTF-8?q?=E8=A7=A6=E9=A1=B6=E3=80=81=E4=B8=A4=E8=B7=AF=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E3=80=81=E6=97=A0=E6=B3=95=E5=A4=8D=E7=AE=97=E3=80=81?= =?UTF-8?q?=E8=A2=AB=E6=8E=A8=E7=BF=BB=E3=80=81=E6=B2=A1=E8=83=BD=E5=8F=8D?= =?UTF-8?q?=E6=9F=A5=E3=80=81=E5=8F=AA=E6=9F=A5=E4=BA=86=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E6=8C=89=E5=90=84=E8=87=AA=E7=9A=84?= =?UTF-8?q?=20kind=20=E8=AE=B0=E6=88=90=E9=99=8D=E7=BA=A7=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=90=BD=E6=88=90=E7=AC=BC?= =?UTF-8?q?=E7=BB=9F=E7=9A=84=20warn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/review.py | 12 +++++++ backend/tests/test_review_verify_codes.py | 39 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 backend/tests/test_review_verify_codes.py diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 65c0b752..c9242c0f 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -111,6 +111,18 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 估不出裁判的金额、裁判和写作是同一个模型:配置上的提醒,答案本身没受影响 "judge_unpriced": ("judge_unpriced", BENIGN), "judge_same_model": ("judge_same_model", BENIGN), + # 双盲复算、反方审稿的核查模型和被核查的模型相同:同 judge_same_model,只关乎独立性,结论本身没受影响 + "dual_check_same_model": ("dual_check_same_model", BENIGN), + "challenge_same_model": ("challenge_same_model", BENIGN), + # 核查没做完、没通过:盲算触顶、两路不一致、指标无法复算;反方审稿触顶、推翻了结论、有句子没能反查、只查了 + # 部分数据源。答案照常交付(出具契约另按缺口降档),是缺口不是断裂,按各自的 kind 记,界面说得清是哪一种 + "dual_check_limit": ("dual_check_limit", DEGRADED), + "dual_check_disagreed": ("dual_check_disagreed", DEGRADED), + "dual_check_unchecked": ("dual_check_unchecked", DEGRADED), + "challenge_limit": ("challenge_limit", DEGRADED), + "challenge_refuted": ("challenge_refuted", DEGRADED), + "challenge_unreviewed": ("challenge_unreviewed", DEGRADED), + "challenge_sources_lost": ("challenge_sources_lost", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 diff --git a/backend/tests/test_review_verify_codes.py b/backend/tests/test_review_verify_codes.py new file mode 100644 index 00000000..b6f1efa8 --- /dev/null +++ b/backend/tests/test_review_verify_codes.py @@ -0,0 +1,39 @@ +"""跑完之后的复核层认得双盲复算、反方审稿的 warn 日志代码(engine/review.py 的 WARN_CODES)。 + +没登记的代码一律按 degraded、kind 记成笼统的 warn:时间线复核和问数据页说不清是哪一种缺口,模型相同这种只关乎独立性、 +不影响结论的提醒也会被当成缺口去叫一次复核模型。这里守住两类: +- 模型相同(dual_check_same_model、challenge_same_model)同 judge_same_model,是配置上的提醒,不进信号; +- 触顶、不一致、无法复算、被推翻、没能反查、只查了部分数据源是缺口,按各自的 kind 记成 degraded。 +""" +from __future__ import annotations + +import pytest + +from app.core.events import EventType +from app.engine import review as rv + +OK_OUTPUT = {"结论": "本周订单 120 单。"} + + +def warn(message: str, code: str) -> dict: + return {"type": str(EventType.LOG), "node_id": "gate", "data": {"level": "warn", "message": message, "code": code}} + + +@pytest.mark.parametrize("code", ["dual_check_same_model", "challenge_same_model"]) +def test_same_model_reminders_do_not_trigger_review(code): + """和 judge_same_model 一样:配置上的提醒,答案本身没受影响,不值得为它叫一次复核模型""" + assert rv.WARN_CODES[code] == (code, rv.BENIGN) + assert rv.scan([warn("核查模型与原流程相同(「mx-1」)", code)], OK_OUTPUT) == [] + + +@pytest.mark.parametrize("code", [ + "dual_check_limit", "dual_check_disagreed", "dual_check_unchecked", + "challenge_limit", "challenge_refuted", "challenge_unreviewed", "challenge_sources_lost", +]) +def test_verify_gaps_are_degraded_signals_of_their_own_kind(code): + """核查没做完、没通过:答案能用,但有缺口要说清是哪一种(kind 是代码本身,不是笼统的 warn)""" + assert rv.WARN_CODES[code] == (code, rv.DEGRADED) + [signal] = rv.scan([warn("核查的一条提醒", code)], OK_OUTPUT) + assert (signal.kind, signal.severity, signal.detail) == (code, rv.DEGRADED, "核查的一条提醒") + # 缺口不是断裂:不触发「重跑一次大概率能好」 + assert not rv.should_retry([signal]) From 67c7a42a3c39270048dba20f1e688882c6e3e50a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:45:44 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E3=80=81=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E7=9A=84=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E5=8F=96=E5=80=BC=E5=8F=AB=E6=B3=95=E6=8C=AA?= =?UTF-8?q?=E5=88=B0=20labels.py=EF=BC=9Afield=5Flabel=E3=80=81option=5Fla?= =?UTF-8?q?bel=20=E6=8C=89=E8=8A=82=E7=82=B9=E7=B1=BB=E5=9E=8B=E8=AF=B4?= =?UTF-8?q?=E3=80=8C=E7=9B=B2=E7=AE=97=E6=A8=A1=E5=9E=8B=E3=80=8D=E3=80=8C?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E8=A2=AB=E6=8E=A8=E7=BF=BB=E6=97=B6=E3=80=8D?= =?UTF-8?q?=E3=80=8C=E5=8F=AA=E6=A0=87=E6=B3=A8=E3=80=8D=E8=BF=99=E4=BA=9B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E4=B8=8A=E7=9A=84=E5=8F=AB=E6=B3=95?= =?UTF-8?q?=EF=BC=8C=E5=8F=91=E5=B8=83=E9=97=A8=E7=A6=81=E3=80=81=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E9=A2=84=E8=A7=88=E4=B8=8D=E5=86=8D=E8=AF=B4=E6=88=90?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E7=9A=84=E3=80=8C=E6=A8=A1=E5=9E=8B=E3=80=8D?= =?UTF-8?q?=E3=80=8C=E5=90=AF=E7=94=A8=E3=80=8D=EF=BC=9Bschema.py=20?= =?UTF-8?q?=E7=85=A7=E6=97=A7=E5=86=8D=E5=AF=BC=E5=87=BA=EF=BC=8C=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=92=8C=E6=89=A7=E8=A1=8C=E5=99=A8=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E5=8F=96=E5=90=8C=E4=B8=80=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/labels.py | 22 +++++++++++++ backend/app/engine/nodes/challenge.py | 4 +-- backend/app/engine/nodes/dual_check.py | 5 +-- backend/app/engine/schema.py | 29 +++++++---------- backend/tests/test_verify_labels.py | 45 ++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_verify_labels.py diff --git a/backend/app/engine/labels.py b/backend/app/engine/labels.py index 721bb032..1ba796a7 100644 --- a/backend/app/engine/labels.py +++ b/backend/app/engine/labels.py @@ -66,6 +66,24 @@ FIELD_LABEL: dict[str, str] = { "defaults": "工作流默认设置", } +#: 双盲复算节点各配置项的叫法(lib/terms.ts 的 DUAL_CHECK_TEXT.field,check-verify 逐项比对)。模型两项照结论句裁判 +#: 「裁判模型 · 接入」「裁判模型」的写法 +DUAL_CHECK_FIELD_LABEL: dict[str, str] = { + "card": "口径卡", "metrics": "复算的指标", "definitions": "口径说明", "provider": "盲算模型 · 接入", + "model": "盲算模型", "tolerance": "允许的相对误差", "on_reject": "未采用原流程时", "enabled": "启用双盲复算", +} +#: 「未采用原流程时」两个取值的短说法:检查器下拉的选项以它开头(DUAL_CHECK_TEXT.onReject),后面可以带一句用途 +DUAL_CHECK_ON_REJECT_LABEL: dict[str, str] = {"degrade": "计入缺口、出具降档", "fail": "判为失败"} +#: 反方审稿节点各配置项的叫法(lib/terms.ts 的 CHALLENGE_TEXT.field,check-challenge 逐项比对) +CHALLENGE_LABEL: dict[str, str] = { + "report": "要审的报告", "max_claims": "最多审几句", "max_queries": "每句最多查询次数", + "timeout_s": "每句时长上限(秒)", "provider": "反方模型 · 接入", "model": "反方模型", + "on_refuted": "结论被推翻时", "enabled": "启用", +} +#: 「结论被推翻时」三个取值的叫法:和检查器下拉(CHALLENGE_TEXT.onRefuted)一致,只去掉「(默认)」。报错让人去改 +#: 某一项时,人在下拉里找得到这个名字 +CHALLENGE_ON_REFUTED_LABEL: dict[str, str] = {"degrade": "出具降档", "flag": "只标注", "fail": "报错并停止运行"} + _FIELD_BY_TYPE: dict[str, dict[str, str]] = { "input": {"fields": "输入字段"}, "agent": {"prompt": "任务"}, @@ -81,6 +99,8 @@ _FIELD_BY_TYPE: dict[str, dict[str, str]] = { "stop_on_reject": "驳回即终止运行"}, "validate": {"source": "待校验内容", "fail_fast": "校验失败即中断"}, "merge": {"inputs": "输入", "sql": "合并 SQL"}, + "dual_check": DUAL_CHECK_FIELD_LABEL, + "challenge": CHALLENGE_LABEL, } #: 出具契约里的键(canvas/issues.ts 的 CONTRACT_KEYS、SUB_KEYS) @@ -114,6 +134,8 @@ _OPTION_LABEL: dict[str, dict[str, str]] = { "incomparable": "标注与历史不可比"}, "isolation": {"strict": "严格:microVM", "fast": "快速:系统沙箱"}, "thinking": {"summarized": "开启并显示摘要", "adaptive": "开启但不显示", "off": "关闭"}, + "on_reject": DUAL_CHECK_ON_REJECT_LABEL, + "on_refuted": CHALLENGE_ON_REFUTED_LABEL, } #: 让人在几个取值之间选时的完整选项文字(发布前修复的候选),和检查器下拉一致,只去掉「(默认)」 CHOICE_LABEL: dict[str, dict[str, str]] = { diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index f6195010..b856f34e 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -55,8 +55,8 @@ from app.engine.evidence import ENTITY_KINDS, iter_units, ledger_enabled, next_e from app.engine.judge import _usd from app.engine.merge_query import MERGE_SOURCE from app.engine.replay import _in_graph -from app.engine.schema import CHALLENGE_LABEL, CHALLENGE_ON_REFUTED_LABEL, GraphSpec, NodeType, challenge_problems, \ - challenge_report +from app.engine.labels import CHALLENGE_LABEL, CHALLENGE_ON_REFUTED_LABEL +from app.engine.schema import GraphSpec, NodeType, challenge_problems, challenge_report from app.engine.state import GraphState from app.engine.verify_agent import RATIONALE, VerifyOutcome, run_verify_agent from app.tools.datasource import run_versions diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 8d30c94c..9b54b752 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -62,7 +62,8 @@ 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.labels import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT_LABEL +from app.engine.schema import DUAL_CHECK_ON_REJECT, NodeType, dual_check_card from app.engine.state import GraphState #: 节点产出的状态:跳过、全部一致、核成了一部分(其余无法复算)、一个都没核成、人已裁决 @@ -208,7 +209,7 @@ async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 目录照样写了:审核人的选择是对数据的判断,和这次运行停不停无关 names = "、".join(f"「{r['name']}」" for r in rejected[:5]) raise NodeError(ctx.node.id, f"双盲复算不一致,审核人没有采用原流程的结果(指标{names})。" - f"「{DUAL_CHECK_FIELD_LABEL['on_reject']}」设为「判为失败」,运行已停止;" + f"「{DUAL_CHECK_FIELD_LABEL['on_reject']}」设为「{DUAL_CHECK_ON_REJECT_LABEL['fail']}」,运行已停止;" "请按审核意见修正取数或口径后重新运行") return _finish(state, ctx, card_id, card, checked, status=RESOLVED, gaps=gaps, choices=choices, note=note, artifact=review["card_artifact"], catalog=catalog) diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 10c945d3..6a607845 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -9,6 +9,13 @@ from typing import Any, Iterable, Literal from pydantic import BaseModel, Field, field_validator, model_validator from app.engine.labels import TYPE_LABEL, field_label, judge_label, option_label, q, type_label # noqa: F401 +# 双盲复算、反方审稿的叫法在 labels.py;校验、执行器和老的调用方照旧从这里取 +from app.engine.labels import ( # noqa: F401 + CHALLENGE_LABEL, + CHALLENGE_ON_REFUTED_LABEL, + DUAL_CHECK_FIELD_LABEL, + DUAL_CHECK_ON_REJECT_LABEL, +) class NodeType(StrEnum): @@ -425,15 +432,9 @@ 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 判为失败 +#: 审核人没有采用原流程的结果(选了盲算,或者判断两边都不对)时:degrade 计入缺口、出具降档(默认)/ fail 判为失败。 +#: 各配置项和这两个取值的叫法(DUAL_CHECK_FIELD_LABEL、DUAL_CHECK_ON_REJECT_LABEL)在 labels.py 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]: @@ -721,17 +722,9 @@ def _check_report_from(node: GraphNode, report_from: Any, spec: GraphSpec, # 反方审稿节点(engine/nodes/challenge.py):审哪份报告、各项配置 # -------------------------------------------------------------------------- -#: 反方审稿节点各项配置在界面上的叫法:报错写叫法,不写键名 -CHALLENGE_LABEL = { - "report": "要审的报告", "max_claims": "最多审几句", "max_queries": "每句最多查询次数", - "timeout_s": "每句时长上限(秒)", "provider": "反方模型 · 接入", "model": "反方模型", - "on_refuted": "结论被推翻时", "enabled": "启用", -} -#: 结论被推翻时怎么办:degrade 计入出具缺口、出具降档(默认)/ flag 只标注 / fail 判节点失败、运行停止 +#: 结论被推翻时怎么办:degrade 计入出具缺口、出具降档(默认)/ flag 只标注 / fail 判节点失败、运行停止。 +#: 各项配置和这三个取值在界面上的叫法(CHALLENGE_LABEL、CHALLENGE_ON_REFUTED_LABEL)在 labels.py:报错写叫法,不写键名 CHALLENGE_ON_REFUTED = ("degrade", "flag", "fail") -#: 选项的叫法和检查器下拉一致(lib/terms.ts 的 CHALLENGE_TEXT.onRefuted),只去掉「(默认)」:报错让人去改某一项时, -#: 人在下拉里找得到这个名字 -CHALLENGE_ON_REFUTED_LABEL = {"degrade": "出具降档", "flag": "只标注", "fail": "报错并停止运行"} def _challenge_field(key: str) -> str: diff --git a/backend/tests/test_verify_labels.py b/backend/tests/test_verify_labels.py new file mode 100644 index 00000000..cdcc5e59 --- /dev/null +++ b/backend/tests/test_verify_labels.py @@ -0,0 +1,45 @@ +"""双盲复算、反方审稿各项配置的叫法放在 engine/labels.py(和别的节点一样),校验、执行报错和通用的 field_label / +option_label 取的是同一份。 + +以前双盲复算的字段叫法写在 schema.py 的 DUAL_CHECK_FIELD_LABEL 里(那时 labels.py 不归双盲复算的工作包),反方审稿的 +写在 schema.py 的 CHALLENGE_LABEL 里:发布门禁、升级预览这些走 field_label 的地方拿不到它们,说成通用的「模型」「启用」, +和检查器上的「盲算模型」「启用双盲复算」对不上。和检查器(lib/terms.ts 的 DUAL_CHECK_TEXT.field、CHALLENGE_TEXT.field +与下拉选项)逐项比对在 check-verify、check-challenge 里做。 +""" +from __future__ import annotations + +from app.engine import labels, schema + + +def test_the_field_labels_live_in_labels_and_schema_reuses_them(): + assert schema.DUAL_CHECK_FIELD_LABEL is labels.DUAL_CHECK_FIELD_LABEL + assert schema.CHALLENGE_LABEL is labels.CHALLENGE_LABEL + assert schema.DUAL_CHECK_ON_REJECT_LABEL is labels.DUAL_CHECK_ON_REJECT_LABEL + assert schema.CHALLENGE_ON_REFUTED_LABEL is labels.CHALLENGE_ON_REFUTED_LABEL + # 每个可选的取值都有叫法,叫法里没有多出来的取值 + assert set(labels.DUAL_CHECK_ON_REJECT_LABEL) == set(schema.DUAL_CHECK_ON_REJECT) + assert set(labels.CHALLENGE_ON_REFUTED_LABEL) == set(schema.CHALLENGE_ON_REFUTED) + + +def test_field_label_names_the_fields_of_the_two_nodes_like_the_inspector(): + assert labels.field_label("model", "dual_check") == "盲算模型" + assert labels.field_label("provider", "dual_check") == "盲算模型 · 接入" + assert labels.field_label("enabled", "dual_check") == "启用双盲复算" + assert labels.field_label("on_reject", "dual_check") == "未采用原流程时" + assert labels.field_label("model", "challenge") == "反方模型" + assert labels.field_label("on_refuted", "challenge") == "结论被推翻时" + assert labels.field_label("max_claims", "challenge") == "最多审几句" + # 别的节点上的同名字段照旧 + assert labels.field_label("model", "agent") == "模型" + for key, words in labels.DUAL_CHECK_FIELD_LABEL.items(): + assert labels.field_label(key, "dual_check") == words, key + for key, words in labels.CHALLENGE_LABEL.items(): + assert labels.field_label(key, "challenge") == words, key + + +def test_option_label_uses_the_short_words_of_the_dropdowns(): + assert labels.option_label("on_refuted", "degrade") == "出具降档" + assert labels.option_label("on_refuted", "flag") == "只标注" + assert labels.option_label("on_refuted", "fail") == "报错并停止运行" + assert labels.option_label("on_reject", "degrade") == "计入缺口、出具降档" + assert labels.option_label("on_reject", "fail") == "判为失败" From 64b6586ecc399be69084182425f76c85e22ac666 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:45:59 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E5=87=BA=E5=85=B7=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=E8=8A=82=E7=82=B9=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E8=AF=B4=E4=B8=A4=E9=81=8D=EF=BC=9A=E4=B8=8A=E6=B8=B8=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E7=9A=84=E5=89=8D=E7=BC=80=E5=9C=A8=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=90=8D=E5=B0=B1=E6=98=AF=E7=B1=BB=E5=9E=8B=E5=8F=AB=E6=B3=95?= =?UTF-8?q?=E6=97=B6=E5=8F=AA=E5=86=99=E4=B8=80=E6=AC=A1=EF=BC=88=E3=80=8C?= =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=EF=BC=9A=E3=80=8D=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E3=80=81=E5=8F=8D?= =?UTF-8?q?=E6=96=B9=E5=AE=A1=E7=A8=BF=E7=9A=84=E7=BC=BA=E5=8F=A3=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E5=8E=BB=E6=8E=89=E8=87=AA=E5=B8=A6=E7=9A=84=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=EF=BC=88=E3=80=8C=E6=8E=A8=E7=BF=BB=E4=BA=86=201=20?= =?UTF-8?q?=E5=8F=A5=E7=BB=93=E8=AE=BA=EF=BC=9A=E2=80=A6=E3=80=8D=E3=80=8C?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E3=80=8CX=E3=80=8D=E4=B8=A4=E8=B7=AF?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8D=E4=B8=80=E8=87=B4=EF=BC=8C=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E4=BA=BA=E9=80=89=E6=8B=A9=E4=BA=86=E7=9B=B2=E7=AE=97?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E3=80=8D=EF=BC=89=EF=BC=9B=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E5=92=8C=E6=8A=A5=E9=94=99=E9=87=8C=E9=82=A3=E5=8F=A5?= =?UTF-8?q?=E7=85=A7=E6=97=A7=E5=B8=A6=E4=B8=BB=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/challenge.py | 12 ++++++++--- backend/app/engine/nodes/dual_check.py | 9 ++++---- backend/app/engine/nodes/io.py | 6 ++++-- backend/tests/test_challenge_node.py | 19 ++++++++++++++--- backend/tests/test_dual_check_node.py | 22 +++++++++++++++----- backend/tests/test_issuance_upstream_gaps.py | 6 ++++++ 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index b856f34e..6a02cef1 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -466,9 +466,15 @@ def _named(claims: list[dict[str, Any]]) -> str: return names + ("等" if len(claims) > _NAMED else "") +def _refuted_gap(claims: list[dict[str, Any]]) -> str: + """出具缺口里的那句:「推翻了 N 句结论:「…」」。不带节点名也不带「反方审稿」:出具契约统一加「反方审稿「节点名」:」 + (没改名的节点只加「反方审稿:」),正文再说一遍就成了「反方审稿:反方审稿推翻了……」。""" + return f"推翻了 {len(claims)} 句结论:{_named(claims)}" + + def _refuted_words(claims: list[dict[str, Any]]) -> str: - """出具缺口、时间线、报错里的那句:「反方审稿推翻了 N 句结论:「…」」。不带节点名:出具契约统一加前缀。""" - return f"反方审稿推翻了 {len(claims)} 句结论:{_named(claims)}" + """时间线、报错里的那句:「反方审稿推翻了 N 句结论:「…」」。它们不经出具契约加前缀,主语得自己带上。""" + return f"反方审稿{_refuted_gap(claims)}" def pick_items(claims: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -676,7 +682,7 @@ def _finish(ctx: NodeContext, state: GraphState, outcome: dict[str, Any], *, on_ result: dict[str, Any] = {"challenge_artifact": outcome.get("artifact"), "counts": outcome.get("counts") or {}, "items": pick_items(claims), "on_refuted": on_refuted} if refuted and on_refuted == "degrade": - result["issuance_gaps"] = [_refuted_words(refuted)] + result["issuance_gaps"] = [_refuted_gap(refuted)] updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} spent = outcome.get("usage") or {} if spent.get("calls"): diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 9b54b752..b1c47136 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -110,10 +110,11 @@ SAME_SQL_ASPECT = "data" DIFF_TIMEOUT = "两条 SQL 比较超时,未能归纳成具体差异,请对照原文" DIFF_FAILED = "两条 SQL 比较时出错,未能归纳成具体差异,请对照原文" NO_SQL = "有一边没有可比较的 SQL,请对照原文" -#: 交给成果节点的缺口(不带节点名:出具契约统一加「双盲复算「节点名」:」) -GAP_BLIND = "指标「{name}」双盲复算不一致,审核人选择了盲算结果" -GAP_NEITHER = "指标「{name}」双盲复算不一致,审核人判断两边都不对" -GAP_UNANSWERED = "指标「{name}」双盲复算不一致,审核人没有作出选择" +#: 交给成果节点的缺口。不带节点名,也不再说「双盲复算」:出具契约统一加「双盲复算「节点名」:」(没改名的节点只加 +#: 「双盲复算:」),正文再说一遍就成了「双盲复算:指标「X」双盲复算不一致……」 +GAP_BLIND = "指标「{name}」两路结果不一致,审核人选择了盲算结果" +GAP_NEITHER = "指标「{name}」两路结果不一致,审核人判断两边都不对" +GAP_UNANSWERED = "指标「{name}」两路结果不一致,审核人没有作出选择" GAP_UNCHECKED = "指标「{name}」无法复算:{reason}" BLIND_SYSTEM = ( diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 80102660..d1c83c2d 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -322,7 +322,8 @@ def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]: 这是给「把关类」节点留的通用口子:节点觉得这次的结论不该完整出具(双盲复算两路对不上、 反方审稿的反驳没有回应),在自己的产出里写 issuance_gaps,成果节点的出具契约照单收进缺口, 出具随之降档。写的时候不用带节点名,这里统一加上「双盲复算「复核销售额」:」这样的前缀, - 和别的缺口一样读得出是谁报的。 + 和别的缺口一样读得出是谁报的。节点名就是类型的叫法(拖进画布没改名)时只写一次:「反方审稿:」, + 不写成「反方审稿「反方审稿」:」。缺口正文也不必再提节点类型,前缀已经说了。 只认这个成果节点的上游:另一条支路上的节点管不到这份成果。不是字符串、空白的条目跳过—— 这个键谁都能写(数据整形的 JSON 模板也行),写坏了只当没写,不能让出具崩掉。 @@ -340,7 +341,8 @@ def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]: reported = payload.get("issuance_gaps") if not isinstance(reported, list): continue - prefix = f"{type_label(node.type)}「{node.title}」:" + kind = type_label(node.type) + prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:" out.extend(prefix + gap.strip() for gap in reported if isinstance(gap, str) and gap.strip()) return out diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 41bc16ac..62c35895 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -265,7 +265,7 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): issuance = row.output["_issuance"] assert issuance["tier"] == "degraded" - assert issuance["gaps"] == [f"反方审稿「审稿」:反方审稿推翻了 1 句结论:「{SOUTH}」"], "缺口不带节点名,前缀由出具契约加" + assert issuance["gaps"] == [f"反方审稿「审稿」:推翻了 1 句结论:「{SOUTH}」"], "缺口不带节点名,前缀由出具契约加" doc = await report_doc(row.id) east, south = unit_of(doc, EAST), unit_of(doc, SOUTH) @@ -275,7 +275,7 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): assert out["items"] == [{"unit": east, "text": EAST, "verdict": "weakened"}, {"unit": south, "text": SOUTH, "verdict": "refuted"}] assert out["on_refuted"] == "degrade" - assert out["issuance_gaps"] == [f"反方审稿推翻了 1 句结论:「{SOUTH}」"] + assert out["issuance_gaps"] == [f"推翻了 1 句结论:「{SOUTH}」"] record = artifact_store.load(out["challenge_artifact"]) assert set(record) == {"report", "doc_artifact", "claims", "model"} @@ -317,6 +317,19 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): assert finished.seq <= row.manifest_seq +async def test_a_challenge_named_after_its_type_is_named_once_in_the_gap(monkeypatch, shop): + """拖进画布没改名的反方审稿:缺口读成「反方审稿:推翻了 1 句结论:…」,前缀和正文都不再重复「反方审稿」""" + Models(monkeypatch, shop) + spec = graph(shop) + next(n for n in spec["nodes"] if n["id"] == "chal")["data"]["label"] = "反方审稿" + row = await finish(spec) + assert row.status == "succeeded", row.error + assert row.output["_issuance"]["gaps"] == [f"反方审稿:推翻了 1 句结论:「{SOUTH}」"] + # 时间线、报错里那一句照旧带主语:它们不经出具契约加前缀 + [refuted] = await logs(row.id, "challenge_refuted") + assert refuted["message"] == f"反方审稿推翻了 1 句结论:「{SOUTH}」" + + async def test_flag_only_records_and_fail_stops_the_run(monkeypatch, shop): Models(monkeypatch, shop) row = await finish(graph(shop, on_refuted="flag")) @@ -843,7 +856,7 @@ async def test_a_fail_that_would_be_swallowed_degrades_instead(monkeypatch, shop row = await finish(graph(shop, on_refuted="fail", on_error="continue")) assert row.status == "succeeded", row.error out = await output_of(row.id, "chal") - assert out["on_refuted"] == "degrade" and out["issuance_gaps"] == [f"反方审稿推翻了 1 句结论:「{SOUTH}」"] + assert out["on_refuted"] == "degrade" and out["issuance_gaps"] == [f"推翻了 1 句结论:「{SOUTH}」"] assert row.output["_issuance"]["tier"] == "degraded" diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 1aa09683..9703144d 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -446,13 +446,25 @@ async def test_choosing_the_blind_result_degrades_and_writes_the_catalog(monkeyp 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"] == ["指标「销售额」双盲复算不一致,审核人选择了盲算结果"] + assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人选择了盲算结果"] issuance = done.output["_issuance"] assert issuance["tier"] == "degraded" - assert issuance["gaps"] == ["双盲复算「复核销售额」:指标「销售额」双盲复算不一致,审核人选择了盲算结果"] + assert issuance["gaps"] == ["双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果"] assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"} +async def test_a_dual_check_named_after_its_type_is_named_once_in_the_gap(monkeypatch, shop, client): + """拖进画布没改名的双盲复算:缺口读成「双盲复算:指标「销售额」两路结果不一致……」,不写两遍「双盲复算」""" + name, _ = shop + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + spec = graph(name, BY_ORDER_DATE) + next(n for n in spec["nodes"] if n["id"] == "dual")["data"]["label"] = "双盲复算" + run = await start(spec) + done = await decide(client, run.id, {"gmv": "neither"}) + assert done.status == "succeeded", done.error + assert done.output["_issuance"]["gaps"] == ["双盲复算:指标「销售额」两路结果不一致,审核人判断两边都不对"] + + async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client): name, source_id = shop Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) @@ -461,7 +473,7 @@ async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client): assert done.status == "succeeded", done.error evs = await events(run.id) out = dual_output(done, evs) - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人判断两边都不对"] + 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 @@ -476,7 +488,7 @@ async def test_a_missing_choice_is_recorded_as_no_choice(monkeypatch, shop, clie evs = await events(run.id) out = dual_output(done, evs) assert out["choices"] == {} and "choice" not in out["metrics"][0] - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人没有作出选择"] + 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"] @@ -505,7 +517,7 @@ async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client): 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 diff --git a/backend/tests/test_issuance_upstream_gaps.py b/backend/tests/test_issuance_upstream_gaps.py index 92fbfc12..0df9d8e9 100644 --- a/backend/tests/test_issuance_upstream_gaps.py +++ b/backend/tests/test_issuance_upstream_gaps.py @@ -93,3 +93,9 @@ async def test_without_issuance_gaps_nothing_changes(): issuance = row.output["_issuance"] assert issuance["tier"] == "formal" assert issuance["gaps"] == [] + + +async def test_a_node_named_after_its_type_is_named_once(): + """节点名就是类型的叫法(拖进画布没改名的):前缀只写一次「数据整形:」,不写成「数据整形「数据整形」:」""" + row = await finish(graph(gaps_node("recheck", "数据整形", ["两路结果不一致"]))) + assert row.output["_issuance"]["gaps"] == ["数据整形:两路结果不一致"] From 7083d5c4574861f7dd96717c2cbdb0d3aa794e83 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:45:59 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E4=BF=AE=E5=A5=BD=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=B5=8B=E8=AF=95=E7=9A=84=E5=81=B6=E5=8F=91?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=9A=E5=87=A0=E6=9D=A1=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=9D=A0=E3=80=8C=E7=AC=AC=E4=BA=8C=E7=89=88=E7=9A=84=20100=20?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E6=8A=A5=E9=94=99=E5=8E=9F=E6=96=87=E9=87=8C?= =?UTF-8?q?=E3=80=8D=E8=AF=81=E6=98=8E=E6=B2=A1=E6=94=B9=E7=94=A8=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E7=89=88=E6=9C=AC=EF=BC=8C=E5=8E=9F=E6=96=87=E9=87=8C?= =?UTF-8?q?=E5=8D=B4=E5=B8=A6=E7=9D=80=E5=8D=81=E5=85=AD=E8=BF=9B=E5=88=B6?= =?UTF-8?q?=E7=9A=84=E9=9A=8F=E6=9C=BA=E6=BA=90=E5=90=8D=EF=BC=8C=E7=BA=A6?= =?UTF-8?q?=E4=BA=94=E7=99=BE=E4=B8=AA=E5=90=8D=E5=AD=97=E5=B0=B1=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=90=AB=E3=80=8C100=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E6=97=A0=E7=BC=98=E6=97=A0=E6=95=85=E5=8F=98?= =?UTF-8?q?=E7=BA=A2=EF=BC=88=E5=92=8C=E5=B9=B6=E8=A1=8C=E3=80=81=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=E3=80=81=E6=96=87=E4=BB=B6=E5=8F=A5=E6=9F=84=E6=97=A0?= =?UTF-8?q?=E5=85=B3=EF=BC=89=EF=BC=9B=E6=BA=90=E5=90=8D=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E5=AD=97=E6=AF=8D=EF=BC=8C=E5=8F=A6=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=AE=88=E4=BD=8F=E8=BF=99=E4=B8=AA=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_run_data_versions.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_run_data_versions.py b/backend/tests/test_run_data_versions.py index 28a06c8c..3b035f2a 100644 --- a/backend/tests/test_run_data_versions.py +++ b/backend/tests/test_run_data_versions.py @@ -91,8 +91,24 @@ V1 = {"明细": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]} V2 = {"明细": [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]], "新增": [["时段", "客流"], ["上午", 7]]} +#: 十六进制里的数字换成字母:名字里不带数字 +_NO_DIGITS = str.maketrans("0123456789", "ghijklmnop") + + def uniq(prefix: str = "dv") -> str: - return f"{prefix}_{uuid.uuid4().hex[:10]}" + """一个不会重复的源名,只有字母。 + + 报错原文里有源名(「数据源「dv_…」」),不少断言靠「第二版的 100 不在原文里」证明没有改用当前版本。十六进制名字 + 每 500 个左右就有一个带着「100」(dv_3a100bc2ef),那几条断言就会无缘无故地失败——这正是 + test_snapshot_file_deleted_after_tools_were_built 偶尔失败的原因,和并行、时序无关。名字里不带数字,数字的断言 + 才只看得见数据。""" + return f"{prefix}_{uuid.uuid4().hex[:12].translate(_NO_DIGITS)}" + + +def test_source_names_carry_no_digits(): + """守住 uniq 的约定:名字里一个数字都没有,报错原文里出现的数字只能来自数据(见 uniq 的说明)""" + names = {uniq() for _ in range(2000)} + assert len(names) == 2000 and not any(ch.isdigit() for name in names for ch in name.removeprefix("dv_")) async def publish(name: str, sheets: dict[str, list[list]]) -> tuple[str, str]: From 375f7165e5321ecaeb032df7d327e1fd16fe3ef3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:48:35 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=E8=AF=B4=E6=B8=85=E3=80=8C=E6=A0=B9=E6=9C=AC=E6=B2=A1=E5=8F=8D?= =?UTF-8?q?=E6=9F=A5=E3=80=8D=E5=92=8C=E3=80=8C=E5=88=B0=E5=BA=95=E9=99=8D?= =?UTF-8?q?=E4=B8=8D=E9=99=8D=E6=A1=A3=E3=80=8D=EF=BC=9A=E4=BA=A7=E5=87=BA?= =?UTF-8?q?=E5=92=8C=E5=AE=A1=E6=89=B9=E5=8D=A1=E7=9A=84=E6=A6=82=E5=86=B5?= =?UTF-8?q?=E5=8A=A0=20unreviewed=EF=BC=88=E6=A8=A1=E5=9E=8B=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E3=80=81=E8=A7=A6=E9=A1=B6=E3=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E4=B8=8D=E5=9C=A8=E4=BA=86=E7=AD=89=E6=B2=A1?= =?UTF-8?q?=E8=83=BD=E5=8F=8D=E6=9F=A5=E7=9A=84=E5=8F=A5=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E8=A7=A6=E9=A1=B6=E4=BD=86=E6=8C=89=E6=9F=A5=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E6=94=B6=E4=BA=86=E5=B0=BE=E7=9A=84=E4=B8=8D=E7=AE=97=EF=BC=89?= =?UTF-8?q?=E5=92=8C=20degrades=EF=BC=88=E6=8C=89=E9=99=8D=E6=A1=A3?= =?UTF-8?q?=E5=A4=84=E7=BD=AE=E3=80=81=E5=8F=88=E6=8E=A5=E5=9C=A8=E5=B8=A6?= =?UTF-8?q?=E5=87=BA=E5=85=B7=E5=A5=91=E7=BA=A6=E7=9A=84=E6=88=90=E6=9E=9C?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E4=B8=8A=E6=B8=B8=E6=97=B6=E6=89=8D=E4=B8=BA?= =?UTF-8?q?=E7=9C=9F=EF=BC=8C=E4=BE=A7=E6=9E=9D=E4=B8=8A=E3=80=81=E6=88=90?= =?UTF-8?q?=E6=9E=9C=E8=8A=82=E7=82=B9=E6=B2=A1=E5=B8=A6=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=97=B6=E4=B8=BA=E5=81=87=EF=BC=89=EF=BC=9B=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=89=8D=E7=9A=84=E4=BA=A7=E5=87=BA=E4=B8=8D=E5=B8=A6=E8=BF=99?= =?UTF-8?q?=E4=B8=A4=E9=A1=B9=EF=BC=8C=E5=AE=A1=E6=89=B9=E5=8D=A1=E7=85=A7?= =?UTF-8?q?=E6=97=A7=E8=A7=84=E5=88=99=E8=AF=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/challenge.py | 42 +++++++++++++++++------ backend/tests/test_challenge_node.py | 49 +++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 6a02cef1..40286d99 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -22,8 +22,10 @@ 没审全(这两个代码还不在期 3 约定的日志代码表里,界面认不得时照原话显示) 5. 处置:被推翻的按 on_refuted 处置。degrade(默认)在产出里写 issuance_gaps,成果节点的出具契约照单收进缺口、 出具降档(nodes/io.py 的 _upstream_issuance_gaps,所以反方审稿要接在成果节点上游,画布校验会提醒);fail 判节点 - 失败;flag 只记录。产出和审批卡里的 on_refuted 是实际生效的那一种(只有 degrade / flag,见 _finish)。审批卡 - (human.py 的 _reviewed_reports)附上审稿概况 + 失败;flag 只记录。产出和审批卡里的 on_refuted 是实际生效的那一种(只有 degrade / flag,见 _finish);degrades + 说被推翻的到底会不会让出具降档(按降档处置、又接在带出具契约的成果节点上游),unreviewed 是没能反查的句数(核查 + 模型不可用、触顶、数据源不在了……这几句记为无法判断,但根本没查成)。审批卡(human.py 的 _reviewed_reports)附上 + 审稿概况,据这两项说「出具会降档」「N 句没能反查」 上限:句数、每句查询次数、每句时长、全局每日金额(核查和裁判各记各的账,engine/spend.py)。都按发起时快照的核查 设置(run.options["verify"]),节点上写了的盖过它:审批停下期间改了设置,恢复、接着跑仍按发起时的那份。没有快照 @@ -56,7 +58,7 @@ from app.engine.judge import _usd from app.engine.merge_query import MERGE_SOURCE from app.engine.replay import _in_graph from app.engine.labels import CHALLENGE_LABEL, CHALLENGE_ON_REFUTED_LABEL -from app.engine.schema import GraphSpec, NodeType, challenge_problems, challenge_report +from app.engine.schema import GraphSpec, NodeType, _ancestors, challenge_problems, challenge_report from app.engine.state import GraphState from app.engine.verify_agent import RATIONALE, VerifyOutcome, run_verify_agent from app.tools.datasource import run_versions @@ -622,6 +624,9 @@ class _Review: unreviewed.append(why) counts = {v: sum(1 for c in claims if c["verdict"] == v) for v in VERDICTS} + # 没能反查的句数:没派出去、派出去却没查成的(unreviewed 里各有原因),加上触顶时没能给出结论的。都记为无法判断, + # 可「无法判断 2 句」既可能是查了、数据不够,也可能是根本没查:审批卡要分开说 + missed = len(unreviewed) + sum(skipped for _, skipped in hit.values()) artifact = await self._store({"report": self.report_id, "doc_artifact": self.doc_artifact, "claims": claims, "model": model}) for limit, (touched, skipped) in hit.items(): @@ -651,7 +656,7 @@ class _Review: ctx.emit(EventType.LOG, level="warn", code="challenge_refuted", items=[{"unit": c["unit"], "text": c["text"]} for c in refuted[:ITEMS_MAX]], message=_refuted_words(refuted)) - return {"artifact": artifact, "counts": counts, "usage": usage, + return {"artifact": artifact, "counts": counts, "usage": usage, "unreviewed": missed, "claims": [{k: c[k] for k in ("unit", "text", "verdict")} for c in claims]} async def _store(self, record: dict[str, Any]) -> str | None: @@ -680,7 +685,11 @@ def _finish(ctx: NodeContext, state: GraphState, outcome: dict[str, Any], *, on_ f"「{CHALLENGE_LABEL['on_refuted']}」改为「{CHALLENGE_ON_REFUTED_LABEL['flag']}」") on_refuted = "degrade" if refuted else "flag" result: dict[str, Any] = {"challenge_artifact": outcome.get("artifact"), "counts": outcome.get("counts") or {}, - "items": pick_items(claims), "on_refuted": on_refuted} + "items": pick_items(claims), "on_refuted": on_refuted, + "degrades": on_refuted == "degrade" and feeds_issuance(ctx.run.spec, ctx.node.id)} + # checkpoint 里升级前审的结果没有这一项:不知道就不写,审批卡照旧不说 + if isinstance(outcome.get("unreviewed"), int): + result["unreviewed"] = outcome["unreviewed"] if refuted and on_refuted == "degrade": result["issuance_gaps"] = [_refuted_gap(refuted)] updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} @@ -700,10 +709,19 @@ def _finish(ctx: NodeContext, state: GraphState, outcome: dict[str, Any], *, on_ # -------------------------------------------------------------------------- +def feeds_issuance(spec: GraphSpec, node_id: str) -> bool: + """这个节点在不在某个带出具契约的成果节点上游。成果节点的出具契约只收上游报上来的缺口(nodes/io.py 的 + _upstream_issuance_gaps):接在侧枝上、或者在成果之后,被推翻的结论记了缺口也进不了出具。认法同画布校验 + (schema._check_challenge 的「不在成果节点上游」警告)。""" + return any(node_id in _ancestors(spec, n.id) for n in spec.nodes + if n.type == NodeType.OUTPUT and n.config.get("contract")) + + def challenge_summary(spec: GraphSpec, outputs: dict[str, Any], report_id: str) -> dict[str, Any] | None: - """审批卡上这份报告的反方审稿概况 {counts, items, artifact, on_refuted}:审它的、已经审完的反方审稿节点给的。 - 没有(没加反方审稿、还没审到、直通了)返回 None,审批卡不加这个键。几个反方审稿节点审同一份报告时取画布上 - 靠后的那个。只看图和状态:重放时算出来的一样。""" + """审批卡上这份报告的反方审稿概况 {counts, items, artifact, on_refuted, unreviewed, degrades}:审它的、已经审完的 + 反方审稿节点给的。没有(没加反方审稿、还没审到、直通了)返回 None,审批卡不加这个键。几个反方审稿节点审同一份报告 + 时取画布上靠后的那个。只看图和状态:重放时算出来的一样。unreviewed、degrades 是期 4 加的:升级前的产出没有, + 概况也不带(审批卡按旧规则说)。""" found: dict[str, Any] | None = None for node in spec.nodes: result = outputs.get(node.id) @@ -713,8 +731,12 @@ def challenge_summary(spec: GraphSpec, outputs: dict[str, Any], report_id: str) continue found = {"counts": dict(result["counts"]), "items": list(result.get("items") or [])[:ITEMS_MAX], "artifact": result.get("challenge_artifact"), "on_refuted": result.get("on_refuted") or "degrade"} + if isinstance(result.get("unreviewed"), int) and not isinstance(result["unreviewed"], bool): + found["unreviewed"] = result["unreviewed"] + if isinstance(result.get("degrades"), bool): + found["degrades"] = result["degrades"] return found -__all__ = ["CHALLENGE_SYSTEM", "ITEMS_MAX", "VERDICTS", "challenge_summary", "claim_sources", "mark_original", - "pick_claims", "pick_items", "run_challenge", "with_note"] +__all__ = ["CHALLENGE_SYSTEM", "ITEMS_MAX", "VERDICTS", "challenge_summary", "claim_sources", "feeds_issuance", + "mark_original", "pick_claims", "pick_items", "run_challenge", "with_note"] diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 62c35895..8765767f 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -276,6 +276,8 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): {"unit": south, "text": SOUTH, "verdict": "refuted"}] assert out["on_refuted"] == "degrade" assert out["issuance_gaps"] == [f"推翻了 1 句结论:「{SOUTH}」"] + # 两句都真查过了;接在带出具契约的成果节点上游、按降档处置:被推翻的真的会让出具降档 + assert out["unreviewed"] == 0 and out["degrades"] is True record = artifact_store.load(out["challenge_artifact"]) assert set(record) == {"report", "doc_artifact", "claims", "model"} @@ -337,6 +339,7 @@ async def test_flag_only_records_and_fail_stops_the_run(monkeypatch, shop): assert row.output["_issuance"]["tier"] == "formal" and row.output["_issuance"]["gaps"] == [] out = await output_of(row.id, "chal") assert out["on_refuted"] == "flag" and "issuance_gaps" not in out and out["counts"]["refuted"] == 1 + assert out["degrades"] is False, "只标注:被推翻的不会让出具降档" assert len(await logs(row.id, "challenge_refuted")) == 1, "flag 也照实记一条" Models(monkeypatch, shop) @@ -408,6 +411,7 @@ async def test_a_verdict_without_any_query_is_not_taken(monkeypatch, shop): assert east["verdict"] == "inconclusive" and "「经受住」" in east["rationale"] [warn] = await logs(row.id, "challenge_unreviewed") assert warn["claims_skipped"] == 2 and "没有执行成功任何查询" in warn["reason"] + assert out["unreviewed"] == 2, "一条查询都没执行成功:两句都是没能反查,不是查过了判断不了" async def test_a_refutation_that_cites_none_of_its_queries_is_not_taken(monkeypatch, shop): @@ -425,6 +429,7 @@ async def test_a_refutation_that_cites_none_of_its_queries_is_not_taken(monkeypa assert "没有引用" in south["rationale"] and "扣除退款后华南仍低于华东。" in south["rationale"] assert east["verdict"] == "stands", "「经受住」不要求引用:它不改变出具" assert await logs(row.id, "challenge_unreviewed") == [], "查过了,不算没能反查" + assert out["unreviewed"] == 0 @pytest.mark.parametrize("queries, said, quoted", [ @@ -470,6 +475,7 @@ async def test_claims_whose_sources_are_gone_are_not_sent_out(monkeypatch, shop) assert all(f"「{shop}」" in c["rationale"] and "不存在或已停用" in c["rationale"] for c in claims) [warn] = await logs(row.id, "challenge_unreviewed") assert warn["claims_skipped"] == 2 and f"「{shop}」" in warn["reason"] + assert out["unreviewed"] == 2 async def test_a_claim_with_part_of_its_sources_gone_says_so(monkeypatch, tmp_path, shop): @@ -665,6 +671,8 @@ async def test_the_time_limit_applies_to_each_claim_and_still_settles(monkeypatc assert south["verdict"] == "inconclusive" and 1 <= len(south["queries"]) < 20 limits = await logs(row.id, "challenge_limit") assert [(lm["limit"], lm["claims_skipped"]) for lm in limits] == [("timeout_s", 0)] + # 触顶但按查到的结果收了尾的那句,判断是反方自己给的,不算没能反查;另一句一条查询都没执行,算 + assert (await output_of(row.id, "chal"))["unreviewed"] == 1 async def test_the_daily_cap_stops_the_remaining_claims(monkeypatch, shop): @@ -684,6 +692,7 @@ async def test_the_daily_cap_stops_the_remaining_claims(monkeypatch, shop): "第一句派出去就撞上上限、一次模型都没调用,和后面不再派出的句子是同一回事,同一种说法" [limit] = await logs(row.id, "challenge_limit") assert limit["limit"] == "daily_max_usd" and limit["claims_skipped"] == 2 + assert out["unreviewed"] == 2, "撞上每日金额上限没反查的,也是没能反查" assert len(await logs(row.id, "verify_limit")) == 1, "第一句撞上每日上限之后,后面的句子不再派 agent 去试" assert row.output["_issuance"]["tier"] == "formal" @@ -725,13 +734,45 @@ async def test_the_approval_card_carries_the_challenge_summary(monkeypatch, shop out = await output_of(row.id, "chal") assert report["node_id"] == "write" assert report["challenge"] == {"counts": out["counts"], "items": out["items"], - "artifact": out["challenge_artifact"], "on_refuted": "degrade"} + "artifact": out["challenge_artifact"], "on_refuted": "degrade", + "unreviewed": 0, "degrades": False} assert report["challenge"]["counts"]["refuted"] == 1 # human.requested 事件里也带着:回放时界面一样读得到 [requested] = await events(row.id, "human.requested", "gate") assert requested.data["reports"] == approval.payload["reports"] +async def test_the_approval_card_says_whether_refutations_degrade(monkeypatch, shop): + """审批卡上「出具会降档」只在真的会降档时写:degrades 按「按降档处置、又接在带出具契约的成果节点上游」算。 + 审批之后的成果节点带了出具契约:会降档;没带(gated 的缺省):不会,见上一个用例""" + Models(monkeypatch, shop) + spec = gated(shop) + next(n for n in spec["nodes"] if n["id"] == "out")["data"]["config"]["contract"] = {"report_from": "write"} + _, approval = await paused(spec) + challenge = approval.payload["reports"][0]["challenge"] + assert challenge["degrades"] is True and challenge["on_refuted"] == "degrade" + + +async def test_a_challenge_off_the_issuance_path_does_not_degrade(monkeypatch, shop): + """反方审稿接在侧枝上(成果节点的出具契约收不到它的缺口):按降档处置也降不了档,产出照实写 degrades 为假""" + Models(monkeypatch, shop) + spec = chain( + node("start", "input"), + node("fetch", "tool", tool=f"db_query__{shop}", args={"sql": QUERY}), + node("write", "report", "写周报", instructions="写周报"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write"}), + ) + spec["nodes"].append(node("chal", "challenge", "审稿", report="write")) + spec["edges"].append({"source": "write", "target": "chal"}) + row = await finish(spec) + assert row.status == "succeeded", row.error + out = await output_of(row.id, "chal") + assert out["on_refuted"] == "degrade" and out["counts"]["refuted"] == 1 and out["issuance_gaps"] + assert out["degrades"] is False + assert row.output["_issuance"]["gaps"] == [], "缺口确实没进出具:和 degrades 说的一致" + + async def test_the_approval_card_shows_what_flag_means(monkeypatch, shop): Models(monkeypatch, shop) _, approval = await paused(gated(shop, on_refuted="flag")) @@ -782,8 +823,11 @@ def test_the_card_only_carries_the_review_of_that_report(): items = [{"unit": "u0", "text": "周报甲的一句结论。", "verdict": "stands"}] outputs = {"chal": {"challenge_artifact": "a1", "counts": counts, "items": items, "on_refuted": "degrade"}} assert challenge_mod.challenge_summary(spec, outputs, "a") == { - "counts": counts, "items": items, "artifact": "a1", "on_refuted": "degrade"} + "counts": counts, "items": items, "artifact": "a1", "on_refuted": "degrade"}, "老产出没有这两个键:概况也不带" assert challenge_mod.challenge_summary(spec, outputs, "b") is None + outputs["chal"].update(unreviewed=1, degrades=True) + assert challenge_mod.challenge_summary(spec, outputs, "a") == { + "counts": counts, "items": items, "artifact": "a1", "on_refuted": "degrade", "unreviewed": 1, "degrades": True} # 直接连着的报告,和直接连着的反方审稿审的那份,都算这个审批节点审的 assert reviewed_report_ids(spec, spec.node_map()["gate"]) == ["a", "b"] @@ -970,6 +1014,7 @@ async def test_an_unavailable_model_leaves_the_claims_inconclusive(monkeypatch, # 时间线上看得出根本没审成:不是只有一条「审了 2 句」 [warn] = await logs(row.id, "challenge_unreviewed") assert warn["level"] == "warn" and warn["claims_skipped"] == 2 and "核查模型不可用" in warn["reason"] + assert out["unreviewed"] == 2 [done] = await logs(row.id, "challenge_done") assert "2 句未能反查" in done["message"] From 9eeb11d29eb0330394d4bfecf94464d017897981 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:56:04 +0800 Subject: [PATCH 06/13] =?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=E7=9A=84=E9=A2=84=E5=91=8A=E5=92=8C?= =?UTF-8?q?=E7=9C=9F=E5=86=99=E9=80=90=E5=AD=97=E4=B8=80=E8=87=B4=EF=BC=9A?= =?UTF-8?q?=E8=A2=AB=E7=9B=AE=E5=BD=95=E6=8C=A1=E4=B8=8B=EF=BC=88=E5=B7=B2?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E3=80=81=E9=A9=B3=E5=9B=9E=E8=BF=87=E3=80=81?= =?UTF-8?q?=E5=92=8C=E5=B7=B2=E6=9C=89=E6=9D=A1=E4=BB=B6=E5=90=88=E4=B8=8D?= =?UTF-8?q?=E8=B5=B7=E6=9D=A5=EF=BC=89=E7=9A=84=E9=82=A3=E4=B8=80=E8=BE=B9?= =?UTF-8?q?=E4=B9=9F=E5=B8=A6=E4=B8=8A=E5=86=99=E5=88=B0=E5=93=AA=E5=BC=A0?= =?UTF-8?q?=E8=A1=A8=E5=93=AA=E4=B8=80=E9=A1=B9=E3=80=81=E6=9C=AC=E6=9D=A5?= =?UTF-8?q?=E8=A6=81=E5=86=99=E4=BB=80=E4=B9=88=E5=92=8C=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=EF=BC=9B=E5=87=A0=E4=B8=AA=E6=8C=87=E6=A0=87=E8=90=BD=E5=88=B0?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E9=A1=B9=E4=B8=8A=E6=97=B6=EF=BC=8Cpreview?= =?UTF-8?q?=5Fjoint=20=E6=8C=89=E7=9C=9F=E5=86=99=E7=9A=84=E5=8A=9E?= =?UTF-8?q?=E6=B3=95=EF=BC=88=E5=BD=92=E6=8B=A2=E3=80=81=E6=8B=BF=E6=8E=89?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E7=9A=84=E3=80=81=E5=AF=B9=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=EF=BC=89=E6=8A=8A=E6=AF=8F=E7=A7=8D=E9=80=89=E6=8B=A9=E7=BB=84?= =?UTF-8?q?=E5=90=88=E7=AE=97=E4=B8=80=E9=81=8D=E6=94=BE=E8=BF=9B=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=B7=A5=E4=BB=B6=E7=9A=84=20catalog=5Fjoint=EF=BC=8C?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=A4=AA=E5=A4=9A=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E4=B8=8D=E9=A2=84=E5=91=8A=EF=BC=9B=E7=9C=9F=E5=86=99?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=20written=20=E5=B8=A6=E4=B8=8A?= =?UTF-8?q?=E5=86=99=E8=BF=9B=E5=8E=BB=E7=9A=84=E5=80=BC=E3=80=82=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=B7=A5=E4=BB=B6=E9=87=8C=E7=9B=B2=E7=AE=97=E7=9A=84?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8F=A6=E5=B8=A6=20rationale=5Fdoc=EF=BC=88?= =?UTF-8?q?=E6=A0=B8=E6=9F=A5=20agent=20=E7=BB=84=E8=A3=85=E7=9A=84?= =?UTF-8?q?=E7=90=86=E7=94=B1=E6=96=87=E6=A1=A3=EF=BC=8C=E8=A3=B8=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E5=B7=B2=E6=A0=87=E5=87=BA=EF=BC=89=EF=BC=9B=E8=A1=A5?= =?UTF-8?q?=E5=9B=BE=E7=BA=A7=E6=B5=8B=E8=AF=95=EF=BC=8C=E6=8C=89=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=A1=E7=9A=84=E8=AE=A4=E6=B3=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=A2=84=E5=91=8A=E4=B8=8E=E5=AE=9E=E9=99=85=E5=86=99=E5=85=A5?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/dual_check_facts.py | 153 +++++++++++++++++----- backend/app/engine/nodes/dual_check.py | 17 ++- backend/tests/fixtures/dual_check_shop.py | 6 +- backend/tests/test_dual_check_facts.py | 143 ++++++++++++++++++-- backend/tests/test_dual_check_node.py | 110 +++++++++++++++- 5 files changed, 373 insertions(+), 56 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 6bde1842..4f101d61 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -36,10 +36,11 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不 也不写。 **预告。** 审批卡上要让审核人看得出选了之后会往目录里写什么(preview):按发起审批时的目录,选原流程、选盲算各会 -写什么、不写的为什么。和真写是同一套判断(derive 认事实、_plan 对目录、_revives 认驳回),所以只在两处可能和最后写 -的不一样:审批停下期间目录被人改了;同一次裁决里几个指标落到同一项上(上一段)——写进去的是合起来的说法,预告说会 -写的也可能因为说法互相矛盾、和已有条件的关系拿不准、合起来又成了驳回过的说法而不写。反过来不会:预告说不写的, -不会因为和别的指标并成一项被写进去。 +写什么、不写的为什么。认出了事实、只是被目录挡下(已确认、驳回过、和已有条件合不起来)的那一边也带上写到哪张表的 +哪一项、本来要写什么,卡上才认得出它和别的指标是不是落到同一项上。同一次裁决里几个指标落到同一项上时,写进去的是 +合起来的说法(上一段),单个指标的预告说不准:preview_joint 把这几个指标各自选哪一边的每种组合都按真写的办法算一遍 +(_merged 归拢、_screen 拿掉驳回的、_plan 对目录),卡上按各指标当前的选择取对应的那一种。和真写是同一套判断、同一句 +原因,只有审批停下期间目录被人改了时才会和最后写的不一样。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。 catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。 """ @@ -73,6 +74,9 @@ ASPECT_LABEL = { _VERB = {"count": "计数", "sum": "求和", "avg": "求平均"} #: 写入时版本对不上(别人刚改过这张表)最多重试几次 _RETRIES = 2 +#: 同一次裁决里几个指标落到同一项上时,最多预告多少种组合(preview_joint)。每个指标两边都落在这一项上时是 +#: 3 的指标数次方:四个指标 72 种还在里面,再多的那一项不预告,审批卡回到按其余指标当前的选择有条件地说 +JOINT_LIMIT = 128 #: 无效记录的取值:被排除的取值(或码值表里它的意思)里有这些词,才算在排除无效记录 _INVALID_WORDS = frozenset({ @@ -94,7 +98,7 @@ class Fact: """要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明;sql、dialect 是被采纳 那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。同一次裁决里几个指标落到同一项上 时并成一项:members 是并进来的每个指标各自的那一项(各带自己的说法,审批卡上各自的预告就是按它算的),value 是 - 合起来之后的说法;单个指标的 members 为空。""" + 合起来之后的说法;单个指标的 members 为空。metric_id 是指标 ID:几个指标并成一项时,预告按它说清每个指标的结果。""" source: str table: str @@ -105,6 +109,7 @@ class Fact: sql: str = "" dialect: str | None = None members: tuple[Fact, ...] = () + metric_id: str = "" def _members(fact: Fact) -> tuple[Fact, ...]: @@ -160,7 +165,8 @@ def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None, dialect = candidate.get("dialect") return Fact(source=str(candidate.get("source") or ""), table=resolved, item=key, value=value, metric=name, choice=str(side), sql=str(sql.get(side) or ""), - dialect=dialect if isinstance(dialect, str) else None), None + dialect=dialect if isinstance(dialect, str) else None, + metric_id=str(candidate.get("metric_id") or "")), None def _business_date(item: Mapping[str, Any], side: str) -> tuple[tuple[str, str, Any] | None, str | None]: @@ -631,33 +637,35 @@ def _rejected_text(fact: Fact, rejected: Any, members: Sequence[Fact]) -> str: "的选择相反,未写入。如需更改,请到数据目录中修改") -def _screen(notes: Mapping[str, Any], fact: Fact) -> tuple[Fact | None, list[str]]: - """几个指标并成的一项,先拿掉自己采纳的说法曾被人工驳回的指标:(剩下的重新并成的一项, 拿掉的为什么不写),一个 - 都不剩时前者为 None。单个指标的、这一项没被驳回过的原样返回,由 _plan 照常判断。 +def _screen(notes: Mapping[str, Any], fact: Fact) -> tuple[Fact | None, list[Fact], list[str]]: + """几个指标并成的一项,先拿掉自己采纳的说法曾被人工驳回的指标:(剩下的重新并成的一项, 拿掉的指标, 没写的原因), + 一个都不剩、或者剩下的又合不起来时前者为 None;原因先是拿掉的那几个的(一条),剩下的合不起来时再跟一条。单个指标 + 的、这一项没被驳回过的原样返回,由 _plan 照常判断。 审批卡上每个指标的预告是按它自己的说法单独算的(preview → _plan),在那里被驳回检查拦下、卡上写着「不写」的, 并进来以后也得拦下。只拿合起来的整句去比,「status <> 'refunded'」和别的条件 AND 在一起就不再等于驳回的那句, 会被写成人工确认,和卡上说的正相反。拿掉的指标照实说原因(和它的预告同一句话),其余的照写,也和各自的预告一致。""" rejected, said = _rejected(notes, fact.item) if not rejected or not fact.members: - return fact, [] + 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, [] + return fact, [], [] reasons = [_rejected_text(fact, said, out)] if not kept: - return None, reasons + return None, out, reasons narrowed, why = _merged(kept) - return narrowed, reasons if why is None else [*reasons, why] + return narrowed, out, 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 里照实说。 + """把审核人的选择写进数据目录:{written: [{source, table, item, value}], skipped: [{reason}]}。value 是真写进去的 + 说法(和已有条件合起来的就是合起来的整句),审批卡上的预告说「会记为」的就是它。永远不抛:数据源没了、目录写不 + 进去,都落在 skipped 里照实说。 先把每个候选认成目录项,再按(数据源、表、项)归拢成一项(_merged:说法一样的、有效记录条件合起来写,说法 不同的整组不写),逐项写(_write:按读到的目录先拿掉各自带回驳回说法的指标,其余的照写)。""" @@ -690,24 +698,25 @@ async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, no if fact is None: skipped.append({"reason": why}) continue - 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}) + value, reasons = await _record(fact, run_id=run_id, node_title=node_title, actor=actor) + if value is not None: + written.append({"source": fact.source, "table": fact.table, "item": fact.item, "value": value}) 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, list[str]]: - """写一项:(写了没有, 没写的原因)。几个指标并成的一项可能写了、同时有几个指标被拿出来没写,原因照样交回。""" +async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[Any, list[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}」已不存在,指标{_names(_members(fact))}的选择未写入数据目录"] + return None, [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(_members(fact))}的选择写入数据目录时出错,未写入"] + return None, [f"指标{_names(_members(fact))}的选择写入数据目录时出错,未写入"] def _plan(notes: Mapping[str, Any], fact: Fact, tables: Mapping[str, Any] | None) -> tuple[Any, str | None]: @@ -767,7 +776,9 @@ def preview(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None candidate 同 derive(choice 不用给);notes 是这个数据源的目录({表名: notes})。返回 {a: …, b: …},每边是 {write: true, source, table, item, value}(value 是真要写进去的说法:和目录里已有的有效记录条件合起来的,是合起来 - 之后的整句)或 {write: false, reason}。和恢复后真写是同一套判断,只在模块说明里说的两处可能不一样。永远不抛。""" + 之后的整句)或 {write: false, reason};认出了事实、只是被目录挡下的不写,照样带上 source、table、item 和本来要写 + 的 value(这个指标自己的说法)——卡上据此认出几个指标落到同一项上。这个指标单独落在这一项上时,reason 和真写时 + 跳过的原因一字不差;和别的指标落到同一项上时看 preview_joint。永远不抛。""" notes = notes or {} tables = schema_cache.get("tables") if isinstance(schema_cache, Mapping) else None out: dict[str, dict[str, Any]] = {} @@ -780,15 +791,87 @@ def preview(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None 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} + if fact is None: + out[side] = {"write": False, "reason": why} + continue + where = {"source": fact.source, "table": fact.table, "item": fact.item} + out[side] = {"write": True, **where, "value": value} if why is None else \ + {"write": False, **where, "value": fact.value, "reason": why} + return out + + +def _joint_outcome(notes: Mapping[str, Any], members: list[Fact], tables: Mapping[str, Any] | None + ) -> dict[str, dict[str, Any]]: + """几个指标落到同一项上、各按自己的选择并成一项时,每个指标的结果:{指标 ID: {write: true, value} 或 {write: false, + reason}}。和 record_choices 逐步一样:_merged 归拢(说法不同的整组不写),_screen 拿掉带回驳回说法的(它们说驳回 + 那一句,剩下的合不起来时说合不起来的那一句),剩下的按 _plan 对目录(写的是同一个值,不写的是同一句原因)。""" + fact, why = _merged(members) + if fact is None: + return {m.metric_id: {"write": False, "reason": why} for m in members} + kept, out, reasons = _screen(notes, fact) + result = {m.metric_id: {"write": False, "reason": reasons[0]} for m in out} + rest = [m for m in members if m.metric_id not in result] + if kept is None: + result.update({m.metric_id: {"write": False, "reason": reasons[-1]} for m in rest}) + return result + value, why = _plan(notes, kept, tables) + result.update({m.metric_id: {"write": False, "reason": why} if why is not None else {"write": True, "value": value} + for m in rest}) + return result + + +def preview_joint(candidates: Sequence[Mapping[str, Any]], + catalogs: Mapping[str, tuple[Mapping[str, Any] | None, Mapping[str, Mapping[str, Any]]]] + ) -> list[dict[str, Any]]: + """同一次裁决里几个指标落到同一项上时的预告:[{source, table, item, members: [{metric_id, choice}], outcomes: + {指标 ID: {write: true, value} 或 {write: false, reason}}}],每条是一种组合(members 是按各自的选择落到这一项上的 + 指标,照候选的先后,至少两个)。 + + candidates 照节点交给真写的先后(不一致的指标的先后),带 metric_id,不带 choice;catalogs 是 {数据源名: (表结构, + {表名: notes})},同 preview 的两个参数。每个指标的两边先各按 derive 认出落到哪一项(同 preview),落到同一项上的 + 几个指标,每种「谁选了哪一边」的组合按 _joint_outcome 算一遍:真写时这几个指标就是这样被归拢、写或不写的。组合超过 + JOINT_LIMIT 的那一项整项不预告。永远不抛:算不出来的那一项不预告。""" + from itertools import product + + landed: dict[tuple[str, str, str], dict[int, list[tuple[str, Fact]]]] = {} + for index, candidate in enumerate(candidates): + schema, notes = catalogs.get(str(candidate.get("source") or ""), (None, {})) + for side in SIDE_LABEL: + try: + fact, _ = derive({**candidate, "choice": side}, schema, notes) + except Exception: # noqa: BLE001 - 认不出就当这一边落不到任何一项上,同 preview 照实不说 + fact = None + if fact is not None: + landed.setdefault((fact.source, fact.table, fact.item), {}).setdefault(index, []).append((side, fact)) + out: list[dict[str, Any]] = [] + budget = JOINT_LIMIT + for (source, table, item), by_metric in landed.items(): + if len(by_metric) < 2: + continue + options = [[None, *sides] for _, sides in sorted(by_metric.items())] + combos = [[c for c in combo if c is not None] for combo in product(*options)] + combos = [c for c in combos if len(c) >= 2] + if len(combos) > budget: + continue + budget -= len(combos) + schema, notes = catalogs.get(source, (None, {})) + tables = schema.get("tables") if isinstance(schema, Mapping) else None + try: + entries = [{"source": source, "table": table, "item": item, + "members": [{"metric_id": fact.metric_id, "choice": side} for side, fact in combo], + "outcomes": _joint_outcome((notes or {}).get(table) or {}, [fact for _, fact in combo], tables)} + for combo in combos] + except Exception: # noqa: BLE001 - 预告算不出来不能拦住审批:这一项不预告,卡上有条件地说 + logger.exception("双盲复算预告几个指标落到同一项上的写入失败(%s 的 %s)", table, item) + continue + out.extend(entries) return out async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[bool, list[str]]: - """写一项:(写了没有, 没写的原因)。几个指标并成的一项先按读到的目录拿掉各自带回驳回说法的指标(_screen),写什么、 - 写不写再由 _plan 定;备注只列真写进去的指标。版本对不上(别人刚改过)重读再来。""" + """写一项:(写进去的说法,没写为 None, 没写的原因)。几个指标并成的一项先按读到的目录拿掉各自带回驳回说法的指标 + (_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] = [] @@ -796,19 +879,19 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node entry = await catalog.read_entry(session, row.id, fact.table) notes = entry.notes if entry else {} version = entry.version if entry else 0 - kept, reasons = _screen(notes, fact) + kept, _, reasons = _screen(notes, fact) if kept is None: - return False, reasons + return None, reasons value, why = _plan(notes, kept, tables) if why is not None: - return False, [*reasons, why] + return None, [*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": kept.item, "value": value, "reason": note}], table=kept.table, tables=tables, at=at) except catalog.CatalogInvalid as e: - return False, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] + return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处 merged[kept.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at) try: @@ -817,9 +900,9 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node await session.rollback() continue except catalog.CatalogInvalid as e: - return False, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] - return True, reasons - return False, [*reasons, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认"] + return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] + return value, reasons + return None, [*reasons, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认"] -__all__ = ["FACT_ITEMS", "Fact", "derive", "note_of", "preview", "record_choices"] +__all__ = ["FACT_ITEMS", "JOINT_LIMIT", "Fact", "derive", "note_of", "preview", "preview_joint", "record_choices"] diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index b1c47136..94a32f0f 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -21,7 +21,8 @@ SQL,就跟着差异说明进了审批卡、对照工件和目录日志。完 缺口;b、neither、没选的写进产出的 issuance_gaps,成果节点的出具契约据此降档(on_reject 为 fail 时节点直接失败)。 两边只差一个方面、又对得上数据目录事实的,选择写进数据目录(engine/dual_check_facts.py,指标自己的筛选范围不写)。 选了会怎样要在卡上看得出:payload 在约定的几项之外带上实际生效的 on_reject,摘要里每个指标带 catalog——选 a、选 b -按发起审批时的目录各会往目录里写什么、不写的为什么(dual_check_facts.preview,和真写同一套判断)。 +按发起审批时的目录各会往目录里写什么、不写的为什么(dual_check_facts.preview,和真写同一套判断);几个指标落到目录 +同一项上时每种选择组合合起来会怎样写(preview_joint),放在对照工件的 catalog_joint 里。 盲算触顶、出错、交不出数,或者输入本身没法单独复算的指标按「无法复算」记缺口,发 dual_check_unchecked。 **产出的状态。** 全部一致是 agreed;没有不一致、但有无法复算的:一个都没核成是 unchecked,核成了一部分是 partial; @@ -57,7 +58,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.dual_check_facts import preview, preview_joint, 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 @@ -807,6 +808,8 @@ def _slim(outcome: Any) -> dict[str, Any]: "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 "", + # 理由组装成的文档(evidence.compose_doc):数字有出处的挂着盲算自己查询的格子,裸数字已标出。审批卡照它画理由 + "rationale_doc": outcome.rationale if isinstance(outcome.rationale, dict) else None, "queries": [{k: q.get(k) for k in ("alias", "artifact", "sql", "row_count", "truncated", "source")} for q in outcome.queries], "notes": list(outcome.notes), @@ -966,7 +969,8 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], {"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 ""} + "rationale": (results.get(i["key"]) or {}).get("rationale") or "", + "rationale_doc": (results.get(i["key"]) or {}).get("rationale_doc")} for i in row["inputs"]]}, "diff": {"equivalent": merged["equivalent"], "items": [{"aspect": i.get("aspect"), "text": i.get("text")} for i in merged["items"]], @@ -991,6 +995,13 @@ async def _review(state: GraphState, ctx: NodeContext, checked: dict[str, Any], "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"))} + # 同一次裁决里几个指标落到数据目录的同一项上时,按各自的选择合起来写(dual_check_facts._merged):每个指标单独的 + # 预告说不准,把每种组合的结果都算好放进对照工件(payload 里放不下,审批卡打开时就取工件),卡上按当前的选择取 + catalogs = {name: (info.get("schema"), info.get("notes") or {}) for name, info in infos.items()} + joint = await asyncio.to_thread(preview_joint, [facts[r["metric_id"]] for r in rows if r["metric_id"] in facts], + catalogs) + if joint: + content["catalog_joint"] = joint 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)}) diff --git a/backend/tests/fixtures/dual_check_shop.py b/backend/tests/fixtures/dual_check_shop.py index 7ac6bff7..565ca784 100644 --- a/backend/tests/fixtures/dual_check_shop.py +++ b/backend/tests/fixtures/dual_check_shop.py @@ -87,10 +87,11 @@ class Blind: 没对上指标名的盲算交 null。不是盲算的调用(报告撰写)返回 writer 的正文并记下收到的消息。 calls 记下每次盲算调用(step / final),tasks 记下盲算收到的任务原文(几个盲算同时进行时也是每个任务一条)。 on_query:盲算第一次查库之前调用一次(比如往库里插一行,造出「两次取数之间数据变了」)。 + rationale:盲算交的理由,{ref} 换成交的那一格的引用(缺省「按口径说明统计,结果是 {ref}。」)。 """ def __init__(self, monkeypatch, source: str, plans: dict, *, writer: str = "本周销售额见口径卡。", row: int = 0, - on_query=None): + on_query=None, rationale: str = "按口径说明统计,结果是 {ref}。"): from app.providers import mock_model self.source = source @@ -98,6 +99,7 @@ class Blind: self.writer = writer self.row = row self.on_query = on_query + self.rationale = rationale self.calls: list[str] = [] self.tasks: list[str] = [] self.writer_prompts: list[str] = [] @@ -114,7 +116,7 @@ class Blind: _, 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}]]。"} + "rationale": blind.rationale.format(ref=f"[[v:{alias}.r{blind.row}.{column}]]")} return AIMessage(content=json.dumps(body, ensure_ascii=False)) if model.tools: blind.calls.append("step") diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 6f8067f8..9c6299ae 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -252,7 +252,8 @@ async def test_a_choice_is_written_as_a_confirmed_human_item_with_its_origin(sho 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": []} + assert out == {"written": [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "paid_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") @@ -290,7 +291,8 @@ async def test_an_inferred_condition_both_sides_use_is_extended(shop): 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 + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter", + "value": "is_test = 0 AND status <> 'refunded'"}], 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") @@ -327,7 +329,8 @@ async def test_an_inferred_same_condition_is_confirmed(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"}] + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter", + "value": "status <> 'refunded'"}] item = (await entry_of(source_id)).notes["valid_filter"] assert item["value"] == "status <> 'refunded'" and (item["source"], item["status"]) == ("human", "confirmed") @@ -348,7 +351,8 @@ async def test_the_same_column_inferred_is_confirmed_and_keeps_its_rule(shop): {"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"}] + assert out["written"] == [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "paid_date", "rule": "按付款成功时间", "timezone": "Asia/Shanghai"}}] 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") @@ -365,7 +369,8 @@ async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_do 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 out["written"] == [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "order_date"}}] assert (await entry_of(source_id)).notes["business_date"]["value"] == {"column": "order_date"} @@ -394,7 +399,8 @@ async def test_complementary_valid_filters_in_one_decision_are_combined(shop): # 第三个指标采纳的是和第一个一样的条件:合并时不重复 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": []} + assert out == {"written": [{"source": name, "table": "orders", "item": "valid_filter", + "value": "status <> 'refunded' AND is_test = 0"}], "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 @@ -410,7 +416,8 @@ async def test_combined_filters_extend_an_inferred_condition_every_adopted_side_ 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 out["written"] == [{"source": name, "table": "orders", "item": "valid_filter", + "value": "channel <> 'test' AND status <> 'refunded' AND is_test = 0"}], out assert (await entry_of(source_id)).notes["valid_filter"]["value"] == \ "channel <> 'test' AND status <> 'refunded' AND is_test = 0" @@ -437,7 +444,8 @@ async def test_a_rejected_condition_is_not_written_back_through_a_combined_decis 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 + assert out["written"] == [{"source": name, "table": "orders", "item": "valid_filter", + "value": "is_test = 0"}], out [skipped] = out["skipped"] assert "「status <> 'refunded'」曾被人工驳回" in skipped["reason"] and "「销售额」、「订单数」" in skipped["reason"] assert "订单金额" not in skipped["reason"] @@ -498,7 +506,8 @@ async def test_combined_filters_do_not_repeat_a_condition_already_in_the_catalog 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": []} + assert out == {"written": [{"source": name, "table": "orders", "item": "valid_filter", + "value": "status <> 'refunded' AND is_test = 0"}], "skipped": []} item = (await entry_of(source_id)).notes["valid_filter"] assert item["value"] == said["b"]["value"] == "status <> 'refunded' AND is_test = 0" @@ -532,7 +541,8 @@ async def test_the_same_choice_from_two_metrics_is_written_once(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": []} + assert out == {"written": [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "paid_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"] @@ -579,3 +589,116 @@ async def test_a_missing_source_or_an_unrecordable_choice_is_reported(shop): assert out["written"] == [] assert "数据源「no_such_source」已不存在" in out["skipped"][0]["reason"] assert "不止差一个方面" in out["skipped"][1]["reason"] + + +# -------------------------------------------------------------------------- +# 预告和真写逐字一致:挡下的一边也说写到哪、为什么;几个指标落到同一项上按合并后的结果预告 +# -------------------------------------------------------------------------- + + +def test_a_side_the_catalog_blocks_still_says_where_and_why(): + """认出了事实、只是被目录挡下(已确认、驳回过、和已有条件合不起来)的一边:照样带上写到哪张表的哪一项、本来要写 + 什么,原因和真写时跳过的原因一字不差。认不出事实的一边只有原因""" + confirmed = {"orders": {"business_date": catalog.make_item({"column": "order_date"}, "human", "confirmed")}} + said = preview(candidate(BY_ORDER_DATE, RIGHT, "a"), SCHEMA, confirmed) + target = {"source": "shop", "table": "orders", "item": "business_date"} + assert said["a"] == {"write": False, **target, "value": {"column": "order_date"}, + "reason": "「orders」的业务日期已是人工确认过的「order_date」,无需重复写入"} + assert said["b"]["write"] is False and {k: said["b"][k] for k in target} == target + assert said["b"]["value"] == {"column": "paid_date"} and "已有人工确认过的说法「order_date」" in said["b"]["reason"] + # 认不出事实(采纳的一边没有这个条件):没有写到哪,只有原因 + said = preview(candidate(WITH_REFUNDS, RIGHT, "a"), SCHEMA, {}) + assert set(said["a"]) == {"write", "reason"} and said["a"]["write"] is False + + +def test_the_joint_preview_covers_every_way_two_metrics_land_on_one_item(): + """两个指标都只差时间列:各自选原流程是 order_date、选盲算是 paid_date,落到同一张表的业务日期上。四种组合各预告 + 一次:说法一样的写,说法不同的两个都不写,原因是真写时那一句""" + from app.engine.dual_check_facts import preview_joint + + picked = [{**candidate(BY_ORDER_DATE, RIGHT, "a", metric="销售额"), "metric_id": "gmv"}, + {**candidate(BY_ORDER_DATE, RIGHT, "a", metric="订单金额"), "metric_id": "total"}] + joint = preview_joint(picked, {"shop": (SCHEMA, {})}) + by = {tuple((m["metric_id"], m["choice"]) for m in e["members"]): e for e in joint} + assert set(by) == {(("gmv", a), ("total", b)) for a in "ab" for b in "ab"} + for entry in joint: + assert {k: entry[k] for k in ("source", "table", "item")} == \ + {"source": "shop", "table": "orders", "item": "business_date"} + assert by[(("gmv", "b"), ("total", "b"))]["outcomes"] == { + "gmv": {"write": True, "value": {"column": "paid_date"}}, + "total": {"write": True, "value": {"column": "paid_date"}}} + split = by[(("gmv", "a"), ("total", "b"))]["outcomes"] + assert split["gmv"] == split["total"] and split["gmv"]["write"] is False + assert "给出了不同的说法" in split["gmv"]["reason"] and "「销售额」" in split["gmv"]["reason"] + + +def test_the_joint_preview_screens_out_a_rejected_condition_like_the_write(): + """驳回过「status <> 'refunded'」:两个指标并成一项时,带回驳回说法的那个拿出来不写(原因同真写),另一个照写自己的""" + from app.engine.dual_check_facts import preview_joint + + notes = {"orders": {"valid_filter": catalog.make_item("status <> 'refunded'", "llm", "rejected")}} + picked = [{**candidate(SUM_WEEK, extra("status <> 'refunded'"), "b", metric="销售额"), "metric_id": "gmv"}, + {**candidate(SUM_WEEK, extra("is_test = 0"), "b", metric="订单金额"), "metric_id": "total"}] + [entry] = preview_joint(picked, {"shop": (SCHEMA, notes)}) + assert [(m["metric_id"], m["choice"]) for m in entry["members"]] == [("gmv", "b"), ("total", "b")] + assert entry["outcomes"]["total"] == {"write": True, "value": "is_test = 0"} + assert entry["outcomes"]["gmv"]["write"] is False and "曾被人工驳回" in entry["outcomes"]["gmv"]["reason"] + + +def test_too_many_combinations_are_left_to_the_timeline(): + """几个指标落到同一项上的组合太多(每个两边都落在这一项上,3 的指标数次方):这一项不预告,审批卡回到有条件地说""" + from app.engine import dual_check_facts as facts + + picked = [{**candidate(BY_ORDER_DATE, RIGHT, "a", metric=f"指标{i}"), "metric_id": f"m{i}"} for i in range(6)] + assert facts.preview_joint(picked, {"shop": (SCHEMA, {})}) == [] + assert len(facts.preview_joint(picked[:3], {"shop": (SCHEMA, {})})) == 3 ** 3 - 1 - 3 * 2 + + +@pytest.mark.parametrize("choices", [("a", "b"), ("b", "b"), ("b", "a"), ("a", None)]) +async def test_the_joint_preview_is_what_gets_written(shop, choices): + """预告和真写逐字一致:按这组选择,各指标的预告(单独落一项的看 preview,几个落到同一项的看 preview_joint)说写的, + 目录里就是那个值;说不写的,原因就在真写跳过的原因里""" + from app.engine.dual_check_facts import preview_joint + + 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}" + picked = [{**candidate(base, extra("status <> 'refunded'", base), "a", metric="销售额", source=name), + "metric_id": "gmv"}, + {**candidate(base, extra("channel <> 'test'", base), "a", metric="订单金额", source=name), + "metric_id": "total"}] + notes = {"orders": (await entry_of(source_id)).notes} + singles = {c["metric_id"]: preview(c, SCHEMA, notes) for c in picked} + joint = preview_joint(picked, {name: (SCHEMA, notes)}) + chosen = {c["metric_id"]: choice for c, choice in zip(picked, choices) if choice} + expected = said_for(chosen, singles, joint) + out = await record_choices([{**c, "choice": chosen[c["metric_id"]]} for c in picked if c["metric_id"] in chosen], + run_id="run-1", node_title="复核", actor=None) + entry = await entry_of(source_id) + for metric_id, said in expected.items(): + if said["write"]: + assert {"source": name, "table": "orders", "item": said["item"], "value": said["value"]} in out["written"] + assert entry.notes[said["item"]]["value"] == said["value"] + else: + assert said["reason"] in [s["reason"] for s in out["skipped"]], (metric_id, said, out) + + +def said_for(chosen: dict, singles: dict, joint: list) -> dict: + """审批卡的认法:一个指标选的那一边认出了写到哪,就看按当前的选择还有谁落到同一项上——只有它自己时照单个指标的 + 预告,几个时照 preview_joint 里这几个成员的那一条""" + out = {} + for metric_id, choice in chosen.items(): + side = singles[metric_id][choice] + if "table" not in side: + out[metric_id] = side + continue + where = (side["source"], side["table"], side["item"]) + peers = sorted((m, c) for m, c in chosen.items() + if "table" in singles[m][c] and (singles[m][c]["source"], singles[m][c]["table"], + singles[m][c]["item"]) == where) + if len(peers) == 1: + out[metric_id] = side + continue + [entry] = [e for e in joint if sorted((m["metric_id"], m["choice"]) for m in e["members"]) == peers] + out[metric_id] = {**entry["outcomes"][metric_id], "item": entry["item"]} + return out diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 9703144d..6ce5a013 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -401,9 +401,14 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, 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 set(mine) == {"path", "value", "sql", "source", "alias", "rationale", "rationale_doc"} assert (mine["sql"], mine["value"], mine["source"], mine["alias"]) == (RIGHT, 330.0, name, "Q1") assert mine["path"] == orig["path"] == "cell(nodes.fetch, 0, 'gmv')" and "330" in mine["rationale"] + # 理由组装成的文档:正文同 rationale,数字挂着盲算自己那次查询的格子 + doc = mine["rationale_doc"] + assert doc["markdown"] == mine["rationale"] and doc["violations"] == [] + [cited] = [seg for block in doc["blocks"] for unit in block["units"] for seg in unit["segments"] if seg.get("cite")] + assert cited["text"] == "330" and cited["cite"]["alias"] == "Q1" assert metric["original"]["value"] == 310.0 and metric["blind"]["value"] == 330.0 assert set(metric["diff"]) == {"equivalent", "items", "fallback", "text_diff"} assert metric["diff"]["equivalent"] is False and metric["diff"]["fallback"] is False @@ -416,6 +421,19 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, assert len([e for e in evs if e.type == "human.requested"]) == 1 +async def test_a_bare_number_in_the_blind_rationale_is_marked(monkeypatch, shop): + """盲算的理由里写了没有出处的数(「比上周多 20」):对照工件里的理由文档把它标成裸数字,审批卡据此醒目标出""" + name, _ = shop + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}, rationale="按口径说明统计,结果是 {ref},比上周多 20。") + run = await start(graph(name, BY_ORDER_DATE)) + card = artifact_store.load((await pending(run.id)).payload["card_artifact"]) + [mine] = card["metrics"][0]["blind"]["inputs"] + doc = mine["rationale_doc"] + bare = [seg["text"] for block in doc["blocks"] for unit in block["units"] for seg in unit["segments"] + if seg.get("issue") == "uncited_number"] + assert bare == ["20"] and mine["rationale"] == doc["markdown"] + + async def test_choosing_the_original_leaves_no_gap(monkeypatch, shop, client): name, source_id = shop Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) @@ -436,7 +454,8 @@ async def test_choosing_the_original_leaves_no_gap(monkeypatch, shop, client): 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"] == [] + assert wrote["written"] == [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "order_date"}}] and wrote["skipped"] == [] async def test_choosing_the_blind_result_degrades_and_writes_the_catalog(monkeypatch, shop, client): @@ -593,9 +612,12 @@ async def test_a_confirmed_catalog_entry_is_not_overwritten(monkeypatch, shop, c preview = (await pending(run.id)).payload["summary"][0]["catalog"] assert preview["a"]["write"] is False and "无需重复写入" in preview["a"]["reason"] assert preview["b"]["write"] is False and "已有人工确认过的说法「order_date」" in preview["b"]["reason"] + # 认出了事实、只是被目录挡下:照样说写到哪张表的哪一项、本来要写什么 + assert {k: preview["b"][k] for k in ("source", "table", "item", "value")} == \ + {"source": name, "table": "orders", "item": "business_date", "value": {"column": "paid_date"}} done = await decide(client, run.id, {"gmv": "b"}) [wrote] = logs(await events(done.id), "dual_check_catalog") - assert wrote["written"] == [] + assert wrote["written"] == [] and [s["reason"] for s in wrote["skipped"]] == [preview["b"]["reason"]] assert "已有人工确认过的说法「order_date」" in wrote["skipped"][0]["reason"] and "paid_date" in wrote["skipped"][0]["reason"] entry = await notes_of(source_id) assert entry.version == 1 and entry.notes["business_date"]["value"] == {"column": "order_date"} @@ -619,19 +641,95 @@ async def test_a_rejected_filter_is_not_written_back_along_with_another_metric(m 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"]} + payload = (await pending(run.id)).payload + said = {s["metric_id"]: s["catalog"]["b"] for s in payload["summary"]} assert said["gmv"]["write"] is False and "曾被人工驳回" in said["gmv"]["reason"] assert said["total"]["write"] is True and said["total"]["value"] == "is_test = 0" - done = await decide(client, run.id, {"gmv": "b", "total": "b"}) + # 两个指标都落到有效记录条件上:合起来的结果另有预告,和真写一致 + choices = {"gmv": "b", "total": "b"} + expected = card_said(payload, choices) + assert expected["gmv"]["write"] is False and expected["gmv"]["reason"] == said["gmv"]["reason"] + assert expected["total"] == {"write": True, "value": "is_test = 0", "item": "valid_filter"} + done = await decide(client, run.id, choices) assert done.status == "succeeded", done.error [wrote] = logs(await events(run.id), "dual_check_catalog") - assert wrote["written"] == [{"source": name, "table": "orders", "item": "valid_filter"}] + assert wrote["written"] == [{"source": name, "table": "orders", "item": "valid_filter", "value": "is_test = 0"}] assert [s["reason"] for s in wrote["skipped"]] == [said["gmv"]["reason"]] + await assert_written_as_said(expected, wrote, source_id) item = (await notes_of(source_id)).notes["valid_filter"] assert item["value"] == said["total"]["value"] and (item["source"], item["status"]) == ("human", "confirmed") assert "「订单金额」" in item["note"] and "「销售额」" not in item["note"] +def card_said(payload: dict, choices: dict) -> dict: + """审批卡按这组选择会怎么预告(DualCheckApproval 的认法):选的那一边认出了写到哪,就看按当前的选择还有哪些指标 + 落到同一项上——只有它自己时照摘要里它的预告,几个时照对照工件 catalog_joint 里这几个成员的那一条。""" + summary = {s["metric_id"]: s["catalog"] for s in payload["summary"]} + joint = artifact_store.load(payload["card_artifact"]).get("catalog_joint") or [] + + def where(metric_id: str, choice: str): + side = summary[metric_id][choice] + return (side["source"], side["table"], side["item"]) if "table" in side else None + + out = {} + for metric_id, choice in choices.items(): + if choice not in ("a", "b"): + continue + target = where(metric_id, choice) + peers = sorted((m, c) for m, c in choices.items() if c in ("a", "b") and target and where(m, c) == target) + if len(peers) < 2: + out[metric_id] = summary[metric_id][choice] + continue + [entry] = [e for e in joint if sorted((m["metric_id"], m["choice"]) for m in e["members"]) == peers] + out[metric_id] = {**entry["outcomes"][metric_id], "item": entry["item"]} + return out + + +async def assert_written_as_said(said: dict, wrote: dict, source_id: str) -> None: + """预告和真写逐字一致:说会记为什么的,目录里就是那个值、日志的 written 里有它;说不记的,原因就在日志的跳过原因里""" + entry = await notes_of(source_id) + for metric_id, side in said.items(): + if side["write"]: + assert any(w["item"] == side["item"] and w["value"] == side["value"] for w in wrote["written"]), \ + (metric_id, side, wrote) + assert entry.notes[side["item"]]["value"] == side["value"] + else: + assert side["reason"] in [s["reason"] for s in wrote["skipped"]], (metric_id, side, wrote) + + +#: 两个指标取自同一条查询的两列,都是按下单日筛的:盲算按支付日算,两个指标都只差时间列,落到 orders 的业务日期上 +TWO_BY_ORDER_DATE = (f"SELECT SUM(amount) AS gmv, SUM(amount) AS total FROM orders " + f"WHERE status <> 'refunded' AND order_date {WEEK}") +TOTAL = {**GMV, "id": "total", "name": "订单金额", "expression": "cell(nodes.fetch, 0, 'total')"} + + +@pytest.mark.parametrize("choices", [ + {"gmv": "a", "total": "b"}, # 说法不同:两个都不写 + {"gmv": "b", "total": "b"}, # 说法一样:写一次 + {"gmv": "a", "total": "a"}, + {"gmv": "b", "total": "neither"}, # 只有一个落到这一项上:照它自己的预告 +]) +async def test_the_card_preview_is_what_the_decision_writes(monkeypatch, shop, client, choices): + """图级:两个指标都只差时间列、落到数据目录的同一项上。审批卡按当前的选择预告的(摘要里各自的预告、对照工件里 + 合起来的预告),和裁决后真写进目录的逐字一致:写的值一样,不写的原因一句不差""" + name, source_id = shop + right_total = f"SELECT SUM(amount) AS total FROM orders WHERE status <> 'refunded' AND paid_date {WEEK}" + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv"), "订单金额": (right_total, "total")}) + run = await start(graph(name, TWO_BY_ORDER_DATE, metrics=[GMV, TOTAL], dual={"definitions": {}})) + assert run.status == "interrupted", run.error + payload = (await pending(run.id)).payload + card = artifact_store.load(payload["card_artifact"]) + assert len(card["catalog_joint"]) == 4, "两个指标两边都落在业务日期上:四种组合各预告一次" + said = card_said(payload, choices) + done = await decide(client, run.id, choices) + assert done.status == "succeeded", done.error + [wrote] = logs(await events(run.id), "dual_check_catalog") + await assert_written_as_said(said, wrote, source_id) + if choices == {"gmv": "a", "total": "b"}: + assert not said["gmv"]["write"] and said["gmv"] == {**said["total"], "item": "business_date"} + assert wrote["written"] == [] and "给出了不同的说法" in said["gmv"]["reason"] + + async def test_a_metric_scope_filter_is_not_written_as_a_valid_filter(monkeypatch, shop, client): """「华东销售额」原流程按指标名只看华东,盲算漏了地区:审核人选原流程,但 region = '华东' 是这个指标自己的筛选 范围,写成表的有效记录条件,以后所有查订单表的工作流(包括下一次盲算)都会只看华东。""" From e35d19d014d98365653efe91f5d7222c4d06af9f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 07:53:18 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E5=87=BA=E5=85=B7=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E9=87=8C=E6=A0=B8=E6=9F=A5=E6=8A=A5=E4=B8=8A=E6=9D=A5=E7=9A=84?= =?UTF-8?q?=E5=8F=A6=E8=B5=B7=E4=B8=80=E7=BB=84=E3=80=8C=E6=A0=B8=E6=9F=A5?= =?UTF-8?q?=E6=9C=AA=E9=80=9A=E8=BF=87=E3=80=8D=EF=BC=8C=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E8=AE=A4=E5=BE=97=E6=9C=9F=203=20=E7=95=99=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E4=B8=AA=E6=A0=B8=E6=9F=A5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 时间线的出具那一行、成果节点卡片的出具印章、证据页的出具横幅:双盲复算、反方审稿报上来的缺口(「双盲复算「节点名」:」「反方审稿:」开头,按节点类型的前缀认,名字里带这几个字的别的节点不算)归在「核查未通过」下,不再和「校验不完整」混在一起——它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),不是校验没做完。分组只有 run/decode.ts 的 splitIssuanceGaps 一处,三个地方共用 - 时间线解读 dual_check_unchecked(哪几个指标无法复算、各自的原因)、challenge_unreviewed(几句没能反查、第一句的原因)、challenge_sources_lost(几句只查了部分数据源、没查成的源):以前认不得,只能照原话显示;记入数据目录那一行带上写进去的值,写法同审批卡的预告 - 反方审稿的时间线文案从 DUAL_CHECK_TEXT.challengeLog 挪到 CHALLENGE_TEXT.log(各节点的文案放在各自的块里),decode.ts 跟着改引用 - 工件种类叫法补上 dual_check(双盲复算对照)、challenge(反方审稿记录),工件列表不再显示键名 - check-decode 补一节:三个日志代码、出具缺口的分组和顺序、前缀不重复节点类型、缺字段的老日志不崩 --- frontend/src/canvas/NodeCard.tsx | 6 +- frontend/src/lib/terms.ts | 64 ++++++++++++------ frontend/src/pages/runs/EvidencePane.tsx | 34 ++++++++-- frontend/src/pages/runs/model.ts | 7 ++ frontend/src/run/decode.ts | 77 ++++++++++++++++++---- scripts/check-decode.mjs | 83 ++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 40 deletions(-) diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 0d7083af..a065555a 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -10,7 +10,7 @@ import { NODE_DEFS, sourceHandles } from './nodeDefs' import { DENSE_EXITS, NODE_WIDTH } from './routing' import { LiveClock, stillClock, TeamMatrix, teamBrief, type TeamEnding } from './TeamMatrix' import { edgeKey, topology } from '../run/derive' -import { exhaustedOf } from '../run/decode' +import { exhaustedOf, splitIssuanceGaps } from '../run/decode' import { api } from '../api/client' import { isComposing, StatusBadge, toast } from '../components/ui' import { claimCountsOf, matchedSource } from '../lib/evidence' @@ -898,10 +898,12 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps const tier = view.at != null && issuedAt != null && view.at < issuedAt ? undefined : issuance?.tier const tierClass = tier === 'formal' ? 'is-formal' : tier === 'degraded' ? 'is-degraded' : tier === 'withheld' ? 'is-withheld' : '' const detail = issuanceDetail?.tier ? issuanceDetail : undefined - const gaps: string[] = Array.isArray(detail?.gaps) ? detail.gaps.map(String) : [] + // 双盲复算、反方审稿报上来的缺口是核查查出来的问题(两路不一致、结论被推翻),和「校验没跑全」分开说 + const { verify: verifyGaps, other: gaps } = splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : []) const stampTitle = tier ? [ `出具判定:${issuanceLabel(tier)}`, detail ? `可追溯 ${detail.matched_numbers ?? 0} 个数字 / 核对 ${detail.metrics_checked ?? 0} 个指标` : '', + verifyGaps.length ? `${DUAL_CHECK_TEXT.verifyGaps.head}:${verifyGaps.join(';')}` : '', // 降档常常不是数字对不上,而是校验根本没跑全(叙述模板渲染为空、指标集为空): // 不写出来,悬停在一个「降档出具」上看不到任何理由 gaps.length ? `校验未全部完成:${gaps.join(';')}` : '', diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index af39ded1..6938c832 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3335,7 +3335,9 @@ export const WHATIF_TEXT = { } /** - * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算和反方审稿的日志。 + * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算的日志。核查的上限叫法 + * (limitLabel)和出具缺口里「核查未通过」那一组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的时间线说法在 + * CHALLENGE_TEXT.log。 * * 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为 * 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口, @@ -3570,31 +3572,24 @@ export const DUAL_CHECK_TEXT = { const where = table ? `「${table}」${source ? `(${source})` : ''}` : '' return where && item ? `${where}的${item}` : where || item || '' }, + /** 写进数据目录的一项连同写进去的值:「orders」(shop)的业务日期记为「paid_date」 */ + catalogValue: (where: string, value: string) => `${where}记为「${value}」`, catalogSkipped: (n: number) => `${formatNumber(n)} 项未写入`, catalogNext: '可在「数据 → 数据目录」中查看或修改', /** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */ limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`), limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', + /** dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列) */ + unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,按缺口计入出具` + : '双盲复算有指标无法复算,按缺口计入出具'), + uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行', }, - /** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */ - challengeLog: { - skippedDisabled: '反方审稿未启用,已跳过', - skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', - // no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、 - // 保存失败,或者它在这次没有执行到的分支上 - skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿', - skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它', - skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', - skipped: '反方审稿已跳过', - sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` - : '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'), - sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', - done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'), - verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record, - refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'), - refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', - limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), - limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', + /** + * 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、 + * 证据页把它们另起一组,不和「校验没跑全」混在一起——它们是核查查出来的问题,不是校验没做完 + */ + verifyGaps: { + head: '核查未通过', }, } @@ -3703,6 +3698,35 @@ export const CHALLENGE_TEXT = { truncated: '结果已截断,数据库中还有更多数据', /** 理由里有出处的数字:读屏听到的是「数字,出处:查询 Q2 · 第 1 行 · amount」,点一下跳到那次查询 */ cited: (text: string, source: string) => `${text},出处:${source},跳到这次查询`, + + // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 + log: { + skippedDisabled: '反方审稿未启用,已跳过', + skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', + // no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、 + // 保存失败,或者它在这次没有执行到的分支上 + skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿', + skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它', + skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', + skipped: '反方审稿已跳过', + sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` + : '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'), + sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', + done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'), + verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record, + refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'), + refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', + limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), + limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', + /** challenge_unreviewed:有句子没能反查(核查模型不可用、数据源不在了……),记为无法判断 */ + unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论没能反查,已记为无法判断` + : '反方审稿有结论没能反查,已记为无法判断'), + unreviewedNext: '这几句没有真正查询数据。请检查核查模型和数据源是否可用,或在「设置 → 偏好设置 → 核查」中调高上限后重新运行', + /** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */ + sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'), + sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`, + sourcesLostNext: '请在「数据」页确认这些数据源是否应启用,启用后重新运行', + }, } /** diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index f04e3298..44c10062 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -12,8 +12,9 @@ import { issuanceMarks, segmentState, unitVerdict, verdictState, type AuditFilter, type AuditGroup, type AuditRow, } from '../../lib/evidence' import { formatNumber } from '../../lib/format' -import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' +import { DUAL_CHECK_TEXT, EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { IssuanceBanner } from '../../run/AssistantStream' +import { splitIssuanceGaps } from '../../run/decode' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' import { Markdown, inlineStyles } from '../../run/Markdown' @@ -145,11 +146,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { {/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */} - {!!output?._issuance?.tier && ( -
- -
- )} + {!!output?._issuance?.tier && } {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -314,6 +311,31 @@ function useAudit(runId: string, key: string): AuditState { return state } +/** + * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的一组「核查未通过」。那几条是核查查出来的问题(两路结果不一致、 + * 结论被推翻、指标无法复算),横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来, + * 紧跟在它下面单列,颜色和横幅里的缺口一样 + */ +function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { + const { verify, other } = useMemo( + () => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance]) + const shown = useMemo(() => (verify.length ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + return ( +
+ + {verify.length > 0 && ( +
+
{DUAL_CHECK_TEXT.verifyGaps.head}
+
    + {verify.map((g) =>
  • · {g}
  • )} +
+
+ )} +
+ ) +} + /** 顶上一行:这次运行按哪种方式展示证据、封存核对的结果、每份报告文档的哈希 */ function SummaryBar({ mode, seal, message, reports, labelOf }: { mode?: string diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 96c7174d..0c8ed4c7 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -332,6 +332,8 @@ export const ARTIFACT_KIND_LABEL: Record = { metric_set: '口径卡指标集', report_doc: '报告文档', schema_snapshot: '表结构快照', + dual_check: '双盲复算对照', + challenge: '反方审稿记录', } export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind @@ -349,6 +351,11 @@ export function artifactDescription(kind: string, meta?: Record | n // 查库时冻结的表结构(工件行的 meta 里有 {source, tables: 表数量}):报告里的表名、字段名按它核对 case 'schema_snapshot': return meta?.source ? `表结构:数据源「${meta.source}」${typeof meta.tables === 'number' ? ` · ${meta.tables} 张表` : ''}` : '查询时保存的表结构' + // 两路结果对不上时交给审核人的完整对照(工件行的 meta 里有 {metrics: 指标个数}) + case 'dual_check': return typeof meta?.metrics === 'number' + ? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明' + // 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点}) + case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询' default: return '工件' } } diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 33dd7161..cceb4d8c 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, - CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT, + CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, NODE_TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -717,16 +717,31 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: /** * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 - * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒 + * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。 + * 无法复算(dual_check_unchecked)、没能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 + * 期 4 补上的:以前认不得,只能照原话显示 */ const VERIFY_LOG_CODES = new Set([ 'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved', - 'dual_check_catalog', 'dual_check_limit', + 'dual_check_catalog', 'dual_check_limit', 'dual_check_unchecked', 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit', + 'challenge_unreviewed', 'challenge_sources_lost', ]) const DC = DUAL_CHECK_TEXT -const CL = DUAL_CHECK_TEXT.challengeLog +const CL = CHALLENGE_TEXT.log + +/** + * 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀 + * (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。 + * 它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),时间线、成果卡片、证据页另起一组「核查未通过」, + * 不和「校验没跑全」的缺口混在一起。其余的照旧 + */ +const VERIFY_GAP_KINDS = [NODE_TYPE_LABEL.dual_check, NODE_TYPE_LABEL.challenge] +export function splitIssuanceGaps(gaps: readonly string[]): { verify: string[]; other: string[] } { + const isVerify = (g: string) => VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`)) + return { verify: gaps.filter(isVerify), other: gaps.filter((g) => !isVerify(g)) } +} const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) /** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */ const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[㐀-鿿]/.test(v) ? v : undefined) @@ -1034,16 +1049,18 @@ function explainLog(code: string | undefined, message: string, d: Record { const key = str(w.item) - const item = key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key) - ? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key) - return DC.log.catalogItem(str(w.table), str(w.source), item) + const known = !!key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key) + const item = known ? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key) + const where = DC.log.catalogItem(str(w.table), str(w.source), item) + const value = known && w.value != null && w.value !== '' ? patchValueText(key, w.value) : '' + return where && value ? DC.log.catalogValue(where, value) : where }).filter(Boolean) const why = skipped.map((s) => str(s.reason)).filter((r): r is string => !!r) const rest = skipped.length ? `${DC.log.catalogSkipped(skipped.length)}${why.length ? `:${why.join(';')}` : ''}` : '' @@ -1062,7 +1079,22 @@ function explainLog(code: string | undefined, message: string, d: Record { + const name = str(m.name) ?? str(m.id) + const why = cjkText(m.reason) + return name ? `「${name}」${why ? `:${why}` : ''}` : '' + }).filter(Boolean) + return { + title: DC.log.unchecked(list.length || undefined), + sub: parts.length ? parts.join(';') : undefined, + next: DC.log.uncheckedNext, + level: 'warn', + } + } + // ---- 反方审稿:同上,文案在 CHALLENGE_TEXT.log(时间线的解读统一在这里做) case 'challenge_skipped': { const reason = str(d.reason) if (reason === 'disabled') { @@ -1107,6 +1139,25 @@ function explainLog(code: string | undefined, message: string, d: Record s?.artifact != null)) }) +await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => { + // 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成 + // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准—— + // 它们是核查查出来的问题,不是校验没做完 + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const events = [ + E(1, 'run.started', null, { total: 3 }), + E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }), + // metrics 是 [{id, name, reason}]:没起名字的写 id,原因是一句中文 + E(3, 'log', 'dc', { level: 'warn', code: 'dual_check_unchecked', message: '双盲复算未能核对 2 个指标,按无法复算计入缺口', + metrics: [{ id: 'gmv', name: '销售额', reason: '输入来自合并查询,无法单独复算' }, { id: 'aov', name: '', reason: '盲算已达查询次数上限' }] }), + E(4, 'node.finished', 'dc', { duration_ms: 20 }), + E(5, 'node.started', 'ch', { node_type: 'challenge', label: '审月报' }), + E(6, 'log', 'ch', { level: 'warn', code: 'challenge_unreviewed', claims_skipped: 2, reason: '数据源「北仓库」已不存在或已停用', + message: '反方审稿有 2 句结论未能反查,已记为无法判断:数据源「北仓库」已不存在或已停用' }), + E(7, 'log', 'ch', { level: 'warn', code: 'challenge_sources_lost', claims: 1, sources: ['北仓库', '南仓库'], + message: '反方审稿有 1 句结论只查了部分数据源' }), + E(8, 'node.finished', 'ch', { duration_ms: 30 }), + E(9, 'node.started', 'done', { node_type: 'output', label: '成果' }), + E(10, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 2, matched_numbers: 3, + gaps: ['双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」', + '叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致'] }), + E(11, 'node.finished', 'done', { duration_ms: 5 }), + E(12, 'run.finished', null, { status: 'succeeded' }), + ] + const steps = mod.decodeRun(events) + const all = flatten(steps) + const by = (code) => all.find((s) => s.code === code) + const NEW = ['dual_check_unchecked', 'challenge_unreviewed', 'challenge_sources_lost'] + check('三个日志代码各有一行、挂在自己的节点下', NEW.every((c) => all.some((s) => s.kind === 'node' && s.children?.some((x) => x.code === c))), + NEW.filter((c) => !by(c)).join('、')) + check('……标题里没有内部代码,原话留在展开区', NEW.every((c) => !/dual_check_|challenge_/.test(`${by(c)?.title}${by(c)?.sub ?? ''}${by(c)?.next ?? ''}`)) + && by('challenge_unreviewed')?.detail === events[5].data.message) + const un = by('dual_check_unchecked') + check('dual_check_unchecked:几个指标无法复算、计入缺口,副标题点名带原因(没名字的写 id),提醒色', + un?.title === '双盲复算有 2 个指标无法复算,按缺口计入出具' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限' + && un.level === 'warn' && !!un.next?.includes('核查'), `${un?.title} / ${un?.sub}`) + const ur = by('challenge_unreviewed') + check('challenge_unreviewed:几句没能反查、记为无法判断,原因放副标题', ur?.title === '反方审稿有 2 句结论没能反查,已记为无法判断' + && ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('没有真正查询数据'), `${ur?.title} / ${ur?.sub}`) + const lost = by('challenge_sources_lost') + check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源' + && lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'), + `${lost?.title} / ${lost?.sub}`) + + const issuance = steps.find((s) => s.kind === 'issuance') + const title = issuance?.title ?? '' + const detail = issuance?.detail ?? '' + check('出具那一行:核查报上来的两条归在「核查未通过」下', detail.split('\n').includes( + '核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail) + check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', detail.split('\n').some((l) => l.startsWith('校验不完整:') + && l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了')), detail) + check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', detail.split('\n').some((l) => l.startsWith('校验不完整:') + && l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail) + const lines = detail.split('\n') + check('……核查那一组排在校验不完整前面,标题以档位开头', title.startsWith('降档出具:核查未通过:') + && lines.findIndex((l) => l.startsWith('核查未通过:')) < lines.findIndex((l) => l.startsWith('校验不完整:')), title) + check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail) + + // splitIssuanceGaps:成果卡片、证据页按同一个函数分组 + const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', + '缺少必需指标:gmv', '反方审稿报告没写完']) + check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({ + verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'], + other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split)) + + // 老日志缺字段、字段类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数 + const odd = [['dual_check_unchecked', { metrics: 'x' }], ['challenge_unreviewed', { claims_skipped: 'x', reason: 3 }], + ['challenge_sources_lost', { claims: null, sources: 'x' }]] + let broken = null + let rows = [] + try { + const got = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'challenge' }), + ...odd.map(([code, data], i) => E(i + 2, 'log', 'x', { level: 'warn', code, ...data })), E(9, 'node.finished', 'x', {})])) + rows = odd.map(([code]) => got.find((s) => s.code === code)) + } catch (e) { + broken = String(e?.message ?? e) + } + check('缺字段的老日志:不抛异常,每个代码照样一行', broken === null && rows.every((r) => r?.title), broken ?? '') + check('……不出 undefined、NaN、[object Object],也不瞎填数', !rows.some((r) => /undefined|NaN|object Object|\d/.test(`${r?.title}${r?.sub ?? ''}`)), + rows.map((r) => `${r?.title}|${r?.sub ?? ''}`).join(' ; ')) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) From 20c24fcf8c6adaed5236f69d0d73e4470ac04dc5 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 07:53:47 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=8D=A1=E8=AF=B4=E6=B8=85=E3=80=8C=E6=A0=B9?= =?UTF-8?q?=E6=9C=AC=E6=B2=A1=E5=8F=8D=E6=9F=A5=E3=80=8D=E5=92=8C=E3=80=8C?= =?UTF-8?q?=E5=88=B0=E5=BA=95=E9=99=8D=E4=B8=8D=E9=99=8D=E6=A1=A3=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E8=BF=90=E8=A1=8C=E7=B1=BB=E5=88=AB=E5=8F=96=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E6=97=B6=E4=B8=8D=E8=AF=B4=E5=87=BA=E5=85=B7=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端给了 unreviewed(核查模型不可用、触顶、数据源不在了等没能反查的句数)就另起一行「N 句没能反查,已记为无法判断」:这几句混在「无法判断」里,分不出是查了数据不够还是一条都没查 - 「出具会降档」只在正式运行、有被推翻的结论、服务端的 degrades 为真时写(老服务端没给时照旧按「不是只标注」算);按降档处置却不在带出具契约的成果节点上游的,说一句为什么不影响出具档位,不再让审核人以为会降档 - 运行类别取不到时一句和出具有关的话都不说:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的运行类别写着正式运行,两处打架。窗口重新拿到焦点时照旧再取,取到正式运行后降档提示才出现 - 理由文档的画法(RationaleDoc)导出给双盲复算的审批卡用:没给跳转时有出处的数字不是按钮,只画实线、挂查询编号、读屏念出处 - 检查器帮助里「结论被推翻时」的选项名照下拉写(出具降档 / 只标注 / 报错并停止运行),和服务端报错里点的名字一致 - 预览用例补没能反查、不在出具路径上两种;check-challenge 补这两段、改写取不到运行类别那一段,并新增一节逐项比对 engine/labels.py 的 CHALLENGE_LABEL、CHALLENGE_ON_REFUTED_LABEL 和检查器的字段名、下拉选项 --- frontend/src/dev/fixtures/challenge.json | 4 +- frontend/src/dev/fixtures/challenge.tsx | 16 ++- frontend/src/lib/terms.ts | 22 ++-- frontend/src/run/ChallengeSummary.tsx | 122 ++++++++++--------- scripts/check-challenge.mjs | 145 +++++++++++++++++++---- 5 files changed, 220 insertions(+), 89 deletions(-) diff --git a/frontend/src/dev/fixtures/challenge.json b/frontend/src/dev/fixtures/challenge.json index 02e296fd..042e3469 100644 --- a/frontend/src/dev/fixtures/challenge.json +++ b/frontend/src/dev/fixtures/challenge.json @@ -12,7 +12,9 @@ "exploreFlag": "run-chal-explore-flag", "runfail": "run-chal-runfail", "dup": "run-chal-dup", - "norecord": "run-chal-norecord" + "norecord": "run-chal-norecord", + "unreviewed": "run-chal-unreviewed", + "offpath": "run-chal-offpath" }, "report": { "node_id": "write", diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index 8c872e24..e1eb4998 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -22,7 +22,9 @@ import fx from './challenge.json' * - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里 * - ?syn=challenge-approval-clear / -clear-flag:正式运行,没有被推翻的结论(降档 / 只标注),两种都不写降档、不写只标注 * - ?syn=challenge-approval-explore-flag:探索运行配只标注,同样不说任何和出具有关的话 - * - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),说明并可重试 + * - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),不说任何和出具有关的话 + * - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句没能反查(unreviewed),被推翻的会降档(degrades) + * - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假) * - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成) * - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null) * @@ -57,7 +59,9 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str } const RUNS = fx.runs -const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } }) +/** 期 4 起服务端另带的两项:unreviewed(没能反查的句数)、degrades(被推翻的会不会让出具降档) */ +type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } +const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } as ApprovalChallenge }) const many = fx.many as unknown as ApprovalChallenge /** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */ const clear: Partial = { @@ -207,6 +211,14 @@ export const cases: Record JSX.Element> = { ), + 'challenge-approval-unreviewed': () => ( + + ), + 'challenge-approval-offpath': () => ( + + ), 'challenge-inspector': () => , 'challenge-inspector-chain': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 6938c832..b6e833af 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3594,10 +3594,10 @@ export const DUAL_CHECK_TEXT = { } /** - * 反方审稿节点:节点库说明、检查器、节点卡片摘要,以及审批卡上的反方审稿(run/ChallengeSummary.tsx)。 - * 四种判断全站只有这一种叫法:经受住、被削弱、被推翻、无法判断。判断由反方模型给出,不是系统核对。 - * 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才说;探索运行不出具,不说任何和出具有关的话; - * 运行类别取不到时照实说取不到 + * 反方审稿节点:节点库说明、检查器、节点卡片摘要、审批卡上的反方审稿(run/ChallengeSummary.tsx),以及时间线上反方 + * 审稿的日志(log)。四种判断全站只有这一种叫法:经受住、被削弱、被推翻、无法判断。判断由反方模型给出,不是系统核对。 + * 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档(服务端的 degrades;老服务端没给时按「没写只标注」 + * 算)时才说;探索运行不出具、运行类别取不到时,不说任何和出具有关的话 */ export const CHALLENGE_TEXT = { description: '从反方立场复核上游报告的关键结论:另一个模型只读查库,逐句判断经受住、被削弱、被推翻还是无法判断', @@ -3628,8 +3628,9 @@ export const CHALLENGE_TEXT = { max_claims: '每份报告最多挑几句关键结论来审。留空则使用「设置 → 偏好设置 → 核查」中的值', max_queries: '复核每句结论时最多执行几次查询。留空则使用「设置 → 偏好设置 → 核查」中的值', timeout_s: '复核每句结论的时长上限,到时按已查到的结果给出判断。留空则使用「设置 → 偏好设置 → 核查」中的值', - on_refuted: '降档:被推翻的结论计入出具缺口,正式运行降档出具;只标注:在审批卡和运行记录中标出,不影响出具;' - + '报错:本节点失败,运行停止', + // 选项名照下拉里的写(「出具降档」「只标注」「报错并停止运行」),和服务端报错里点的选项名一致 + on_refuted: '出具降档:被推翻的结论计入出具缺口,正式运行降档出具;只标注:在审批卡和运行记录中标出,不影响出具;' + + '报错并停止运行:本节点失败,运行停止', }, onRefuted: { degrade: '出具降档(默认)', flag: '只标注', fail: '报错并停止运行' }, unknownValue: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, @@ -3678,8 +3679,11 @@ export const CHALLENGE_TEXT = { degrade: '出具会降档', degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具', flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位', - /** 运行类别没取到:不知道是不是正式运行,照实说,并写出正式运行时的后果(旁边给重试) */ - runClassFailed: '暂时无法确认运行类别;如为正式运行,被推翻的结论会使出具降档', + /** 按降档处置,但节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具 */ + degradeOff: '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位', + /** 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了等,这几句根本没查成,只是记为无法判断 */ + unreviewed: (n: number) => `${formatNumber(n)} 句没能反查,已记为无法判断`, + unreviewedHint: '核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线', /** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */ noRecord: '反方审稿记录未保存,无法查看理由和查询', more: (n: number) => `另有 ${formatNumber(n)} 句未列出`, @@ -3698,6 +3702,8 @@ export const CHALLENGE_TEXT = { truncated: '结果已截断,数据库中还有更多数据', /** 理由里有出处的数字:读屏听到的是「数字,出处:查询 Q2 · 第 1 行 · amount」,点一下跳到那次查询 */ cited: (text: string, source: string) => `${text},出处:${source},跳到这次查询`, + /** 同上,没有可跳的查询时(双盲复算的理由)只念出处 */ + citedPlain: (source: string) => `(出处:${source})`, // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 log: { diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index e09907ea..f8f7892b 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -17,16 +17,24 @@ export interface ChallengeSummaryProps { } /** - * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),逐句列出结论原文和判断, - * 点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、 - * 不能点开,整块说一次。 + * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),有句子根本没反查成时 + * 另起一行说「N 句没能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行 + * 过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 * * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 - * 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才写——只标注的不写降档,探索运行不出具,和出具有关的话一句不说。 - * 运行类别不由审批卡传进来,按运行 id 从证据 store 取(loadRunClass),取到之前不说;取不到时照实说取不到,可重试。 + * 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它(按降档处置、又接 + * 在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档处置却不在出具 + * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别不由审批卡传进来,按运行 id 从证据 store + * 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新拿到焦点时再取)。 * * 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。 */ +/** + * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 没能反查的句数,degrades 被推翻的会不会让出具降档。 + * 老服务端都不给 + */ +type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } + export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) { const reviewed = useMemo(() => reports.flatMap((r) => { const challenge = challengeOf(r) @@ -37,7 +45,7 @@ export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) { } function ChallengeList({ reviewed, runId }: { - reviewed: { report: ApprovalReport; challenge: ApprovalChallenge }[]; runId: string + reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string }) { const run = useRunClass(runId) return ( @@ -74,7 +82,7 @@ const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v * payload.reports[i].challenge 认不认得:要么有计数、要么有条目。计数缺了按条目数(条目最多 10 条,计数才是全部); * 条目缺了 unit 的留空(行的键带序号,区分得开),不补假的:补出来的键会被当成真的 unit 去和工件里的句子比 */ -function challengeOf(report: ApprovalReport): ApprovalChallenge | null { +function challengeOf(report: ApprovalReport): Challenge | null { const raw = report.challenge as unknown if (!raw || typeof raw !== 'object') return null const c = raw as Record @@ -86,41 +94,32 @@ function challengeOf(report: ApprovalReport): ApprovalChallenge | null { const counts = Object.fromEntries(VERDICTS.map((v) => [v, given ? count(given[v]) : items.filter((it) => it.verdict === v).length])) as ChallengeCounts if (!given && !items.length) return null + const unreviewed = typeof c.unreviewed === 'number' && Number.isFinite(c.unreviewed) ? count(c.unreviewed) : undefined return { counts, items, artifact: typeof c.artifact === 'string' && c.artifact ? c.artifact : null, on_refuted: typeof c.on_refuted === 'string' ? c.on_refuted : undefined, + ...(unreviewed !== undefined ? { unreviewed } : {}), + ...(typeof c.degrades === 'boolean' ? { degrades: c.degrades } : {}), } } -interface RunClass { - runClass: 'formal' | 'exploratory' | null - /** 取失败了、还没取到(重取期间也算:那一行和焦点所在的重试按钮不跟着闪掉) */ - failed: boolean - retry: () => void -} - -function useRunClass(runId: string): RunClass { +/** + * 这次运行的类别:取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具有关的话都不说(卡顶照审批自带的运行 + * 类别写着,这里再猜一个只会和它对不上)。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发), + * 取到了正式运行,降档的提示随之出现 + */ +function useRunClass(runId: string): 'formal' | 'exploratory' | null { const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined)) const load = useEvidence((s) => s.loadRunClass) useEffect(() => { if (runId) void load(runId) }, [runId, load]) const status = slot?.status - const [hadError, setHadError] = useState(false) - useEffect(() => { - if (status === 'error') setHadError(true) - else if (status === 'ok') setHadError(false) - }, [status]) - const failed = status === 'error' || (hadError && status === 'loading') - // 取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发)。只取一次就放弃的话,一次网络抖动 - // 会让「出具会降档」一直不出现,而卡顶按审批自带的运行类别照样写着正式运行 useEffect(() => { if (status !== 'error' || !runId) return const again = () => { void load(runId) } window.addEventListener('focus', again) return () => window.removeEventListener('focus', again) }, [status, runId, load]) - const retry = useCallback(() => { if (runId) void load(runId) }, [runId, load]) - const runClass = status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null - return { runClass, failed, retry } + return status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null } // ---------------------------------------------------------------- 取工件 @@ -213,7 +212,7 @@ const focusLost = () => !document.activeElement || document.activeElement === do // ---------------------------------------------------------------- 卡片 function ChallengeBlock({ report, challenge, run }: { - report: ApprovalReport; challenge: ApprovalChallenge; run: RunClass + report: ApprovalReport; challenge: Challenge; run: 'formal' | 'exploratory' | null }) { const [open, setOpen] = useState>(() => new Set()) const [all, setAll] = useState(false) @@ -222,9 +221,12 @@ function ChallengeBlock({ report, challenge, run }: { const total = VERDICTS.reduce((n, v) => n + counts[v], 0) // 审批里没带工件:服务端保存审稿记录失败了。每句都看不了理由和查询,不画成能点开的样子,整块说一次 const noRecord = !challenge.artifact - const refuted = counts.refuted > 0 && run.runClass === 'formal' + const refuted = counts.refuted > 0 && run === 'formal' // 没给 on_refuted(老服务端)按降档;报错的节点到不了审批,不会是 fail const flagged = challenge.on_refuted === 'flag' + // 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算 + const degrades = challenge.degrades ?? !flagged + const unreviewed = challenge.unreviewed ?? 0 // 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件 const extra = all && detail.data ? detail.data.claims.filter((c) => !items.some((it) => sameClaim(c, it))) : [] const rows: ChallengeItem[] = [...items, ...extra] @@ -256,24 +258,13 @@ function ChallengeBlock({ report, challenge, run }: { const showAll = () => { focusMore.current = true; setAll(true) } const retryAll = () => { focusMore.current = true; detail.retry?.() } - // 运行类别重取成功后,「暂时无法确认」那一行(连同焦点所在的重试按钮)换成降档说明:焦点接到概况那一行上 - const countsRef = useRef(null) - const runRetried = useRef(false) - useEffect(() => { - if (run.failed || !runRetried.current) return - runRetried.current = false - if (focusLost()) countsRef.current?.focus() - }, [run.failed]) - const retryRun = () => { runRetried.current = true; run.retry() } - return (
{CHALLENGE_TEXT.head(report.label || report.node_id)}
{total > 0 || items.length ? ( -
+
{VERDICTS.map((v, i) => ( {i > 0 && ·} @@ -282,7 +273,7 @@ function ChallengeBlock({ report, challenge, run }: { ))} - {refuted && !flagged && ( + {refuted && !flagged && degrades && ( {CHALLENGE_TEXT.degrade} @@ -292,20 +283,23 @@ function ChallengeBlock({ report, challenge, run }: { ) : (
{CHALLENGE_TEXT.none}
)} + {/* 根本没反查成的几句:「无法判断」里分不出查了数据不够和一条都没查,单独说一次 */} + {unreviewed > 0 && ( +

+ {CHALLENGE_TEXT.unreviewed(unreviewed)} + 。{CHALLENGE_TEXT.unreviewedHint} +

+ )} {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */} - {refuted && !flagged && ( + {refuted && !flagged && degrades && (
{CHALLENGE_TEXT.degradeHint}
)} + {refuted && !flagged && !degrades && ( +
{CHALLENGE_TEXT.degradeOff}
+ )} {refuted && flagged && (
{CHALLENGE_TEXT.flagNote}
)} - {/* 运行类别取不到:不知道是不是正式运行,不能装作没事(卡顶按审批自带的运行类别照样写着正式运行),照实说并可重试 */} - {counts.refuted > 0 && !flagged && run.failed && ( -
- {CHALLENGE_TEXT.runClassFailed} - -
- )} {rows.length > 0 && (
    {rows.map((item, i) => { @@ -525,7 +519,11 @@ function Rationale({ claim, onJump }: { claim: ChallengeClaim; onJump: (alias: s /** 列表项从哪一个片段开始:同 EvidenceDoc(行首的「- 」「1. 」是结构片段) */ const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ -function RationaleDoc({ doc, onJump }: { doc: EvidenceDocData; onJump: (alias: string) => void }) { +/** + * 核查 agent 的理由文档(evidence.compose_doc 组装的,数字带出处、裸数字被标出)。反方审稿、双盲复算的审批卡共用: + * onJump 给了的(反方审稿,旁边列着它的查询)有出处的数字是按钮,点一下跳到那次查询;没给的(双盲复算)只画出处 + */ +export function RationaleDoc({ doc, onJump }: { doc: EvidenceDocData; onJump?: (alias: string) => void }) { return (
    {doc.blocks.map((block, b) => )} @@ -534,7 +532,7 @@ function RationaleDoc({ doc, onJump }: { doc: EvidenceDocData; onJump: (alias: s } /** 一块:外观借 Markdown 的 BlockView,和报告文档、普通答案长得一样(块的分法同 EvidenceDoc 的 DocBlock) */ -function RationaleBlock({ block, doc, onJump }: { block: EvidenceBlock; doc: EvidenceDocData; onJump: (alias: string) => void }) { +function RationaleBlock({ block, doc, onJump }: { block: EvidenceBlock; doc: EvidenceDocData; onJump?: (alias: string) => void }) { const units = block.units ?? [] const unit = (u: EvidenceUnit, code = false) => switch (block.type) { @@ -605,11 +603,12 @@ function cellAlias(seg: EvidenceSegment, doc: EvidenceDocData): string | null { } /** - * 一句话:文字按行内 Markdown 渲染(整句算一次样式,同 EvidenceDoc 的 UnitView)。有出处的数字是按钮,后面挂查询编号; - * 裸数字、疑似不存在的名称画点状线,句末挂「无证据」「疑似不存在的名称」。别的片段(表名、字段名的链接)照普通文字 + * 一句话:文字按行内 Markdown 渲染(整句算一次样式,同 EvidenceDoc 的 UnitView)。有出处的数字画实线、后面挂查询编号, + * 给了 onJump 时是按钮;裸数字、疑似不存在的名称画点状线,句末挂「无证据」「疑似不存在的名称」。别的片段(表名、字段名的 + * 链接)照普通文字 */ function RationaleUnit({ unit, doc, code, onJump }: { - unit: EvidenceUnit; doc: EvidenceDocData; code: boolean; onJump: (alias: string) => void + unit: EvidenceUnit; doc: EvidenceDocData; code: boolean; onJump?: (alias: string) => void }) { const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural') const whole = segs.map((s) => s.text).join('') @@ -624,13 +623,22 @@ function RationaleUnit({ unit, doc, code, onJump }: { const body = styles ? : s.text if (alias) { const source = sourceOf(s, doc) + // inline-block:不吃上面那条下划线 + const tag = {alias} + if (!onJump) { + return ( + // 不是按钮:.ev-seg 的手形光标换成问号,悬停看出处 + + {body}{tag}{CHALLENGE_TEXT.citedPlain(source)} + + ) + } return ( ) } diff --git a/scripts/check-challenge.mjs b/scripts/check-challenge.mjs index 44f13877..013974ae 100644 --- a/scripts/check-challenge.mjs +++ b/scripts/check-challenge.mjs @@ -2,19 +2,22 @@ // challenge、canvas/verifyFields.tsx)。 // // 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、 -// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时降档提示悄悄消失、没经过反方审稿的报告 -// 也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、 +// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式 +// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、 +// 根本没反查成的几句混在「无法判断」里看不出来、没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、 // 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、 // 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上); // 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了 // 下游的报告、留空时认的报告和服务端不一样(「初稿 → 终稿 → 反方审稿」审终稿)、选中的报告被删了还看着像「未选择」、 // 选中的不是报告撰写节点却说成不在上游、反方模型和撰写报告的模型相同不提醒、画布校验已经报了的又在字段下面说一遍、 -// 节点卡片的摘要还写着功能开发中;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。 +// 节点卡片的摘要还写着功能开发中、检查器上的叫法和服务端报错里的(engine/labels.py 的 CHALLENGE_LABEL、 +// CHALLENGE_ON_REFUTED_LABEL)对不上;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。 // // 跑在预览页上:/preview.html?syn=challenge-*(用例见 frontend/src/dev/fixtures/challenge.tsx)。夹具在 // frontend/src/dev/fixtures/challenge.json,理由文档是后端 run_verify_agent + compose_doc 真跑出来的(虚构的库和数字)。 // 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})、画布校验(两个纯计算的 POST)在这里伪造,别的非 GET -// 一律拦掉并记下来,别的 GET 放行。 +// 一律拦掉并记下来,别的 GET 放行。叫法对照那一节不开浏览器:读 backend/app/engine/labels.py,terms.ts 借 vite 转译后 +// 直接 import。 // // node scripts/check-challenge.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-challenge.mjs @@ -42,10 +45,13 @@ const RUN_CLASS = { [RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal', [RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal', [RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal', + [RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', } const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位' const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具' +const DEGRADE_OFF = '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位' +const UNREVIEWED = (n) => `${n} 句没能反查,已记为无法判断。核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线` const FAILED = '无法获取反方审稿的详细内容' // F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面 const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份' @@ -67,6 +73,48 @@ async function section(name, fn) { } } +// terms.ts 借 vite 转译成 data: URL 再 import(同 check-verify、check-decode):依赖也各自转好替换进去 +const loaded = new Map() +async function load(path) { + if (loaded.has(path)) return loaded.get(path) + const res = await fetch(`${WEB}${path}?t=${Date.now()}`) + if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`) + let code = await res.text() + const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1])) + for (const dep of deps) { + const url = await load(dep) + const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g') + code = code.replace(pattern, JSON.stringify(url)) + } + const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64') + loaded.set(path, url) + return url +} + +await section('检查器的叫法和服务端报错一致', async () => { + // 校验和执行报错写的是 engine/labels.py 的 CHALLENGE_LABEL(字段)、CHALLENGE_ON_REFUTED_LABEL(「结论被推翻时」的 + // 取值),报错让人去改某一项时,检查器上得是同一个名字,下拉里得找得到那个选项(选项只多一个「(默认)」) + const { CHALLENGE_TEXT: C } = await import(await load('/src/lib/terms.ts')) + const py = readFileSync(`${root}backend/app/engine/labels.py`, 'utf8') + const dict = (name) => { + const block = py.match(new RegExp(`^${name}: dict\\[str, str\\] = \\{([\\s\\S]*?)\\}$`, 'm'))?.[1] + return block == null ? null : Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) + } + const fields = dict('CHALLENGE_LABEL') + const options = dict('CHALLENGE_ON_REFUTED_LABEL') + check('从 labels.py 读出了 CHALLENGE_LABEL、CHALLENGE_ON_REFUTED_LABEL', !!fields && !!options + && Object.keys(fields).length === 8 && Object.keys(options).length === 3, `${JSON.stringify(fields)} / ${JSON.stringify(options)}`) + const wrong = Object.entries(fields ?? {}).filter(([k, v]) => C.field?.[k] !== v) + check('检查器的字段名(CHALLENGE_TEXT.field)和 CHALLENGE_LABEL 一字不差、一项不多', !wrong.length + && Object.keys(C.field ?? {}).length === Object.keys(fields ?? {}).length, wrong.map(([k, v]) => `${k}:${C.field?.[k]} ≠ ${v}`).join(';')) + const off = Object.entries(options ?? {}).filter(([k, v]) => (C.onRefuted?.[k] ?? '').replace(/(默认)$/, '') !== v) + check('「结论被推翻时」的下拉选项去掉「(默认)」后和 CHALLENGE_ON_REFUTED_LABEL 一字不差', !off.length + && Object.keys(C.onRefuted ?? {}).length === 3, off.map(([k, v]) => `${k}:${C.onRefuted?.[k]} ≠ ${v}`).join(';')) + check('……只有缺省的那一项(出具降档)带「(默认)」', Object.entries(C.onRefuted ?? {}).filter(([, v]) => v.endsWith('(默认)')) + .map(([k]) => k).join(',') === 'degrade', JSON.stringify(C.onRefuted)) + check('帮助里提到的选项名就是下拉里的名字', Object.values(options ?? {}).every((v) => C.help?.on_refuted?.includes(`${v}:`)), C.help?.on_refuted) +}) + const browser = await chromium.launch({ executablePath: CHROME }) /** @@ -155,7 +203,8 @@ await section('审批卡:概况与降档提示', async () => { check('降档的原因常显在概况下面,不靠悬停', await note.count() === 1 && await note.isVisible() && (await text(note)) === DEGRADE_NOTE && (await page.locator('[data-challenge-degrade]').getAttribute('title')) === null, await note.count() ? await text(note) : '没有') check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0) - check('运行类别取到了:没有「暂时无法确认运行类别」', await page.locator('[data-challenge-runclass]').count() === 0) + // 老服务端不给 degrades:按旧规则(不是只标注就降档),不写「不在出具路径上」 + check('没给 degrades:按旧规则写降档,不写「不影响出具档位」', await page.locator('[data-challenge-degrade-off]').count() === 0) const rows = await block.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => ({ unit: el.getAttribute('data-challenge-item'), tag: el.querySelector('[data-challenge-tag]')?.textContent?.trim(), @@ -304,31 +353,83 @@ await section('正式运行、没有被推翻的结论:不写降档', async () } }) -await section('运行类别取不到:说明并可重试', async () => { +await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => { + // 取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的 + // 运行类别写着正式运行,两处说法打架。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行 + // 降档提示才出现 const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail') - await page.locator('[data-challenge="write"]').waitFor() + const block = page.locator('[data-challenge="write"]') + await block.waitFor() await runClassSettled(page, hits, RUNS.runfail) - const failedLine = page.locator('[data-challenge-runclass="failed"]') - check('取不到:不写降档,也不装作没事——写明暂时无法确认运行类别', await page.locator('[data-challenge-degrade]').count() === 0 - && await failedLine.count() === 1 && (await text(failedLine)).startsWith('暂时无法确认运行类别'), - await failedLine.count() ? await text(failedLine) : '没有说明') - // 窗口重新拿到焦点时再取一次(store 对出错的槽会重发) + const quiet = async () => { + const all = await text(block) + return await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], ' + + '[data-challenge-flag], [data-challenge-runclass]').count() === 0 && !all.includes('出具') && !all.includes('运行类别') + } + check('取不到:不写降档、不写只标注,也不提运行类别', await quiet(), await text(block)) + check('取不到:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句' + && await page.locator('[data-challenge-item]').count() === 4) + check('取不到:不再给「重试」(重试的是运行类别,不是审稿内容)', await block.locator('button', { hasText: '重试' }).count() === 0) + // 窗口重新拿到焦点时再取一次(store 对出错的槽会重发):还是取不到,照样不说 await page.evaluate(() => window.dispatchEvent(new Event('focus'))) for (let i = 0; i < 30 && hits.runs.filter((r) => r === RUNS.runfail).length < 2; i++) await page.waitForTimeout(100) check('窗口重新拿到焦点:再取一次运行类别', hits.runs.filter((r) => r === RUNS.runfail).length === 2, hits.runs.join('、')) + await page.waitForTimeout(300) + check('……还是取不到:照样不说出具', await quiet(), await text(block)) state.runFail = false - await failedLine.locator('button', { hasText: '重试' }).focus() - await page.keyboard.press('Enter') + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) await page.waitForSelector('[data-challenge-degrade]') - check('按重试:取到正式运行后写「出具会降档」,说明随之消失', await failedLine.count() === 0 - && hits.runs.filter((r) => r === RUNS.runfail).length === 3, hits.runs.join('、')) - await page.waitForTimeout(100) - check('按重试:那一行拿掉后焦点接到概况上,不掉到页面上', await page.evaluate(() => - document.activeElement?.hasAttribute('data-challenge-counts') ?? false), await page.evaluate(() => document.activeElement?.tagName)) + check('取到了正式运行:写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && hits.runs.filter((r) => r === RUNS.runfail).length === 3, + hits.runs.join('、')) check('取不到:页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() }) +await section('有句子根本没反查成:单独说一行,不混在「无法判断」里', async () => { + // 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了……这几句记为无法判断,可一条查询都没执行。「无法判断 2 句」 + // 分不出是查了、数据不够,还是根本没查,审批卡单独说 + const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await runClassSettled(page, hits, RUNS.unreviewed) + const line = block.locator('[data-challenge-unreviewed]') + check('写明几句没能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2) + && (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有') + check('……概况照服务端的计数写,不另算', (await text(block.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句') + // degrades 为真、正式运行、有被推翻的:照常写降档 + check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0) + check('没能反查:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() + // 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行 + for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) { + const other = await open(syn) + await other.page.locator('[data-challenge="write"]').waitFor() + await runClassSettled(other.page, other.hits, run) + check(`${syn}:没有没能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0 + && !(await text(other.page.locator('[data-challenge="write"]'))).includes('没能反查')) + await other.ctx.close() + } +}) + +await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => { + // 反方审稿接在侧枝上(或成果节点没带出具契约):被推翻的结论记了缺口也进不了出具。以前照 on_refuted 写「出具会降档」, + // 审核人以为这次会降档,实际照常出具 + const { ctx, page, hits, errors } = await open('challenge-approval-offpath') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await runClassSettled(page, hits, RUNS.offpath) + check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0 + && !(await text(block)).includes('出具会降档'), await text(block)) + const off = page.locator('[data-challenge-degrade-off]') + check('写明本节点不在带出具契约的成果节点上游、不影响出具档位', await off.count() === 1 && (await text(off)) === DEGRADE_OFF, + await off.count() ? await text(off) : '没有') + check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0) + check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + await section('没有反方审稿:什么都不画', async () => { const { ctx, page, hits, errors } = await open('challenge-approval-none') await page.locator('[data-approval-report="write"]').waitFor() @@ -674,11 +775,13 @@ await section('窄屏与深浅色', async () => { check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) await ctx.close() } - // 这次新加的几行说明(降档原因、运行类别取不到、审稿记录没存上):窄屏不撑宽,深浅色都看得清 + // 几行说明(降档原因、没能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清 for (const theme of ['dark', 'light']) { const notes = [ ['challenge-approval', '[data-challenge-degrade-note]'], - ['challenge-approval-runfail', '[data-challenge-runclass="failed"] span'], + ['challenge-approval-unreviewed', '[data-challenge-unreviewed] > span:first-child'], + ['challenge-approval-unreviewed', '[data-challenge-unreviewed] > span:last-child'], + ['challenge-approval-offpath', '[data-challenge-degrade-off]'], ['challenge-approval-norecord', '[data-challenge-norecord]'], ] for (const [syn, sel] of notes) { From f8adbaa463f3dce655d5e102c3f64efa5663d78c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:10:10 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E6=A0=B8=E6=9F=A5=E7=B1=BB=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=A3=80=E6=9F=A5=E5=99=A8=E7=9A=84=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E5=90=88=E6=88=90=E4=B8=80=E4=BB=BD=EF=BC=9A?= =?UTF-8?q?=E9=80=89=E7=94=BB=E5=B8=83=E8=8A=82=E7=82=B9=E3=80=81=E4=B8=89?= =?UTF-8?q?=E6=80=81=E5=BC=80=E5=85=B3=E3=80=81=E6=A0=B8=E6=9F=A5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=E5=8F=AA=E7=95=99=E5=9C=A8=20verify?= =?UTF-8?q?Fields.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 期 3 两个工作包各写了一份:双盲复算在 DualCheckFields.tsx(选口径卡、启用三态、盲算模型),反方审稿在 verifyFields.tsx(选报告、启用三态、反方模型),说法和细节各不相同、修一处漏一处。现在双盲复算改用 verifyFields 的 NodePick、TriState、ProviderPick、ModelInput,只给自己的文案;它特有的选指标、口径说明、相对误差留在 DualCheckFields.tsx。写进配置的值不变(留空写回 undefined,三态只认真布尔)。 - NodePick 加可选的分组(上游的一组、画布上其余同类的一组,选了不在上游的照样能选、下面提醒连到上游)、下拉项和缺省名的写法;写着的卡被删了、写的不是口径卡(助手改图、导入 JSON 会写出这种配置)时双盲复算也照实列出、说一句,不再只认「不在画布上」 - TriState 可选在「跟随设置」后面写出设置里现在是开是关(双盲复算用,每次打开检查器重取设置);设置的取法 useVerifyDefaults 挪到这里共用,用不上的控件不发请求 - 画布节点、连线的类型收窄成两边都给得出的 CanvasNode、CanvasEdge,节点卡片摘要和检查器共用 upstreamOf、resolvePick - check-verify 跟着改:提示、三态、按模型名找接入的标记换成共用控件的;字段叫法从 engine/labels.py 读(期 4 从 schema.py 挪过去);补写着的卡不在画布上、不是口径卡两种情形 --- frontend/src/canvas/DualCheckFields.tsx | 217 ++++++------------------ frontend/src/canvas/verifyFields.tsx | 191 ++++++++++++++------- frontend/src/lib/terms.ts | 12 +- scripts/check-verify.mjs | 69 +++++--- 4 files changed, 237 insertions(+), 252 deletions(-) diff --git a/frontend/src/canvas/DualCheckFields.tsx b/frontend/src/canvas/DualCheckFields.tsx index 0b0870a5..d55ee4b2 100644 --- a/frontend/src/canvas/DualCheckFields.tsx +++ b/frontend/src/canvas/DualCheckFields.tsx @@ -1,62 +1,41 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import type { ReactNode } from 'react' import { X } from 'lucide-react' import clsx from 'clsx' -import { api } from '../api/client' import { useStudio } from '../store/studio' -import { modelOptions, providerOfModel, useCatalog } from '../store/catalog' -import { useRadioGroup } from '../components/ui' import { DUAL_CHECK_TEXT } from '../lib/terms' -import type { VerifySettings } from '../types' import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs' +import { + ModelInput, NodePick, ProviderPick, TriState, resolvePick, upstreamOf, useVerifyDefaults, + type CanvasEdge, type CanvasNode, type NodePickText, type TriStateText, type VerifyModelText, +} from './verifyFields' /* * 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。 * - * 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。 - * 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。 - * 后端认的键和缺省见 types.ts 的 DualCheckConfig + * 选口径卡、盲算模型、启用三态用核查类节点共用的控件(verifyFields.tsx,反方审稿也用它们),这里只给文案;双盲复算 + * 特有的选指标、口径说明、相对误差在这里。口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读, + * 不往检查器的 props 里加字段。每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随 + * 默认」,配置里不留空值。后端认的键和缺省见 types.ts 的 DualCheckConfig */ const T = DUAL_CHECK_TEXT -type CanvasNode = NodeSummaryContext['nodes'][number] -type CanvasEdge = NodeSummaryContext['edges'][number] - const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : []) -/** 某个节点的全部上游(沿连线往回走),不含它自己 */ -function upstreamOf(id: string, edges: readonly CanvasEdge[]): Set { - const parents = new Map() - for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) - const seen = new Set() - const stack = [...(parents.get(id) ?? [])] - while (stack.length) { - const cur = stack.pop()! - if (seen.has(cur) || cur === id) continue - seen.add(cur) - stack.push(...(parents.get(cur) ?? [])) - } - return seen -} - -/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */ -function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { - const up = upstreamOf(id, edges) - const cards = nodes.filter((n) => n.data.nodeType === 'metrics') - return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) } -} - /** - * 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它, - * 好几张时说不准,node 为 null、由调用方照实说 + * 实际复算哪张卡:写了 card 就是它(不在画布上、不是口径卡时 node 为 null;不在上游也照样是它,指标照列,选卡的地方 + * 另外提醒);没写取上游的口径卡(schema.dual_check_card 的认法),上游只有一张时就是它,好几张时说不准,node 为 null、 + * 由调用方照实说 */ function targetOf(config: Record, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { - const { upstream } = cardsOf(id, nodes, edges) + const up = upstreamOf(id, edges) + const upstream = nodes.filter((n) => up.has(n.id) && n.data.nodeType === 'metrics') const card = typeof config.card === 'string' && config.card ? config.card : '' - if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream } - return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream } + const pick = resolvePick(id, card, 'metrics', 'upstream', nodes, edges) + const node = pick.kind === 'target' || pick.kind === 'notUpstream' ? pick.node : null + return { node, card, upstream } } /** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */ @@ -92,33 +71,7 @@ export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContex return config.enabled === false ? `${text} · ${S.off}` : text } -// ---------------------------------------------------------------- 设置里的默认值 - -/** - * 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲 - * 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得 - * 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑 - */ -let verifyPending: Promise | null = null -let verifyValue: VerifySettings | null | undefined -function fetchVerify(): Promise { - verifyPending ??= api.settings.get().then( - (s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null), - () => verifyValue ?? null, - ).finally(() => { verifyPending = null }) - return verifyPending -} -function useVerifyDefaults(): VerifySettings | null { - const [value, setValue] = useState(verifyValue ?? null) - useEffect(() => { - let alive = true - void fetchVerify().then((v) => { if (alive) setValue(v) }) - return () => { alive = false } - }, []) - return value -} - -/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */ +/** 字段下面的一行提醒(写了说明的指标不在复算范围里之类),提醒色 */ function Warn({ children, attr }: { children: ReactNode; attr: string }) { return (
    {children}
    @@ -127,33 +80,23 @@ function Warn({ children, attr }: { children: ReactNode; attr: string }) { // ---------------------------------------------------------------- 各字段 -/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */ -function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) { - const nodes = useStudio((s) => s.nodes) - const edges = useStudio((s) => s.edges) - const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges]) - const current = typeof value === 'string' ? value : '' - const known = [...upstream, ...other].some((n) => n.id === current) - const option = (n: CanvasNode) => ( - - ) - return ( - <> - - {/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */} - {!current && upstream.length > 1 && {T.cardChoose(upstream.length)}} - {current && known && !upstream.some((n) => n.id === current) && {T.cardNotUpstream}} - {current && !known && {T.cardMissing(current)}} - - ) +/** + * 要复算的口径卡(共用的 NodePick):上游的一组、其余的一组(选了不在上游的,提醒把它连到上游);留空取上游的口径卡, + * 上游有好几张又没选时写明要选(服务端校验会挡住发起运行);写着的卡不在画布上、不是口径卡时照实列出来、说一句 + */ +const CARD_TEXT: NodePickText = { + auto: (only, count) => T.cardDefault(only, count), + many: (n) => T.cardChoose(n), + missing: (id) => T.cardGone(id), + notUpstream: (label) => label, + wrongType: (label) => T.cardWrongType(label), + missingHint: T.cardMissingHint, + notUpstreamHint: T.cardNotUpstream, + wrongTypeHint: (type) => T.cardWrongTypeHint(type), + groups: { upstream: T.cardUpstream, other: T.cardOther }, } +/** 一张口径卡在下拉里的叫法:节点名,口径名不同时附在后面 */ +const cardOption = (n: CanvasNode) => T.cardOption(String(n.data.label || n.id), n.data.config?.caliber) /** * 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回 @@ -280,52 +223,12 @@ function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: } /** - * 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫 - * 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼) + * 盲算模型(共用的 ProviderPick、ModelInput):留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫「按模型名找 + * 接入」,并写明实际用哪个接入(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)。取值链的说明在字段的帮助里 */ -function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) { - const providers = useCatalog((s) => s.providers) - const provider = typeof value === 'string' ? value : '' - const known = providers.some((p) => p.name === provider) - const byModel = !provider && typeof config.model === 'string' && !!config.model.trim() - return ( - - ) -} - -/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */ -function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) { - const providers = useCatalog((s) => s.providers) - const provider = typeof config.provider === 'string' ? config.provider : '' - const pv = provider ? providers.find((p) => p.name === provider) : undefined - const model = typeof value === 'string' ? value : '' - const byModel = !provider && !!model.trim() - const owner = byModel ? providerOfModel(providers, model.trim()) : undefined - const enabled = providers.filter((p) => p.enabled) - const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0] - const hint = byModel && providers.length > 0 - ? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : '' - const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))] - return ( - <> - onChange(e.target.value.trim() ? e.target.value : undefined)} /> - {models.map((m) => - {hint && ( -
    - {hint} -
    - )} - - ) +const MODEL_TEXT: VerifyModelText = { + providerFollow: T.providerFollow, providerFromModel: T.providerFromModel, providerMissing: T.providerGone, + providerDisabled: T.providerOff, modelFollow: T.modelFollow, modelDefault: T.modelOfProvider, byModel: T.byModel, } /** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */ @@ -344,39 +247,25 @@ function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRend ) } -const ENABLED = ['follow', 'on', 'off'] as const -type Enabled = typeof ENABLED[number] - -/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */ -function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) { - const settings = useVerifyDefaults() - const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow' - const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined) - const radio = useRadioGroup(ENABLED, current, pick) - return ( -
    - {ENABLED.map((v) => ( - - ))} -
    - ) +/** + * 启用(共用的 TriState):跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里 + * 留 null;「跟随设置」后面写出设置里现在是开是关(每次打开检查器都重取)。各态的说明在字段的帮助里 + */ +const ENABLED_TEXT: TriStateText = { + follow: T.enabled.follow, on: T.enabled.on, off: T.enabled.off, followNow: (on) => T.enabledFollow(on), } /** nodeDefs 里 dual_check 各字段的 render */ export const dualCheckFields = { - card: (p: FieldRenderProps) => , + card: (p: FieldRenderProps) => ( + + ), metrics: (p: FieldRenderProps) => , definitions: (p: FieldRenderProps) => , - provider: (p: FieldRenderProps) => , - model: (p: FieldRenderProps) => , + provider: (p: FieldRenderProps) => , + model: (p: FieldRenderProps) => , tolerance: (p: FieldRenderProps) => , - enabled: (p: FieldRenderProps) => , + enabled: (p: FieldRenderProps) => ( + v.dual_check?.enabled} /> + ), } diff --git a/frontend/src/canvas/verifyFields.tsx b/frontend/src/canvas/verifyFields.tsx index caac6ab2..c8416b94 100644 --- a/frontend/src/canvas/verifyFields.tsx +++ b/frontend/src/canvas/verifyFields.tsx @@ -1,19 +1,21 @@ -import { useMemo } from 'react' -import type { Edge } from '@xyflow/react' +import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' +import { api } from '../api/client' import { useRadioGroup } from '../components/ui' import { nodeTypeLabel } from '../lib/terms' import { modelOptions, providerOfModel, useCatalog } from '../store/catalog' -import { useStudio, type FlowNode } from '../store/studio' -import type { NodeType } from '../types' +import { useStudio } from '../store/studio' +import type { NodeType, VerifySettings } from '../types' import { fieldOfIssue } from './issues' import type { FieldDef, FieldRenderProps } from './nodeDefs' /** - * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,nodeDefs 用下面的工厂函数拼进字段表: - * - nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; - * - triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); - * - verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择。 + * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,两个节点共用这一份(期 3 两个工作包各写过一份,期 4 合并): + * - NodePick / nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; + * - TriState / triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); + * - ProviderPick、ModelInput / verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择; + * - useVerifyDefaults:设置里「核查」一组的当前值(「跟随设置」后面写出现在是多少)。 + * 双盲复算特有的(选指标、口径说明、相对误差)留在 DualCheckFields.tsx。 * * 文案一律由调用方传进来(各节点自己那一块的 *_TEXT),控件里不写死任何一句话:两个节点说法不同(反方模型 / 盲算模型), * 控件是同一个。读画布节点用 studio store,不往 FieldRenderProps 里加字段。 @@ -22,6 +24,10 @@ import type { FieldDef, FieldRenderProps } from './nodeDefs' * 再画一句就是同一件事说两遍(同结论句裁判「校验也会报,报了就不重复说」)。校验没回来、校验失败时照画。 */ +/** 画布上的节点、连线只用得到这几项:检查器给的是 FlowNode / Edge,节点卡片的摘要给的是 NodeSummaryContext 里的 */ +export interface CanvasNode { id: string; data: { nodeType: NodeType; label?: string; config?: Record } } +export interface CanvasEdge { source: string; target: string } + /** * 画布校验在这个节点的这一项上报了问题没有。认法和 Inspector 把问题挂到字段下面时一样(fieldOfIssue), * 两边说的「这一项有问题」不会对不上 @@ -34,13 +40,13 @@ function useReported(nodeId: string, key: string): boolean { } /** 节点在下拉里的叫法:名字和 id 不同时把 id 带上(同 NodeRefsInput) */ -function nodeLabel(n: FlowNode): string { +function nodeLabel(n: CanvasNode): string { const label = String(n.data?.label || n.id) return label === n.id ? label : `${label}(${n.id})` } -/** 本节点的全部上游(沿连线往回找)。只认上游:下游的节点运行到这里时还没有产出 */ -function upstreamOf(nodeId: string, edges: Edge[]): Set { +/** 某个节点的全部上游(沿连线往回找),不含它自己。只认上游:下游的节点运行到这里时还没有产出 */ +export function upstreamOf(nodeId: string, edges: readonly CanvasEdge[]): Set { const parents = new Map() for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) const seen = new Set() @@ -66,17 +72,17 @@ export type PickRule = 'upstream' | 'direct' * 配置里那一项按服务端的认法落到哪个节点上:target 是定下来的那一个;其余几种是定不下来的原因——写了的节点不存在、 * 不是这一类、不在上游(先后同服务端),留空时上游一个都没有、不止一个 */ -export type PickState = - | { kind: 'target'; node: FlowNode } +export type PickState = + | { kind: 'target'; node: N } | { kind: 'missing' } - | { kind: 'wrongType'; node: FlowNode } - | { kind: 'notUpstream'; node: FlowNode } + | { kind: 'wrongType'; node: N } + | { kind: 'notUpstream'; node: N } | { kind: 'none' } | { kind: 'many'; count: number } -export function resolvePick( - nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[], -): PickState { +export function resolvePick( + nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[], +): PickState { const up = upstreamOf(nodeId, edges) if (typeof chosen === 'string' && chosen) { const node = nodes.find((n) => n.id === chosen) @@ -95,20 +101,48 @@ export function resolvePick( } /** 这个节点实际要核查的是哪一个;定不下来(服务端这时报错、不核查)时返回 undefined */ -export function targetNode( - nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[], -): FlowNode | undefined { +export function targetNode( + nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[], +): N | undefined { const pick = resolvePick(nodeId, chosen, type, rule, nodes, edges) return pick.kind === 'target' ? pick.node : undefined } +// ---------------------------------------------------------------- 设置里的核查一组 + +/** + * 设置里核查一组的值:「跟随设置」后面写出现在是开是关、相对误差的占位写出现在是多少。每次打开检查器都重取一次——去 + * 设置页关掉双盲复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的 + * 画,免得闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑 + */ +let verifyPending: Promise | null = null +let verifyValue: VerifySettings | null | undefined +function fetchVerify(): Promise { + verifyPending ??= api.settings.get().then( + (s) => (verifyValue = s?.verify && typeof s.verify === 'object' ? s.verify as VerifySettings : null), + () => verifyValue ?? null, + ).finally(() => { verifyPending = null }) + return verifyPending +} +export function useVerifyDefaults(active = true): VerifySettings | null { + const [value, setValue] = useState(verifyValue ?? null) + useEffect(() => { + // 用不上设置的控件(反方审稿的启用不写当前值)不取,打开检查器不多发一个请求 + if (!active) return + let alive = true + void fetchVerify().then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, [active]) + return value +} + // ---------------------------------------------------------------- 选画布上的节点 export interface NodePickText { - /** 留空那一项:按缺省能认出是哪一个时给出它的名字 */ - auto: (only: string | null) => string - /** 上游一个都没有 */ - none: string + /** 留空那一项:按缺省能认出是哪一个时给出它的名字;count 是上游这一类节点有几个 */ + auto: (only: string | null, count: number) => string + /** 上游一个都没有时下面那句提示;不给就不提示(留空那一项已经说了) */ + none?: string /** 上游不止一个、又没有选 */ many: (n: number) => string /** 选中的节点已不在画布上(下拉里那一项的叫法) */ @@ -121,45 +155,69 @@ export interface NodePickText { notUpstreamHint: string /** type 是选中节点的类型名(「输入」) */ wrongTypeHint: (type: string) => string + /** + * 下拉里把上游的和画布上其余同类节点分两组列时,两组的标题(双盲复算的口径卡:选了不在上游的,提示把它连到上游)。 + * 不给时只列上游的 + */ + groups?: { upstream: string; other: string } } -function NodePick({ p, fieldKey, nodeType, rule, text }: { - p: FieldRenderProps; fieldKey: string; nodeType: NodeType; rule: PickRule; text: NodePickText -}) { +export interface NodePickOptions { + fieldKey: string + nodeType: NodeType + rule: PickRule + text: NodePickText + /** 下拉里一个节点的叫法,缺省是「名字(id)」 */ + optionLabel?: (n: CanvasNode) => string + /** 留空那一项里,按缺省认出的那一个的名字,缺省是节点名(没起名的用 id) */ + nameOf?: (n: CanvasNode) => string +} + +const nodeName = (n: CanvasNode): string => String(n.data?.label || n.id) + +export function NodePick({ p, fieldKey, nodeType, rule, text, optionLabel = nodeLabel, nameOf = nodeName }: NodePickOptions & { p: FieldRenderProps }) { const nodes = useStudio((s) => s.nodes) const edges = useStudio((s) => s.edges) const reported = useReported(p.nodeId, fieldKey) const value = typeof p.value === 'string' ? p.value : '' - const options = useMemo(() => { + const { upstream, other } = useMemo(() => { const up = upstreamOf(p.nodeId, edges) - return nodes.filter((n) => up.has(n.id) && n.data?.nodeType === nodeType) + const same = nodes.filter((n) => n.data?.nodeType === nodeType) + return { upstream: same.filter((n) => up.has(n.id)), other: same.filter((n) => !up.has(n.id) && n.id !== p.nodeId) } }, [nodes, edges, p.nodeId, nodeType]) const pick = useMemo( () => resolvePick(p.nodeId, value, nodeType, rule, nodes, edges), [p.nodeId, value, nodeType, rule, nodes, edges], ) // 选中的节点被删了、不是这一类、挪到了下游:下拉里照实多列一项(选不回去),不让它看着像「未选择」。 - // 校验已经报了的,这一项照样列(看得出选的是什么),只是下面那句提示不再说 - const stale = pick.kind === 'missing' || pick.kind === 'wrongType' || pick.kind === 'notUpstream' ? pick : null + // 分组列出画布上其余同类节点时,不在上游的那个本来就在「其余」一组里,不再另列。校验已经报了的,这一项照样列 + // (看得出选的是什么),只是下面那句提示不再说 + const stale = pick.kind === 'missing' || pick.kind === 'wrongType' || (pick.kind === 'notUpstream' && !text.groups) ? pick : null const hint = reported ? '' : pick.kind === 'missing' ? text.missingHint : pick.kind === 'wrongType' ? text.wrongTypeHint(nodeTypeLabel(pick.node.data?.nodeType)) : pick.kind === 'notUpstream' ? text.notUpstreamHint - : pick.kind === 'none' ? text.none + : pick.kind === 'none' ? text.none ?? '' : pick.kind === 'many' ? text.many(pick.count) : '' const hintId = `${p.id}-hint` - const only = !value && pick.kind === 'target' ? String(pick.node.data?.label || pick.node.id) : null + const only = !value && pick.kind === 'target' ? nameOf(pick.node) : null + const option = (n: CanvasNode) => return ( <> @@ -177,10 +235,12 @@ function NodePick({ p, fieldKey, nodeType, rule, text }: { */ export function nodePickField(def: { key: string; label: string; nodeType: NodeType; rule?: PickRule; text: NodePickText; help?: string; advanced?: boolean + optionLabel?: (n: CanvasNode) => string }): FieldDef { return { key: def.key, label: def.label, type: 'select', help: def.help, advanced: def.advanced, - render: (p) => , + render: (p) => , } } @@ -190,43 +250,56 @@ export interface TriStateText { follow: string on: string off: string - /** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样 */ - hint: { follow: string; on: string; off: string } + /** 「跟随设置」那一项附上设置里现在是开是关(设置还没取到时 on 为 null / undefined)。不给就只写 follow */ + followNow?: (on: boolean | null | undefined) => string + /** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样。不给就不写(说明在字段的帮助里) */ + hint?: { follow: string; on: string; off: string } } type Tri = 'follow' | 'on' | 'off' const TRI: readonly Tri[] = ['follow', 'on', 'off'] -function TriState({ p, label, text }: { p: FieldRenderProps; label: string; text: TriStateText }) { +export function TriState({ p, label, text, setting }: { + p: FieldRenderProps; label: string; text: TriStateText + /** 从设置的核查一组里取这一项现在的开关:给了才在「跟随设置」后面写出来 */ + setting?: (v: VerifySettings) => boolean | null | undefined +}) { // 只认真正的布尔:手写的 "false"、0 不当成关闭,按跟随设置显示(服务端校验会报出来) const state: Tri = p.value === true ? 'on' : p.value === false ? 'off' : 'follow' // 跟随设置写回 undefined:配置里不留这一项,和没配过的节点一样 const set = (t: Tri) => p.onChange(t === 'on' ? true : t === 'off' ? false : undefined) const radio = useRadioGroup(TRI, state, set) + const settings = useVerifyDefaults(!!setting) + const now = setting && settings ? setting(settings) : undefined const hintId = `${p.id}-hint` return ( <> {/* 字段标签的 for 指不到一组按钮,组名另写在 aria-label 上 */}
    + aria-describedby={[text.hint ? hintId : '', p.describedBy].filter(Boolean).join(' ') || undefined} + className="inline-flex max-w-full flex-wrap gap-0.5 rounded-md border p-0.5"> {TRI.map((t) => ( ))}
    -
    {text.hint[state]}
    + {text.hint &&
    {text.hint[state]}
    } ) } /** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 null)/ true / false */ -export function triStateField(def: { key: string; label: string; text: TriStateText; advanced?: boolean }): FieldDef { +export function triStateField(def: { + key: string; label: string; text: TriStateText; advanced?: boolean; help?: string + setting?: (v: VerifySettings) => boolean | null | undefined +}): FieldDef { return { - key: def.key, label: def.label, type: 'select', advanced: def.advanced, - render: (p) => , + key: def.key, label: def.label, type: 'select', advanced: def.advanced, help: def.help, + render: (p) => , } } @@ -243,17 +316,17 @@ export interface VerifyModelText { /** 模型框的占位:接入也没选 / 选了接入 */ modelFollow: string modelDefault: string - /** 取值链的说明,常显在模型框下面 */ - help: string + /** 取值链的说明,常显在模型框下面。不给就不写(说明在字段的帮助里) */ + help?: string byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => string - /** 和被核查的模型是同一个 */ - same: (model: string) => string + /** 和被核查的模型是同一个(给了 subject 时用) */ + same?: (model: string) => string } /** 被核查的那个模型:节点上明确写着的模型名;说不准(没写、跟随默认)时为 null,不提醒 */ -export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null +export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: readonly CanvasNode[]; edges: readonly CanvasEdge[] }) => string | null -function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) { +export function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) { const providers = useCatalog((s) => s.providers) const provider = typeof p.value === 'string' ? p.value : '' const model = typeof p.config.model === 'string' ? p.config.model.trim() : '' @@ -272,7 +345,7 @@ function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText ) } -function ModelInput({ p, fieldKey, text, subject }: { +export function ModelInput({ p, fieldKey, text, subject }: { p: FieldRenderProps; fieldKey: string; text: VerifyModelText; subject?: SubjectModel }) { const providers = useCatalog((s) => s.providers) @@ -292,7 +365,7 @@ function ModelInput({ p, fieldKey, text, subject }: { const fallback = enabled.find((x) => x.kind !== 'mock') ?? enabled[0] const byModelHint = byModel && providers.length > 0 ? text.byModel(name, owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : '' - const same = !!name && !!against && name === against && !reported + const same = !!name && !!against && name === against && !reported && !!text.same return ( <> - {text.same(name)} + {text.same!(name)}
    )} -
    {text.help}
    + {text.help &&
    {text.help}
    } ) } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index b6e833af..c5115b80 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3350,9 +3350,8 @@ export const DUAL_CHECK_TEXT = { // ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx) /** - * 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在 - * 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」 - * 「裁判模型」的写法 + * 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL,check-verify + * 逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」「裁判模型」的写法 */ field: { card: '口径卡', @@ -3388,7 +3387,10 @@ export const DUAL_CHECK_TEXT = { cardOption: (label: string, caliber?: string | null) => (caliber && caliber !== label ? `${label}(${caliber})` : label), cardGone: (id: string) => `${id}(不在画布上)`, cardNotUpstream: '所选口径卡不在本节点上游,运行到本节点时还没有它的结果。请把它连接到本节点之前', - cardMissing: (id: string) => `口径卡「${id}」不在画布上,请重新选择`, + cardMissingHint: '所选口径卡已不在画布上,请重新选择', + /** 选中的节点在画布上,但不是口径卡(助手改图、导入 JSON 会写出这种配置):下拉里那一项、下面那句提示 */ + cardWrongType: (label: string) => `${label}(不是口径卡)`, + cardWrongTypeHint: (type: string) => `所选节点是「${type}」节点,不是口径卡,请重新选择`, metricsAll: '全部指标', metricsDefault: '未指定:复算全部输入来自查询的指标', metricsPicked: (n: number, total: number) => `已选 ${n} / ${total} 个指标`, @@ -3422,7 +3424,7 @@ export const DUAL_CHECK_TEXT = { }, tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`), toleranceInvalid: '请填写不小于 0 的数', - /** 选项以服务端报错里的短说法(schema.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */ + /** 选项以服务端报错里的短说法(engine/labels.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */ onReject: { degrade: '计入缺口、出具降档(默认)', fail: '判为失败,运行终止' } as Record, onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, enabled: { follow: '跟随设置', on: '开启', off: '关闭' }, diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 931bead6..67c22705 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -24,7 +24,8 @@ // // 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边 // 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉, -// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。 +// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite +// 转译后直接 import。 // // node scripts/check-verify.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs @@ -126,26 +127,23 @@ await section('核查设置的叫法和服务端一字不差', async () => { }) await section('双盲复算检查器的叫法和服务端校验报错一致', async () => { - // 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 schema.py 的 DUAL_CHECK_FIELD_LABEL:检查器上得是同一个名字, - // 人才找得到是哪一项。处置的取值:报错里用短说法,下拉的选项以它开头、后面可以带一句用途(labels.py 的约定) + // 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 labels.py 的 DUAL_CHECK_FIELD_LABEL(期 4 从 schema.py 挪过去, + // field_label 按节点类型也说这一套):检查器上得是同一个名字,人才找得到是哪一项。处置的取值:报错里用短说法,下拉的 + // 选项以它开头、后面可以带一句用途(labels.py 的约定) const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts')) const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled'] check('检查器的字段名按配置键放在 DUAL_CHECK_TEXT.field', KEYS.every((k) => typeof D.field?.[k] === 'string' && D.field[k]), KEYS.filter((k) => !D.field?.[k]).join('、')) check('……模型的两项照「裁判模型 · 接入」「裁判模型」的叫法', D.field.provider === '盲算模型 · 接入' && D.field.model === '盲算模型', `${D.field.provider} / ${D.field.model}`) - const py = readFileSync(`${root}backend/app/engine/schema.py`, 'utf8') - const block = py.match(/^DUAL_CHECK_FIELD_LABEL = \{([\s\S]*?)^\}/m)?.[1] - if (block == null) { - // 双盲复算的服务端在另一个工作包里,合入之前 schema.py 还没有这一组:合入后这一节就逐项比对 - check('schema.py 还没有 DUAL_CHECK_FIELD_LABEL(双盲复算服务端合入后逐项比对)', true) - return - } - const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) + const py = readFileSync(`${root}backend/app/engine/labels.py`, 'utf8') + const block = py.match(/^DUAL_CHECK_FIELD_LABEL: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] + check('从 labels.py 读出了 DUAL_CHECK_FIELD_LABEL', block != null) + const labels = Object.fromEntries([...(block ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) const wrong = KEYS.filter((k) => labels[k] !== D.field[k]) check('每个字段的叫法和 DUAL_CHECK_FIELD_LABEL 一字不差', !wrong.length && Object.keys(labels).length === KEYS.length, wrong.map((k) => `${k}:${D.field[k]} ≠ ${labels[k]}`).join(';')) - const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL = \{([^}]*)\}/m)?.[1] ?? '' + const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL: dict\[str, str\] = \{([^}]*)\}/m)?.[1] ?? '' const short = Object.fromEntries([...opts.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) check('「未采用原流程时」的选项以服务端的短说法开头', Object.keys(short).length === 2 && Object.entries(short).every(([k, v]) => D.onReject?.[k]?.startsWith(v)), JSON.stringify(short)) @@ -931,11 +929,32 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 check('……标签指向这个下拉', await page.locator('[data-field="card"] label').getAttribute('for') === await card.getAttribute('id')) await card.selectOption('card2') check('选另一张:写进 card', (await config(page)).card === 'card2', JSON.stringify(await config(page))) - check('……不在上游的照实提醒', (await text(page.locator('[data-dual-card-warn]'))).includes('不在本节点上游')) + check('……不在上游的照实提醒', (await text(page.locator('[data-node-pick-hint="notUpstream"]'))).includes('不在本节点上游')) const swapped = await summaryBecomes(page, '复算「月报口径卡」 · 全部 1 个指标') check('……摘要换成这张卡', swapped.ok, swapped.seen) await card.selectOption('') check('选回默认:card 去掉,不留空串', !('card' in (await config(page))) || (await config(page)).card === undefined, JSON.stringify(await config(page))) + // 选口径卡用的是和反方审稿共用的控件(verifyFields.tsx 的 NodePick):写着的卡被删了、写的不是口径卡(助手改图、导入 + // JSON 会写出这种配置)都照实列在下拉里(选不回去)、下面说一句,不让它看着像「默认」 + const setCard = (value) => page.evaluate((v) => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.id === 'dc') + s.updateNode('dc', { config: { ...n.data.config, card: v } }) + }, value) + const selected = () => card.evaluate((el) => el.options[el.selectedIndex]?.textContent) + await setCard('ghost') + await page.waitForTimeout(100) + check('写着的卡已不在画布上:下拉照实标出,下面说一句', await selected() === 'ghost(不在画布上)' + && (await text(page.locator('[data-node-pick-hint="missing"]'))) === '所选口径卡已不在画布上,请重新选择', await selected()) + await setCard('fetch') + await page.waitForTimeout(100) + const wrongType = page.locator('[data-node-pick-hint="wrongType"]') + check('写的不是口径卡:下拉标「不是口径卡」,提示说清它是哪类节点,不说成不在画布上', await selected() === '取数(不是口径卡)' + && await wrongType.count() === 1 && (await text(wrongType)).startsWith('所选节点是「') && (await text(wrongType)).endsWith('」节点,不是口径卡,请重新选择') + && await page.locator('[data-node-pick-hint="missing"]').count() === 0, `${await selected()} · ${await wrongType.count() ? await text(wrongType) : '没有提示'}`) + await card.selectOption('') + check('……选回默认:提示收起、card 去掉', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === undefined, + JSON.stringify(await config(page))) // 指标:逐个勾;全部勾着是缺省,不写;最后一个不让取消 const opts = page.locator('[data-dual-metric-option]') @@ -973,16 +992,18 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 // 启用:跟随设置 / 开启 / 关闭 const enabled = page.locator('[data-field="enabled"] [role="radiogroup"]') + check('启用用的是和反方审稿共用的三态控件,组名是字段名', (await enabled.getAttribute('data-tri-state')) === 'follow' + && (await enabled.getAttribute('aria-label')) === D.field.enabled) check('启用:三态,跟随设置写出设置里眼下是开着的', (await enabled.locator('[role="radio"]').allInnerTexts()).join('|') === '跟随设置(当前开启)|开启|关闭' - && await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-value') === 'follow') - await enabled.locator('[data-value="off"]').click() + && await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-tri') === 'follow') + await enabled.locator('[data-tri="off"]').click() const off = await summaryBecomes(page, { suffix: ' · 已关闭' }) check('关闭:enabled 为 false,摘要标「已关闭」', (await config(page)).enabled === false && off.ok, off.seen) - await enabled.locator('[data-value="off"]').focus() + await enabled.locator('[data-tri="off"]').focus() await page.keyboard.press('ArrowLeft') check('键盘方向键换选项:开启是 true', (await config(page)).enabled === true - && await page.evaluate(() => document.activeElement?.getAttribute('data-value')) === 'on') - await enabled.locator('[data-value="follow"]').click() + && await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on') + await enabled.locator('[data-tri="follow"]').click() check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page))) // 未采用原流程时 @@ -1007,7 +1028,7 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 check('模型:写进 model', (await config(page)).model === 'model-b') await provider.selectOption('') check('只填了模型:接入留空那一项改叫「按模型名找接入」,写明按模型名用哪个接入', (await provider.locator('option').first().innerText()) === '按模型名找接入' - && (await text(page.locator('[data-dual-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined) + && (await text(page.locator('[data-verify-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined) const tol = page.locator('[data-field="tolerance"] input') check('允许的相对误差:占位写出设置里的值', await tol.getAttribute('placeholder') === '跟随设置(0.000001)', await tol.getAttribute('placeholder')) await tol.fill('0.001') @@ -1035,20 +1056,20 @@ await section('检查器:上游有几张口径卡时必须选;「跟随设 await page.waitForFunction(() => document.querySelector('[data-field="card"] select option')?.textContent?.includes('2 张')) check('上游两张:留空那一项写明要选,不说「建议」', (await card.locator('option').first().innerText()) === '请选择要复算的口径卡(上游有 2 张)' && !(await text(page.locator('[data-field="card"]'))).includes('建议'), await card.locator('option').first().innerText()) - check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-dual-card-warn]'))) === '上游有 2 张口径卡,请选择要复算的那一张') + check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 张口径卡,请选择要复算的那一张') const many = await summaryBecomes(page, '复算上游的口径卡 · 上游有 2 张,请指定') check('……节点卡片的摘要也这么说', many.ok, many.seen) await card.selectOption('card') - check('……选了一张:提醒收起', await page.locator('[data-dual-card-warn]').count() === 0 && (await config(page)).card === 'card') + check('……选了一张:提醒收起', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === 'card') // 去设置里关掉双盲复算、改了相对误差,再回到画布打开这个节点:「跟随设置」写的是新的值 - const enabled = page.locator('[data-field="enabled"] [role="radio"][data-value="follow"]') + const enabled = page.locator('[data-field="enabled"] [role="radio"][data-tri="follow"]') check('打开时:跟随设置(当前开启)', (await text(enabled)) === '跟随设置(当前开启)', await text(enabled)) verifyNow = { ...fx.verify, dual_check: { ...fx.verify.dual_check, enabled: false, tolerance: 0.01 } } await page.evaluate(() => window.__studio.setState({ selectedId: null })) await page.waitForSelector('[data-field="enabled"]', { state: 'detached' }) await page.evaluate(() => window.__studio.setState({ selectedId: 'dc' })) - await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-value="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 }) + await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-tri="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 }) .catch(() => {}) check('再打开:重取设置,写当前关闭', (await text(enabled)) === '跟随设置(当前关闭)', await text(enabled)) await page.locator('button', { hasText: '高级选项' }).click() @@ -1071,7 +1092,7 @@ await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色 check(`${width}px(${theme}):检查器不横向溢出`, w.page <= 0 && w.panel <= 0 && w.body <= 0, JSON.stringify(w)) const c = await page.evaluate(() => { const base = getComputedStyle(document.querySelector('[data-dual-inspector]')).backgroundColor - const el = document.querySelector('[data-dual-by-model]') + const el = document.querySelector('[data-verify-by-model]') const radio = document.querySelector('[data-field="enabled"] [role="radio"][aria-checked="true"]') return { base, hint: el && getComputedStyle(el).color, radio: radio && getComputedStyle(radio).color, radioBg: radio && getComputedStyle(radio).backgroundColor } From b0cf23dddafed69bde0a02fbeb4320a35a26634e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:11:02 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=8D=A1=E7=9A=84=E5=86=99=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=A2=84=E5=91=8A=E5=92=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=80=90?= =?UTF-8?q?=E5=AD=97=E4=B8=80=E8=87=B4=EF=BC=8C=E7=9B=B2=E7=AE=97=E7=9A=84?= =?UTF-8?q?=E7=90=86=E7=94=B1=E6=8C=89=E7=90=86=E7=94=B1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E7=94=BB=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预告逐条说:会写的说「哪张表的哪一项会记为……」,不写的照服务端的原话说「不会记入:原因」(和裁决后时间线上跳过的原因一字不差),不再只有一句「不记」看不出为什么。三条分行列出,原因再长窄屏也不撑宽 - 被目录挡下的一边(已确认、驳回过、和已有条件合不起来)服务端照样带着写到哪,卡上据此认出几个指标是不是落到同一项上 - 同一次裁决里几个指标按当前的选择落到同一项上时,照完整对照里服务端算好的合并预告(catalog_joint)说:会写的是合起来的值、写明和哪几个指标一起记入,不写的照原因说;选择一变跟着换成那一组合的结果。工件里没有(老服务端、组合太多、工件还没取到)或认不出时,回到按其余指标当前的选择有条件地推 - 盲算的理由:对照工件带了 rationale_doc(核查 agent 组装、裸数字已标出)时按文档画,和反方审稿的理由同一种画法:有出处的数字画实线、挂盲算自己的查询编号,裸数字画点状线、挂「无证据」,下面再醒目地说一句;老工件只有正文,照旧纯文本 - 夹具 dual_check.json 补 catalog_blocked(目录里已有人工确认的业务日期时的真实预告)、merges 各组合的 catalog 与 joint(服务端按那一组合的目录算的真实输出)、filters_rejected(驳回过的条件),盲算理由换成 compose_doc 的真实输出;test_dual_check_fixture 核对预告与服务端一字不差、joint 里按选择取出的那一条就是真写的结果 - check-verify 补三节:被目录挡下的一边、照合并预告说并和真写核对、合并预告认不出时回退;理由文档的实线、裸数字、提醒和老工件的纯文本 - dual_check_facts._write 的返回标注改成和 _record 一致(写进去的说法或 None) --- backend/app/engine/dual_check_facts.py | 2 +- backend/tests/test_dual_check_fixture.py | 90 ++- frontend/src/dev/fixtures/dual_check.json | 694 +++++++++++++++++++++- frontend/src/dev/fixtures/dual_check.tsx | 8 +- frontend/src/lib/terms.ts | 26 +- frontend/src/run/DualCheckApproval.tsx | 325 ++++++---- scripts/check-verify.mjs | 203 ++++++- 7 files changed, 1186 insertions(+), 162 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 4f101d61..e3b0a875 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -869,7 +869,7 @@ def preview_joint(candidates: Sequence[Mapping[str, Any]], async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node_title: str, - actor: str | None) -> tuple[bool, list[str]]: + actor: str | None) -> tuple[Any, list[str]]: """写一项:(写进去的说法,没写为 None, 没写的原因)。几个指标并成的一项先按读到的目录拿掉各自带回驳回说法的指标 (_screen),写什么、写不写再由 _plan 定;备注只列真写进去的指标。版本对不上(别人刚改过)重读再来。""" label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item) diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index 47f8df79..9b44c95d 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -16,11 +16,17 @@ variants 和 peers 各自的 catalog)也是服务端的真实输出:按下 服务端合入之前没有这个模块,逐项比对那几项跳过,形状和门槛照样查。 **同一次裁决里几个指标落到同一项上。** 预告是每个指标单独算的,服务端写目录前却要把同一次裁决里落到同一张表同一项上的 -说法归拢(业务日期、去重规则说法不同的一项都不写,有效记录条件合成一句写),卡上按其余指标当前的选择补上这一层 -(2026-10 第 3 轮复核:两个指标分开选,卡上各自说「记为……」,服务端一项都不写)。peers 是换掉「退款率」对不上的那个 -输入的几对 SQL,和「销售额」(或换上的变体)落到数据目录的同一项上;merges 是检查脚本看的几种组合,written 是服务端 -按这几种选择真写一遍(record_choices,空目录、临时数据源)的结果。它要建数据源,regenerate 不生成:测试报不一致时, -照报出的实际结果改 written,再看审批卡的说法要不要跟着改。 +说法归拢(业务日期、去重规则说法不同的一项都不写,有效记录条件合成一句写,驳回过的拿出来不写)。服务端把每种组合按 +真写的办法算好放进对照工件(dual_check_facts.preview_joint → catalog_joint),卡上按各指标当前的选择取;工件里没有时 +卡上按其余指标当前的选择有条件地推。peers 是换掉「退款率」对不上的那个输入的几对 SQL,和「销售额」(或换上的变体) +落到数据目录的同一项上;merges 是检查脚本看的几种组合:notes 是这一组合发起审批时的目录(不写就是空目录),catalog +是两个指标按它各自的预告,joint 是 preview_joint 的真实输出(这两项 regenerate 重新生成),written 是服务端按这几种 +选择真写一遍(record_choices,临时数据源)的结果——它要建数据源,regenerate 不生成:测试报不一致时,照报出的实际结果 +改 written,再看审批卡的说法要不要跟着改。joint 里按这一组合的选择取出的那一条,和 written 一字不差(会写的值、不写的 +原因)。 + +**挡下的一边。** catalog_blocked 是「销售额」那一对 SQL 在目录里已有人工确认的业务日期时的预告(notes 是那时的目录): +两边都认出了事实、都被目录挡下,照样带着写到哪和原因,审批卡逐条说「不会记入:原因」。 重新生成夹具里的差异说明和预告(只改 diff、摘要的 diff 和 fallback、预告,其余原样;没有预告的模块时预告不动): cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py @@ -136,11 +142,9 @@ def _merge_metrics(fx: dict[str, Any], case: dict[str, Any]) -> list[tuple[str, ("refund_rate", PEER_METRIC, tuple(peer["sql"]), peer["catalog"])] -def _shown(side: dict[str, Any]) -> dict[str, Any]: - """一边的预告里审批卡用得到的:写不写,写的话写到哪、写成什么。不写的原因只在时间线上给人看,措辞不比。""" - if side.get("write") is True: - return {k: side.get(k) for k in ("write", "source", "table", "item", "value")} - return {"write": side.get("write")} +def _merge_candidates(fx: dict[str, Any], case: dict[str, Any]) -> list[dict[str, Any]]: + """merges 里一种组合交给服务端的候选(不带 choice):两个指标各自的那一对 SQL,带上指标 id(preview_joint 按它说结果)。""" + return [{**_candidate(metric, *sql), "metric_id": metric_id} for metric_id, metric, sql, _ in _merge_metrics(fx, case)] def _server(name: str) -> Any: @@ -209,14 +213,41 @@ def test_catalog_previews_have_the_server_shape(): def test_catalog_previews_follow_the_server(): - """预告里审批卡用得到的部分(写不写、写到哪、写成什么)和服务端(dual_check_facts.preview)一致:服务端改了写目录 - 的规则,这里先变红——审批卡上照预告说的、检查脚本按预告核对的有条件说法都要跟着看一遍。""" + """预告和服务端(dual_check_facts.preview)一字不差:写不写、写到哪、写成什么,不写的原因(审批卡逐条照原话说「不会 + 记入:原因」)。服务端改了写目录的规则或说法,这里先变红——审批卡上照预告说的、检查脚本按预告核对的说法都要跟着看一遍。""" preview = _server("preview") if preview is None: pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入") for name, metric, original, blind, _, expected in _preview_cases(_load()): - got = preview(_candidate(metric, original, blind), SCHEMA, {}) - assert {s: _shown(got[s]) for s in ("a", "b")} == {s: _shown(expected[s]) for s in ("a", "b")}, name + assert preview(_candidate(metric, original, blind), SCHEMA, {}) == expected, name + + +def test_a_blocked_side_still_says_where_it_would_write(): + """目录里已有人工确认的业务日期:两边都认出了事实、都被挡下——照样带着写到哪、本来要写什么和原因,和服务端一字不差""" + preview = _server("preview") + if preview is None: + pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入") + fx = _load() + blocked = fx["catalog_blocked"] + gmv = fx["card"]["metrics"][0] + (a, b), = _differing(gmv) + assert preview(_candidate(gmv["name"], a["sql"], b["sql"]), SCHEMA, blocked["notes"]) == blocked["catalog"] + for side in ("a", "b"): + said = blocked["catalog"][side] + assert said["write"] is False and said["reason"] and said["table"] == "orders" and said["item"] == "business_date" + + +def test_merges_carry_the_server_joint_preview(): + """merges 里每一种组合:两个指标各自的预告(catalog)和合起来的预告(joint)都是服务端按这一组合的目录算出来的""" + preview, preview_joint = _server("preview"), _server("preview_joint") + if preview is None or preview_joint is None: + pytest.skip("双盲复算的服务端(dual_check_facts.preview_joint)还没合入") + fx = _load() + for name, case in fx["merges"].items(): + notes = case.get("notes") or {} + candidates = _merge_candidates(fx, case) + assert case["catalog"] == {c["metric_id"]: preview(c, SCHEMA, notes) for c in candidates}, name + assert case["joint"] == preview_joint(candidates, {SOURCE: (SCHEMA, notes)}), name def test_merges_put_two_metrics_on_the_same_item(): @@ -226,8 +257,9 @@ def test_merges_put_two_metrics_on_the_same_item(): fx = _load() assert fx["merges"] for name, case in fx["merges"].items(): - sides = [said[case["choices"][metric_id]] for metric_id, _, _, said in _merge_metrics(fx, case)] - assert all(s["write"] is True for s in sides), name + sides = [case["catalog"][metric_id][case["choices"][metric_id]] for metric_id, _, _, _ in _merge_metrics(fx, case)] + # 落到同一项上的:会写的、被目录挡下的都算(都带着写到哪),服务端照样把它们归拢成一项 + assert all("table" in s for s in sides), name targets = {(s["source"], s["table"], s["item"]) for s in sides} assert len(targets) == 1, name assert all((SOURCE, w["table"], w["item"]) in targets for w in case["written"]), name @@ -263,15 +295,27 @@ async def test_merged_choices_follow_the_server(make_source): fx = _load() for name, case in fx["merges"].items(): source, source_id = await make_source() - candidates = [{**_candidate(metric, *sql), "source": source, "choice": case["choices"][metric_id]} - for metric_id, metric, sql, _ in _merge_metrics(fx, case)] + for table, notes in (case.get("notes") or {}).items(): + async with SessionLocal() as session: + await catalog.write_entry(session, source_id, table, notes, if_version=0, actor="数据组") + candidates = [{**c, "source": source, "choice": case["choices"][c["metric_id"]]} + for c in _merge_candidates(fx, case)] out = await record(candidates, run_id="run-dc-0001", node_title="复核周报口径", actor=None) written = [] async with SessionLocal() as session: for w in out["written"]: entry = await catalog.read_entry(session, source_id, w["table"]) + assert w["value"] == entry.notes[w["item"]]["value"], name written.append({"table": w["table"], "item": w["item"], "value": entry.notes[w["item"]]["value"]}) assert written == case["written"], (name, out["skipped"]) + # 审批卡按这一组合的选择从 joint 里取的那一条,就是真写的结果:会写的值、不写的原因一字不差 + chosen = {c["metric_id"]: case["choices"][c["metric_id"]] for c in candidates} + [entry] = [e for e in case["joint"] if {m["metric_id"]: m["choice"] for m in e["members"]} == chosen] + for metric_id, said in entry["outcomes"].items(): + if said["write"]: + assert {"table": entry["table"], "item": entry["item"], "value": said["value"]} in written, (name, metric_id) + else: + assert said["reason"] in [s["reason"] for s in out["skipped"]], (name, metric_id, out["skipped"]) def regenerate() -> None: @@ -294,6 +338,16 @@ def regenerate() -> None: for group, metric_name in SWAPS.items(): for swap in fx[group].values(): swap["catalog"] = preview(_candidate(metric_name, *swap["sql"]), SCHEMA, {}) + gmv = fx["card"]["metrics"][0] + (a, b), = _differing(gmv) + blocked = fx["catalog_blocked"] + blocked["catalog"] = preview(_candidate(gmv["name"], a["sql"], b["sql"]), SCHEMA, blocked["notes"]) + if (preview_joint := _server("preview_joint")) is not None: + for case in fx["merges"].values(): + notes = case.get("notes") or {} + candidates = _merge_candidates(fx, case) + case["catalog"] = {c["metric_id"]: preview(c, SCHEMA, notes) for c in candidates} + case["joint"] = preview_joint(candidates, {SOURCE: (SCHEMA, notes)}) FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 6d2a75ae..ba60e4a1 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -1,5 +1,5 @@ { - "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants、peers 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改;merges 里的 written 是服务端按那几种选择真写一遍(record_choices)的结果,同一个测试核对,它要建临时数据源才算得出,不重新生成,报不一致时照报出的实际结果改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说;peers 换掉「退款率」对不上的那个输入,和「销售额」落到数据目录的同一项上,merges 是检查脚本看的几种组合(同一次裁决里几个指标说法不同、要合写时卡上怎么说)", + "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants、peers 里的 catalog(采用哪一边会往数据目录里写什么)、catalog_blocked(目录里已有人工确认的业务日期时「销售额」的预告)、merges 里的 catalog 和 joint(几个指标落到同一项上时服务端算好的合并预告)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改;merges 里的 written 是服务端按那几种选择真写一遍(record_choices)的结果,同一个测试核对,它要建临时数据源才算得出,不重新生成,报不一致时照报出的实际结果改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说;peers 换掉「退款率」对不上的那个输入,和「销售额」落到数据目录的同一项上,merges 是检查脚本看的几种组合(notes 是那一组合发起审批时的目录,不写就是空目录)。card 里「销售额」盲算一边的 rationale_doc 是核查 agent 组装理由文档的真实输出(evidence.compose_doc,含一个没有出处的数字),「退款率」的几个输入没有这一项(同老工件)", "approval": { "id": "ap-dc-0001", "run_id": "run-dc-0001", @@ -76,7 +76,164 @@ "sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE created_at >= '2026-09-01'\n AND created_at < '2026-09-08'", "source": "shop", "alias": "B1", - "rationale": "销售额按下单时间统计本周全部订单金额之和" + "rationale": "销售额按下单时间 created_at 归属到当周,本周全部订单金额之和是 51,388.3,比上周多 1200。", + "rationale_doc": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-dc-0001", + "node_id": "dc", + "markdown": "销售额按下单时间 created_at 归属到当周,本周全部订单金额之和是 51,388.3,比上周多 1200。", + "source": "销售额按下单时间 created_at 归属到当周,本周全部订单金额之和是 [[v:Q1.r0.gmv]],比上周多 1200。[[see:Q1]]", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:b615a51b194e1ba1", + "locator": {}, + "node_id": "dc", + "exec": 1, + "artifact": "72a1a5e4698968eb90c3af92928aed35ea4e302a88607b7ead29022cb33bb867", + "call_id": "blind-1", + "tool": "db_query__shop", + "source": "shop", + "columns": [ + "gmv" + ], + "rows": 1, + "truncated": false, + "label": "db_query__shop · 1 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 57 + ], + "cites": [ + "Q1" + ], + "see": [ + { + "ref": "Q1", + "role": "support", + "alias": "Q1", + "kind": "query", + "status": "resolved", + "eid": "ev:query:b615a51b194e1ba1", + "locator": {} + } + ], + "segments": [ + { + "kind": "text", + "text": "销售额按下单时间 created_at 归属到当周,本周全部订单金额之和是 ", + "state": "neutral", + "span": [ + 0, + 38 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "51,388.3", + "ref": "v:Q1.r0.gmv", + "cite": { + "ref": "Q1.r0.gmv", + "alias": "Q1", + "locator": { + "row": 0, + "column": "gmv" + }, + "eid": "ev:cell:5a48378137ed04ed", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 51388.3, + "rendered": "51,388.3" + }, + "state": "deterministic", + "span": [ + 38, + 46 + ], + "id": "s1" + }, + { + "kind": "text", + "text": ",比上周多 ", + "span": [ + 46, + 52 + ], + "state": "neutral", + "id": "s2" + }, + { + "kind": "number", + "text": "1200", + "span": [ + 52, + 56 + ], + "state": "none", + "issue": "uncited_number", + "id": "s3" + }, + { + "kind": "text", + "text": "。", + "span": [ + 56, + 57 + ], + "state": "neutral", + "id": "s4" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 5, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 2, + "numbers_cited": 1, + "values": 0, + "uncited_numbers": 1, + "unresolved": 0, + "see": 1, + "uncited_claims": 0, + "violations": 1 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「1200」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「1200」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 52, + 56 + ], + "text": "1200", + "context": "之和是 51,388.3,比上周多 1200。", + "segment": "s3", + "unit": "u0" + } + ] + } } ] }, @@ -778,7 +935,159 @@ "gmv": "a", "refund_rate": "b" }, - "written": [] + "written": [], + "catalog": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": { + "column": "paid_at" + } + }, + "refund_rate": { + "write": true, + "value": { + "column": "paid_at" + } + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": { + "column": "created_at" + } + }, + "refund_rate": { + "write": true, + "value": { + "column": "created_at" + } + } + } + } + ] }, "time_same": { "gmv": null, @@ -795,6 +1104,158 @@ "column": "paid_at" } } + ], + "catalog": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": { + "column": "paid_at" + } + }, + "refund_rate": { + "write": true, + "value": { + "column": "paid_at" + } + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": { + "column": "created_at" + } + }, + "refund_rate": { + "write": true, + "value": { + "column": "created_at" + } + } + } + } ] }, "dedup_columns": { @@ -804,7 +1265,62 @@ "gmv": "a", "refund_rate": "a" }, - "written": [] + "written": [], + "catalog": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "dedup", + "value": "对 user_id 计数时去重" + }, + "b": { + "write": false, + "reason": "指标「销售额」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录" + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "dedup", + "value": "对 id 计数时去重" + }, + "b": { + "write": false, + "reason": "指标「退款率」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录" + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "dedup", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的去重规则给出了不同的说法(指标「销售额」采纳的是「对 user_id 计数时去重」;指标「退款率」采纳的是「对 id 计数时去重」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的去重规则给出了不同的说法(指标「销售额」采纳的是「对 user_id 计数时去重」;指标「退款率」采纳的是「对 id 计数时去重」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + } + ] }, "filters": { "gmv": "filter_extra", @@ -819,7 +1335,177 @@ "item": "valid_filter", "value": "status <> 'test' AND status <> 'cancelled'" } + ], + "catalog": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'test'" + }, + "b": { + "write": false, + "reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'cancelled'" + }, + "b": { + "write": false, + "reason": "指标「退款率」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "valid_filter", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": "status <> 'test' AND status <> 'cancelled'" + }, + "refund_rate": { + "write": true, + "value": "status <> 'test' AND status <> 'cancelled'" + } + } + } ] + }, + "filters_rejected": { + "gmv": "filter_extra", + "refund_rate": "filter", + "choices": { + "gmv": "a", + "refund_rate": "a" + }, + "notes": { + "orders": { + "valid_filter": { + "value": "status <> 'test'", + "source": "llm", + "status": "rejected" + } + } + }, + "written": [ + { + "table": "orders", + "item": "valid_filter", + "value": "status <> 'cancelled'" + } + ], + "catalog": { + "gmv": { + "a": { + "write": false, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'test'", + "reason": "「orders」的有效记录条件「status <> 'test'」曾被人工驳回,与这次指标「销售额」的选择相反,未写入。如需更改,请到数据目录中修改" + }, + "b": { + "write": false, + "reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'cancelled'" + }, + "b": { + "write": false, + "reason": "指标「退款率」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "valid_filter", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "「orders」的有效记录条件「status <> 'test'」曾被人工驳回,与这次指标「销售额」的选择相反,未写入。如需更改,请到数据目录中修改" + }, + "refund_rate": { + "write": true, + "value": "status <> 'cancelled'" + } + } + } + ] + } + }, + "catalog_blocked": { + "notes": { + "orders": { + "business_date": { + "value": { + "column": "paid_at" + }, + "source": "human", + "status": "confirmed" + } + } + }, + "catalog": { + "a": { + "write": false, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + }, + "reason": "「orders」的业务日期已是人工确认过的「paid_at」,无需重复写入" + }, + "b": { + "write": false, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + }, + "reason": "「orders」的业务日期已有人工确认过的说法「paid_at」,与这次指标「销售额」采纳的「created_at」不同,未覆盖。如需更改,请到数据目录中修改" + } } } } diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx index 12f7d370..b89ed5f8 100644 --- a/frontend/src/dev/fixtures/dual_check.tsx +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -28,7 +28,10 @@ import data from './dual_check.json' * - peer=<名字>:「退款率」的摘要同样换成 peers 里那一对 SQL 的。peers 和「销售额」(或换上的变体)落到数据目录的 * 同一项上,看同一次裁决里几个指标说法不同、要合写时卡上怎么说(夹具的 merges) * - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体、peers 的 - * 指标用它们自己的 catalog)。catalog=bad:「销售额」带一份认不出的预告 + * 指标用它们自己的 catalog)。catalog=bad:「销售额」带一份认不出的预告。catalog=blocked:「销售额」带目录里已有人工 + * 确认的业务日期时的预告(catalog_blocked:两边都被挡下,各带写到哪和原因) + * - merge=<名字>:两个指标的预告换成 merges 里那一种组合按它的目录算的(catalog;和 variant、peer 一起用,换上的 SQL + * 照它的 gmv、refund_rate 写)。合起来的预告(joint)在完整对照里,由检查脚本按同一种组合伪造工件 * - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」 */ const approval = (patch: Partial = {}): Approval => ({ @@ -44,6 +47,7 @@ function approvalOf(search: string): Approval { const variant = (data.variants as unknown as Record)[q.get('variant') ?? ''] const peer = (data.peers as unknown as Record)[q.get('peer') ?? ''] const catalog = q.get('catalog') + const merge = (data.merges as unknown as Record }>)[q.get('merge') ?? ''] const onReject = q.get('on_reject') const previews = data.catalog_preview as unknown as Record const summary = data.approval.payload.summary.map((s) => { @@ -54,6 +58,8 @@ function approvalOf(search: string): Approval { ...(items ? { diff: items.slice(0, 3).map((i) => i.text), fallback: !items.some((i) => i.aspect !== 'other' && i.aspect !== 'parse') } : {}), ...(catalog === '1' ? { catalog: swapped ? swapped.catalog : previews[s.metric_id] } : {}), ...(catalog === 'bad' && s.metric_id === 'gmv' ? { catalog: { a: { write: true, table: 'orders' }, b: { write: false } } } : {}), + ...(catalog === 'blocked' && s.metric_id === 'gmv' ? { catalog: data.catalog_blocked.catalog } : {}), + ...(merge?.catalog?.[s.metric_id] ? { catalog: merge.catalog[s.metric_id] } : {}), } }) return approval({ payload: { ...data.approval.payload, summary, ...(onReject ? { on_reject: onReject } : {}) } }) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index c5115b80..b83b3c54 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3465,6 +3465,8 @@ export const DUAL_CHECK_TEXT = { textDiff: '规范化后不同的行', textDiffNone: '这一边没有不同的行', rationale: '盲算的说明', + /** 理由文档里有没有出处的数字时,理由下面那一句提醒 */ + rationaleBare: '理由中标出的数字没有引用盲算自己的查询结果,无法核对出处', /** * 服务端没有预告(老服务端,见 catalogPreview)、两边又只差一处时,提交前说清选择可能被记进数据目录。卡上只看得到 * 差异的方面,看不到服务端写目录的其余条件(dual_check_facts.derive:只有一边按时间筛的不记,多出的条件得在排除 @@ -3483,21 +3485,27 @@ export const DUAL_CHECK_TEXT = { tail: '。写入的项记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准', }, /** - * 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择各一句,写的说哪张表的 - * 哪一项记成什么,不写的只说不记(原因在裁决后的时间线上)。两边都不写时卡上不提数据目录。卡上是 - * 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重。 + * 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择逐条一句,会写的说哪张表 + * 的哪一项会记为什么,不写的说不会记入和原因(服务端的原话,和裁决后时间线上跳过的原因一字不差)。两边都认不出 + * 可记的事实(都没有写到哪)时卡上不提数据目录。卡上是「head + 三条 + tail」,审核人当前选的那一条加重。 * - * 预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged): - * 业务日期、去重规则说法不同的一项都不写(conflict),有效记录条件用 AND 合成一句写(joined)。卡上按其余指标当前的 - * 选择补上这一层,tail 里说明预告是按当前的选择算的 + * 同一次裁决里几个指标按当前的选择落到同一张表同一项上时,服务端写目录前把它们归拢(dual_check_facts._merged): + * 说法相同的写一次,有效记录条件用 AND 合成一句,业务日期、去重规则说法不同的都不写,驳回过的拿出来不写。对照工件 + * 里有服务端把每种组合算好的结果(catalog_joint)时照它说:会写的是合起来的那个值,并写明和哪几个指标一起记 + * (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择有条件地说(conflict、joined), + * tail 里说明预告是按当前的选择算的 */ catalogPreview: { head: '记入数据目录:', /** where 同时间线(log.catalogItem):「orders」(shop)的业务日期;value 是要写进去的说法 */ - write: (choice: string, where: string, value: string) => `${choice}时,${where}记为「${value}」`, + write: (choice: string, where: string, value: string) => `${choice}时,${where}会记为「${value}」`, /** 两边写的是同一张表的同一项、只差取值时,后一边省掉「哪张表的哪一项」 */ - writeSame: (choice: string, value: string) => `${choice}时记为「${value}」`, + writeSame: (choice: string, value: string) => `${choice}时会记为「${value}」`, + /** reason 是服务端的原话 */ + blocked: (choice: string, reason: string) => `${choice}时不会记入:${reason}`, skip: (choice: string) => `${choice}时不记`, + /** 接在 write / writeSame 后面:和这些指标按当前的选择一起记入同一项(服务端算好的结果) */ + together: (metrics: string) => `,与${metrics}的选择一起记入`, /** metrics 是 metricNames 写好的、和这一边说法不同的指标(按它们当前的选择) */ conflict: (choice: string, where: string, metrics: string) => `${choice}时,${where}与${metrics}的选择说法不同,都不记`, /** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */ @@ -3507,7 +3515,7 @@ export const DUAL_CHECK_TEXT = { /** 「退款率」、「订单数」;多于 3 个只列前 3 个 */ metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、') + (names.length > 3 ? `等 ${names.length} 个指标` : ''), - tail: '。写入的项记为人工确认的事实;以上按发起审批时的数据目录和各指标当前的选择预告,实际写入以时间线上的记录为准', + tail: '写入的项记为人工确认的事实。以上按发起审批时的数据目录和各指标当前的选择预告,审批期间数据目录有改动时,以时间线上的记录为准', }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 601ca785..048e0ad2 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -1,17 +1,20 @@ -import { Fragment, useEffect, useId, useMemo, useState } from 'react' +import { useEffect, useId, useMemo, useState } from 'react' import type { CSSProperties } from 'react' import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { useCatalog } from '../store/catalog' +import { asDoc } from '../store/evidence' import { useStudio } from '../store/studio' import { Spinner, useToast } from '../components/ui' +import { segmentState } from '../lib/evidence' import { NONE } from '../lib/format' import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms' import { patchValueText } from './catalogPatch' +import { RationaleDoc } from './ChallengeSummary' import type { Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply, - DualCheckSummaryItem, + DualCheckSummaryItem, EvidenceDocData, } from '../types' const T = DUAL_CHECK_TEXT.approval @@ -59,24 +62,36 @@ interface MetricView { full: DualCheckMetric | null } +/** 盲算一边的输入,约定之外服务端另带核查 agent 组装的理由文档(期 4;老工件没有) */ +type BlindInput = DualCheckBlindInput & { rationale_doc?: unknown } +/** 完整对照,约定之外服务端另带几个指标落到同一项上时每种选择组合的预告(期 4;老工件没有) */ +type Card = DualCheckCard & { catalog_joint?: unknown } + /** - * 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么(value 是给人看的 - * 写法,raw 是原样的取值,和别的指标的说法比是不是同一个时用) + * 写到数据目录的哪一项:数据源、表、目录项(business_date / valid_filter / dedup)。服务端写目录前按这三样归拢同一次 + * 裁决里的几个指标;where 是给人看的说法(「orders」(shop)的业务日期) + */ +interface Target { table: string; source: string | null; item: string; where: string } + +/** + * 服务端预告的一边。写的:写到哪、写成什么(value 是给人看的写法,raw 是原样的取值,和别的指标的说法比是不是同一个时 + * 用)。不写的:reason 是服务端的原话;认出了事实、只是被目录挡下的(已确认、驳回过、和已有条件合不起来)另带 target + * 和本来要写的 raw——服务端照样会把它和落到同一项上的别的指标归拢,卡上据此认同一项上有谁 */ type PreviewSide = - | { write: true; table: string; source: string | null; item: string; where: string; value: string; raw: unknown } - | { write: false } -type WritingSide = Extract + | { write: true; target: Target; value: string; raw: unknown } + | { write: false; target: Target | null; reason: string | null; raw?: unknown } interface CatalogPreview { a: PreviewSide; b: PreviewSide } /** - * 同一次裁决里,别的指标按当前的选择落到同一项上(同一数据源、同一张表、同一项)的:differ 是说法和这一边不同的指标名 - * ——业务日期、去重规则说法不同时服务端一项都不写;joined 是条件和这一边不同的指标名——有效记录条件用 AND 合成一句写 + * 对照工件里的 catalog_joint(dual_check_facts.preview_joint):几个指标按各自的选择落到同一项上时,服务端按真写的办法 + * 算好的每个指标的结果。members 是落到这一项上的指标和各自的选择,outcomes 按指标 id 给结果 */ -interface Peers { differ: string[]; joined: string[] } -type PreviewPeers = Partial> +interface JointOutcome { write: boolean; raw?: unknown; reason?: string | null } +interface JointEntry { source: string | null; table: string; item: string; members: Map; outcomes: Map } const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) const hasSql = (v: unknown): boolean => typeof v === 'string' && !!v.trim() @@ -88,9 +103,9 @@ function summaryOf(raw: unknown): DualCheckSummaryItem[] { } /** 工件内容像不像一份对照:至少得有 metrics 数组,里面的项带 metric_id */ -function cardOf(content: unknown): DualCheckCard | null { - if (!content || typeof content !== 'object' || !Array.isArray((content as DualCheckCard).metrics)) return null - const card = content as DualCheckCard +function cardOf(content: unknown): Card | null { + if (!content || typeof content !== 'object' || !Array.isArray((content as Card).metrics)) return null + const card = content as Card return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') } } @@ -117,22 +132,23 @@ function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: strin return CATALOG_ASPECTS.has(items[0].aspect) ? items[0].aspect as CatalogAspect : null } -/** 预告的一边:写的得有表、认得出的目录项和取值;认不出返回 undefined */ -function previewSideOf(raw: unknown): PreviewSide | undefined { - if (!raw || typeof raw !== 'object') return undefined - const s = raw as Record - if (s.write === false) return { write: false } +/** 预告里写到哪:得有表、认得出的目录项;认不出为 null */ +function targetOf(s: Record): Target | null { const table = str(s.table) const item = str(s.item) - if (s.write !== true || !table || !item || !Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, item) || s.value == null || s.value === '') { - return undefined - } + if (!table || !item || !Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, item)) return null const source = str(s.source) ?? null const label = CATALOG_TABLE_FIELD_LABEL[item as keyof typeof CATALOG_TABLE_FIELD_LABEL] - return { - write: true, table, source, item, - where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value), raw: s.value, - } + return { table, source, item, where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label) } +} + +/** 预告的一边:写的得有写到哪和取值;不写的有没有写到哪都认(老服务端只给原因);认不出返回 undefined */ +function previewSideOf(raw: unknown): PreviewSide | undefined { + if (!isRecord(raw)) return undefined + if (raw.write === false) return { write: false, target: targetOf(raw), reason: str(raw.reason) ?? null, raw: raw.value } + const target = targetOf(raw) + if (raw.write !== true || !target || raw.value == null || raw.value === '') return undefined + return { write: true, target, value: patchValueText(target.item, raw.value), raw: raw.value } } /** @@ -146,11 +162,37 @@ function previewOf(raw: unknown): CatalogPreview | null { return a && b ? { a, b } : null } -/** 两边写的是不是同一个数据源、同一张表的同一项(服务端写目录前按这三样归拢) */ -const sameTarget = (a: PreviewSide, b: PreviewSide): boolean => - a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item +/** 是不是同一个数据源、同一张表的同一项(服务端写目录前按这三样归拢) */ +const sameTarget = (a: Pick | null | undefined, + b: Pick | null | undefined): boolean => + !!a && !!b && a.table === b.table && a.source === b.source && a.item === b.item -const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +/** + * 对照工件里的 catalog_joint:认不出的条目跳过(宁可这一项回到有条件地说,不替服务端编)。每条要有写到哪、至少两个 + * 成员、每个成员都有结果 + */ +function jointOf(card: Card | null): JointEntry[] { + const raw = card?.catalog_joint + if (!Array.isArray(raw)) return [] + const out: JointEntry[] = [] + for (const e of raw) { + if (!isRecord(e) || !Array.isArray(e.members) || !isRecord(e.outcomes)) continue + const table = str(e.table) + const item = str(e.item) + if (!table || !item) continue + const members = new Map() + for (const m of e.members) { + if (isRecord(m) && str(m.metric_id) && (m.choice === 'a' || m.choice === 'b')) members.set(m.metric_id as string, m.choice) + } + const outcomes = new Map() + for (const [id, o] of Object.entries(e.outcomes)) { + if (isRecord(o) && typeof o.write === 'boolean') outcomes.set(id, { write: o.write, raw: o.value, reason: str(o.reason) ?? null }) + } + if (members.size < 2 || [...members.keys()].some((id) => !outcomes.has(id))) continue + out.push({ source: str(e.source) ?? null, table, item, members, outcomes }) + } + return out +} /** 同一项的两个说法是不是同一个(同服务端的 _same_fact):业务日期只看哪一列,其余是文字、压掉多余的空白比 */ function sameFact(item: string, a: unknown, b: unknown): boolean { if (item === 'business_date') return isRecord(a) && isRecord(b) && a.column === b.column @@ -158,36 +200,87 @@ function sameFact(item: string, a: unknown, b: unknown): boolean { return typeof a === 'string' && typeof b === 'string' && flat(a) === flat(b) } +/** 预告里的一条:说什么、这一选会不会写,以及是怎么说出来的(data-* 标记,检查脚本据此核对) */ +interface Clause { + side: DualCheckChoice + text: string + write: boolean + /** 句子里写明了是哪张表的哪一项:同一项的下一条可以省掉 */ + where: boolean + /** 服务端说不写、给了原因 */ + reason: boolean + /** 照服务端算好的合并结果说(catalog_joint) */ + joint: boolean + /** 合并结果里和别的指标一起记入 */ + together: boolean + /** 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined) */ + conflict: boolean + joined: boolean +} + /** - * 每个指标的预告是单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢成一项 - * (dual_check_facts._merged):业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就一项都不写;有效记录 - * 条件都在排除无效记录,用 AND 合成一句写。这里按其余指标当前的选择,给每个指标的两种选择各找出落到同一项上的指标 - * (见 Peers),卡上照它说。只看预告会写的一边:选「两边都不对」、还没选的,服务端不会并进来;预告说不写的,多半是 - * 差异对不上目录项,服务端同样不并。少数是因为目录里已有的说法才不写,服务端仍会把它并进来、可能让这一项都不写, - * 可预告里没有它落到哪一项,卡上看不出,所以结尾说以时间线上的记录为准 + * 每个指标三个选择各一条预告。预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢成 + * 一项(dual_check_facts._merged、_screen、_plan)。按其余指标当前的选择,找出这一选落到同一项上的指标(选了 a、b, + * 那一边认出了写到哪的;被目录挡下的也算——服务端照样并进来): + * - 只有它自己:照它自己的预告说,会写的说会记为什么,不写的说原因; + * - 还有别的:照对照工件里服务端为这几个成员算好的那一条(catalog_joint)说,会写的是合起来的值、写明和谁一起记, + * 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时按旧办法推:会写的一边,业务日期、去重规则说法 + * 不同说都不记,有效记录条件说合成一句,结尾说以时间线上的记录为准 */ -function peersOf(views: MetricView[], choices: Record): Map { - const adopted: { id: string; name: string; side: WritingSide }[] = [] - for (const v of views) { - const c = choices[v.id] - const side = c === 'a' || c === 'b' ? v.preview?.[c] : undefined - if (side?.write) adopted.push({ id: v.id, name: v.name, side }) - } - const out = new Map() - for (const v of views) { - const peers: PreviewPeers = {} - for (const c of ['a', 'b'] as const) { - const mine = v.preview?.[c] - if (!mine?.write) continue - // 说法和这一边不同的指标:业务日期、去重规则让整组都不写,有效记录条件要合成一句 - const names = [...new Set(adopted - .filter((o) => o.id !== v.id && sameTarget(o.side, mine) && !sameFact(mine.item, o.side.raw, mine.raw)) - .map((o) => o.name))] - if (!names.length) continue - peers[c] = mine.item === 'valid_filter' ? { differ: [], joined: names } : { differ: names, joined: [] } +function clausesOf(view: MetricView, views: MetricView[], choices: Record, joint: JointEntry[]): Clause[] { + const P = T.catalogPreview + const blank = { write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false } + const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), ...blank }) + const out: Clause[] = [] + for (const side of ['a', 'b'] as const) { + const mine = view.preview?.[side] + if (!mine) { out.push(skip(side)); continue } + const blocked = (reason: string | null | undefined): Clause => + (reason ? { side, text: P.blocked(T.choices[side], reason), ...blank, reason: true } : skip(side)) + // 前一条已经写明了是哪张表的哪一项,这一条省掉 + const short = side === 'b' && !!out[0]?.where && sameTarget(view.preview?.a.target, mine.target) + const writing = (value: string, extra: Partial = {}, tail = ''): Clause => ({ + side, ...blank, write: true, where: !short, ...extra, + text: (short ? P.writeSame(T.choices[side], value) : P.write(T.choices[side], mine.target!.where, value)) + tail, + }) + const peers = mine.target ? views.filter((o) => { + const c = choices[o.id] + return o.id !== view.id && (c === 'a' || c === 'b') && sameTarget(o.preview?.[c]?.target, mine.target) + }) : [] + if (!peers.length) { + out.push(mine.write ? writing(mine.value) : blocked(mine.reason)) + continue } - out.set(v.id, peers) + const members = new Map([[view.id, side], ...peers.map((o) => [o.id, choices[o.id]] as [string, DualCheckChoice])]) + const entry = joint.find((e) => sameTarget(e, mine.target) + && e.members.size === members.size && [...members].every(([id, c]) => e.members.get(id) === c)) + const outcome = entry?.outcomes.get(view.id) + if (entry && outcome) { + if (!outcome.write || outcome.raw == null || outcome.raw === '') { + out.push({ ...blocked(outcome.reason), joint: true }) + continue + } + // 一起记入的:结果也是写的那几个成员(拿掉驳回的、没写进去的不算) + const with_ = peers.filter((o) => entry.outcomes.get(o.id)?.write).map((o) => o.name) + out.push(writing(patchValueText(entry.item, outcome.raw), { joint: true, together: with_.length > 0 }, + with_.length ? P.together(P.metricNames(with_)) : '')) + continue + } + if (!mine.write) { out.push(blocked(mine.reason)); continue } + // 没有算好的合并结果:照服务端归拢的规矩推。只看会写的那几个(挡下的说不准会怎样) + const differ = [...new Set(peers.filter((o) => { + const theirs = o.preview?.[choices[o.id] as 'a' | 'b'] + return theirs?.write && !sameFact(mine.target!.item, theirs.raw, mine.raw) + }).map((o) => o.name))] + if (differ.length && mine.target!.item !== 'valid_filter') { + const names = P.metricNames(differ) + out.push({ side, ...blank, conflict: true, where: !short, + text: short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], mine.target!.where, names) }) + continue + } + out.push(differ.length ? writing(mine.value, { joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value)) } + out.push(skip('neither')) return out } @@ -302,7 +395,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const cardId = str(payload.card_artifact) ?? '' const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject) - const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null) + const [card, setCard] = useState<{ id: string; data?: Card | null } | null>(null) useEffect(() => { if (!cardId) return let alive = true @@ -324,8 +417,10 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const [note, setNote] = useState('') const [busy, setBusy] = useState(false) const pending = views.filter((v) => !choices[v.id]).length - // 预告要按其余指标当前的选择补上「同一项上说法不同」「合成一句」:选择一变,各块的说法跟着变 - const peers = useMemo(() => peersOf(views, choices), [views, choices]) + // 预告要按其余指标当前的选择算:几个指标落到同一项上时照完整对照里服务端算好的合并结果说,选择一变,各块跟着变 + const joint = useMemo(() => jointOf(data), [data]) + const clauses = useMemo(() => new Map(views.map((v) => [v.id, clausesOf(v, views, choices, joint)])), + [views, choices, joint]) /** 提交裁决(approved 为真,value 是 {choices, note})或驳回(认不出指标时的出口) */ const send = async (approved: boolean) => { @@ -397,7 +492,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro ) : ( <> {views.map((v) => ( - setChoices((cur) => ({ ...cur, [v.id]: c }))} /> ))} @@ -423,8 +518,8 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro } /** 一个不一致的指标:名字、两个值、相差多少、差异说明、对照两条 SQL、三选一 */ -function MetricBlock({ view, name, choice, peers, onChoose }: { - view: MetricView; name: string; choice?: DualCheckChoice; peers?: PreviewPeers; onChoose: (c: DualCheckChoice) => void +function MetricBlock({ view, name, choice, clauses, onChoose }: { + view: MetricView; name: string; choice?: DualCheckChoice; clauses: Clause[]; onChoose: (c: DualCheckChoice) => void }) { const uid = useId() // 只有兜底说明(或只有解析失败)时默认展开:一条具体说法都没有,审核人只能对照原文判断 @@ -484,7 +579,8 @@ function MetricBlock({ view, name, choice, peers, onChoose }: { {expanded && view.full && } {view.preview ? ( // 服务端预告了:两边都不写就不提数据目录 - (view.preview.a.write || view.preview.b.write) && + // 服务端预告了:两边都认不出可记的事实(都没有写到哪)就不提数据目录 + (view.preview.a.target || view.preview.b.target) && ) : view.catalog && (

    {T.catalog[view.catalog]}{T.catalog.tail}

    )} @@ -509,50 +605,27 @@ function MetricBlock({ view, name, choice, peers, onChoose }: { ) } -/** 预告里的一句:说什么、这一选会不会写(说法不同、都不写的不算)、是不是说法不同、是不是要和别的条件合写 */ -interface Clause { side: DualCheckChoice; text: string; write: boolean; conflict: boolean; joined: boolean } - /** - * 服务端预告的「记入数据目录」:三个选择各一句,审核人当前选的那一句加重——选之前三句都看得到,选了之后一眼看出这一选 - * 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」。 - * peers 是同一次裁决里别的指标按当前的选择落到同一项上的(peersOf):说法不同的这一句改说都不记,有效记录条件补一句 - * 合写;选择变了跟着变 + * 服务端预告的「记入数据目录」:三个选择逐条一句(clausesOf),审核人当前选的那一条加重——选之前三条都看得到,选了之后 + * 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里 */ -function CatalogPreviewNote({ preview, peers, choice }: { preview: CatalogPreview; peers?: PreviewPeers; choice?: DualCheckChoice }) { +function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: DualCheckChoice }) { const P = T.catalogPreview - const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), write: false, conflict: false, joined: false }) - const clause = (side: 'a' | 'b'): Clause => { - const s = preview[side] - if (!s.write) return skip(side) - // 前一句已经写明了是哪张表的哪一项(说法不同的那句也写),这一句省掉 - const short = side === 'b' && sameTarget(preview.a, s) - const { differ = [], joined = [] } = peers?.[side] ?? {} - if (differ.length) { - const names = P.metricNames(differ) - const text = short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], s.where, names) - return { side, text, write: false, conflict: true, joined: false } - } - const text = short ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value) - return joined.length - ? { side, text: text + P.joined(P.metricNames(joined)), write: true, conflict: false, joined: true } - : { side, text, write: true, conflict: false, joined: false } - } - const clauses = [clause('a'), clause('b'), skip('neither')] return ( -

    - {P.head} - {clauses.map(({ side, text, write, conflict, joined }, i) => ( - - {i > 0 && ';'} - - {text} - - - ))} - {P.tail} -

    +
    +
    {P.head}
    +
      + {clauses.map((c) => ( +
    • + {c.text} +
    • + ))} +
    +

    {P.tail}

    +
    ) } @@ -560,7 +633,7 @@ function CatalogPreviewNote({ preview, peers, choice }: { preview: CatalogPrevie interface Pair { path: string original?: DualCheckInput - blind?: DualCheckBlindInput + blind?: BlindInput source: string | null /** SQL 或取到的值不同 */ differs: boolean @@ -576,7 +649,7 @@ const sameValue = (a: unknown, b: unknown): boolean => { } function pairsOf(metric: DualCheckMetric): Pair[] { - const byPath = new Map() + const byPath = new Map() const valid = (i: unknown): i is DualCheckInput => !!i && typeof i === 'object' && typeof (i as DualCheckInput).path === 'string' for (const i of (Array.isArray(metric.original?.inputs) ? metric.original.inputs : []).filter(valid)) { byPath.set(i.path, { ...byPath.get(i.path), original: i }) @@ -710,15 +783,47 @@ function PairSql({ pair }: { pair: Pair }) {
- {str(pair.blind?.rationale) && ( -

- {T.rationale}:{pair.blind!.rationale} -

- )} +
) } +/** 理由文档里有没有没出处的数字(evidence.compose_doc 标出的裸数字,状态是「无证据」) */ +const hasBare = (doc: EvidenceDocData): boolean => + (doc.blocks ?? []).some((b) => (b.units ?? []).some((u) => (u.segments ?? []).some((seg) => segmentState(seg) === 'none'))) + +/** + * 盲算的说明。工件带了核查 agent 组装的理由文档(rationale_doc)时按文档画,和反方审稿的理由同一种画法:数字有出处的 + * 画实线、挂盲算自己的查询编号(悬停看出处),裸数字画点状线、句末挂「无证据」,下面再醒目地说一句。老工件只有渲染后的 + * 正文,照旧显示纯文本 + */ +function BlindRationale({ input }: { input?: BlindInput }) { + const doc = useMemo(() => { + const raw = input?.rationale_doc + const parsed = raw && typeof raw === 'object' ? asDoc(raw) : null + return parsed && (parsed.blocks ?? []).length ? parsed : null + }, [input?.rationale_doc]) + if (doc) { + return ( +
+
{T.rationale}
+ + {hasBare(doc) && ( +

+ {T.rationaleBare} +

+ )} +
+ ) + } + if (!str(input?.rationale)) return null + return ( +

+ {T.rationale}:{input!.rationale} +

+ ) +} + /** 一条 SQL 原文:逐行画,marks 里的行(按行比出的不同行)高亮;空白行不标 */ function SqlPane({ side, label, lines, marks }: { side: 'original' | 'blind'; label: string; lines: string[] | null; marks?: Set }) { return ( diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 67c22705..8371cc3d 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -15,8 +15,12 @@ // (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的 // 「未采用原流程时」说(payload 带了实际生效的处置就照它说,老服务端从运行的图快照里读),探索运行不提出具,读不到 // 节点设置时有条件地说; +// - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪; +// 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端 +// 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 -// 叫法和服务端校验报错里的(schema.py 的 DUAL_CHECK_FIELD_LABEL,合入后)一字不差;盲算模型不藏在高级选项里 +// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 +// 和反方审稿共用的控件(verifyFields.tsx);盲算模型不藏在高级选项里 // (时间线「模型相同」那一行要能定位过来);上游有好几张口径卡时写明必须选;「跟随设置」的当前值每次打开都重取; // 节点卡片的摘要写出复算哪张口径卡、几个指标; // - 设置页「核查」一节:读回各项,改了之后整组写 verify(不限存 null),叫法和服务端 VERIFY_SETTING_LABEL 一字不差; @@ -303,7 +307,22 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输 const no = await gmv.locator('[data-dual-norm="original"]').boundingBox() const nb = await gmv.locator('[data-dual-norm="blind"]').boundingBox() check('……规范化后的两栏也左右并排', !!no && !!nb && Math.abs(no.y - nb.y) < 2 && nb.x >= no.x + no.width - 1, JSON.stringify({ no, nb })) - check('盲算的说明', (await text(gmv.locator('[data-dual-rationale]'))).includes('销售额按下单时间统计本周全部订单金额之和')) + // 盲算的说明按核查 agent 组装的理由文档画(夹具里是 compose_doc 的真实输出):有出处的数字照证据画实线、挂盲算自己的 + // 查询编号,裸数字画点状线、句末挂「无证据」,下面再醒目地说一句;退款率的输入是老工件,没有理由文档,照旧纯文本 + const why = gmv.locator('[data-dual-rationale="doc"]') + check('盲算的说明按理由文档画', await why.count() === 1 && (await text(why)).includes('销售额按下单时间 created_at 归属到当周') + && (await text(why)).includes('51,388.3'), await text(why)) + const cite = why.locator('[data-challenge-cite="Q1"]') + check('……有出处的数字画实线、挂查询编号,不是按钮(没有可跳的查询)', await cite.count() === 1 + && (await cite.getAttribute('data-ev-line')) === 'solid' && (await cite.evaluate((el) => el.tagName)) === 'SPAN' + && (await cite.locator('sup').innerText()).trim() === 'Q1' && (await cite.getAttribute('title') ?? '').includes('Q1'), await cite.getAttribute('title')) + const bare = await why.locator('.ev-mark[data-ev-state="none"]').allInnerTexts() + check('……裸数字画点状线、挂「无证据」', JSON.stringify(bare.map((t) => t.replace(/\s*(无证据)$/, ''))) === '["1200"]' + && (await why.locator('[data-ev-tag="none"]').innerText()).includes('无证据'), JSON.stringify(bare)) + check('……下面醒目地说一句没有出处', (await text(why.locator('[data-dual-rationale-bare]'))) === '理由中标出的数字没有引用盲算自己的查询结果,无法核对出处') + const plain = metric(page, 'refund_rate').locator('[data-dual-rationale="text"]') + check('老工件没有理由文档:照旧显示纯文本', await plain.count() === 1 && (await text(plain)) === '盲算的说明:退款笔数:订单表中能找到对应订单的退款记录数', + await plain.count() ? await text(plain) : '没有') check('输入取值:路径、两边的值', (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('45,678.5') && (await text(gmv.locator('[data-dual-input="vars.kpi.gmv"]'))).includes('51,388.3')) const rr = metric(page, 'refund_rate') @@ -436,14 +455,19 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 }) /** - * 一块里预告的三句:{a, b, neither} → {text, write, current, conflict, joined}。conflict:同一次裁决里别的指标对同一项 - * 说法不同、都不记;joined:有效记录条件要和别的指标的合成一句 + * 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined}。reason:不写、照服务端 + * 的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;conflict、joined:没有合并预告时 + * 按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句) */ const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')] .map((s) => [s.dataset.dualCatalogSide, { text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight), + reason: s.dataset.reason != null, joint: s.dataset.joint != null, together: s.dataset.together != null, conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, }]))) +/** 不写的一条:选择的叫法加服务端的原话 */ +const blockedText = (label, reason) => `${label}时不会记入:${reason}` +const CHOICE_LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } await section('审批卡:服务端预告了写目录时照它说,当前选的那一句加重', async () => { // payload.summary[].catalog(约定之外、服务端另带的):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的 @@ -461,9 +485,10 @@ await section('审批卡:服务端预告了写目录时照它说,当前选 const gmv = metric(base.page, 'gmv') check('完整对照还没到:已照预告说', (await base.page.locator('[data-dual-check]').getAttribute('data-state')) === 'loading') let c = await previewClauses(gmv) - check('两边换了时间列:采用原流程记原流程那一列', c.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' && c.a.write, c.a?.text) - check('……采用盲算记盲算那一列(同一项,省掉哪张表的哪一项)', c.b?.text === '采用盲算的结果时记为「created_at」' && c.b.write, c.b?.text) + check('两边换了时间列:采用原流程会记为原流程那一列', c.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」' && c.a.write, c.a?.text) + check('……采用盲算会记为盲算那一列(同一项,省掉哪张表的哪一项)', c.b?.text === '采用盲算的结果时会记为「created_at」' && c.b.write, c.b?.text) check('……两边都不对时不记', c.neither?.text === '两边都不对时不记' && !c.neither.write, c.neither?.text) + check('……三个选择逐条分行列出', await gmv.locator('[data-dual-catalog="preview"] li[data-dual-catalog-side]').count() === 3) const said = await text(gmv.locator('[data-dual-catalog]')) check('……开头说记入数据目录,结尾说以时间线为准,不再用有条件的说法', said.startsWith('记入数据目录:') && said.includes('人工确认') && said.includes('时间线') && !said.includes('仅当'), said) @@ -497,18 +522,19 @@ await section('审批卡:服务端预告了写目录时照它说,当前选 } else { const got = await previewClauses(m) const ok = ['a', 'b'].every((s) => (expect[s].write - ? got[s]?.write && got[s].text.includes(LABEL[expect[s].item]) && got[s].text.includes(`记为「${previewValue(expect[s].value)}」`) - : !got[s]?.write && got[s]?.text.endsWith('时不记'))) - check(`${what}:写的一边说清写到哪、写成什么,不写的一边说不记`, ok, `${got.a?.text} ‖ ${got.b?.text}`) + ? got[s]?.write && got[s].text.includes(LABEL[expect[s].item]) && got[s].text.includes(`会记为「${previewValue(expect[s].value)}」`) + : !got[s]?.write && got[s]?.reason && got[s].text === blockedText(CHOICE_LABEL[s], expect[s].reason))) + check(`${what}:写的一边说清写到哪、会记为什么,不写的一边照服务端的原话说不会记入的原因`, ok, `${got.a?.text} ‖ ${got.b?.text}`) if (name === 'dedup') { // 采用去重的原流程记去重规则,采用不去重的盲算不记:两种选择分别看加重的那一句 await m.locator('label', { hasText: '采用原流程的结果' }).click() let cur = await previewClauses(m) - check('只差去不去重,选原流程(去重):加重的那一句说记为去重规则', cur.a.current - && cur.a.text === '采用原流程的结果时,「orders」(shop)的去重规则记为「对 user_id 计数时去重」', cur.a.text) + check('只差去不去重,选原流程(去重):加重的那一条说会记为去重规则', cur.a.current + && cur.a.text === '采用原流程的结果时,「orders」(shop)的去重规则会记为「对 user_id 计数时去重」', cur.a.text) await m.locator('label', { hasText: '采用盲算的结果' }).click() cur = await previewClauses(m) - check('……选盲算(不去重):加重的那一句说不记', cur.b.current && cur.b.text === '采用盲算的结果时不记' && !cur.a.current, cur.b.text) + check('……选盲算(不去重):加重的那一条说不会记入、为什么', cur.b.current && cur.b.reason + && cur.b.text === blockedText('采用盲算的结果', expect.b.reason) && cur.b.text.includes('不去重是这个指标自己的算法') && !cur.a.current, cur.b.text) } } check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) @@ -556,12 +582,43 @@ await section('审批卡:服务端预告了写目录时照它说,当前选 } }) -await section('审批卡:同一次裁决里几个指标落到数据目录的同一项上,按其余指标当前的选择说', async () => { +await section('审批卡:被数据目录挡下的一边照样说写到哪、不会记入的原因照服务端的原话说', async () => { + // 目录里已有人工确认的业务日期(夹具 catalog_blocked:服务端按那时的目录算的真实预告):两边都认出了事实、都被目录 + // 挡下。以前预告只给 {write: false},卡上一句「不记」,看不出是因为目录里已经有了;现在逐条说「不会记入:原因」, + // 原因和裁决后时间线上跳过的原因一字不差(test_dual_check_node 的图级测试核对) + const said = fx.catalog_blocked.catalog + check('夹具:两边都被挡下,各带写到哪和原因', ['a', 'b'].every((s) => said[s].write === false && said[s].table === 'orders' + && said[s].item === 'business_date' && !!said[s].reason), JSON.stringify(said)) + const { page, errors } = await preview('dual-check-approval', { query: 'catalog=blocked' }) + await page.waitForSelector('[data-dual-check][data-state="ready"]') + const gmv = metric(page, 'gmv') + const c = await previewClauses(gmv) + check('采用原流程:说不会记入,原因是服务端的原话(已是人工确认过的同一列)', c.a?.text === blockedText(CHOICE_LABEL.a, said.a.reason) + && c.a.reason && !c.a.write && c.a.text.includes('无需重复写入'), c.a?.text) + check('……采用盲算:同样照原话说(已有不同的说法,未覆盖)', c.b?.text === blockedText(CHOICE_LABEL.b, said.b.reason) + && c.b.reason && !c.b.write && c.b.text.includes('未覆盖'), c.b?.text) + check('……两边都不对时不记', c.neither?.text === '两边都不对时不记' && !c.neither.write, c.neither?.text) + check('……三条都没有「会记为」', !Object.values(c).some((x) => x.text.includes('会记为')), JSON.stringify(c)) + await gmv.locator('label', { hasText: '采用盲算的结果' }).click() + const cur = await previewClauses(gmv) + check('选盲算:加重的是「不会记入」那一条', cur.b?.current && cur.b.weight >= 500 && !cur.a.current, JSON.stringify(cur.b)) + check('页面没有报错', errors.length === 0, errors.join(';')) + for (const theme of ['dark', 'light']) { + const narrow = await preview('dual-check-approval', { query: 'catalog=blocked', width: 390, height: 844, theme }) + await narrow.page.waitForSelector('[data-dual-check][data-state="ready"]') + const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`390px(${theme}):原因很长也不横向溢出`, w <= 0, `${w}px`) + await shot(narrow.page, `approval-blocked-narrow-${theme}`) + } +}) + +await section('审批卡:几个指标落到同一项上、工件里没有合并预告(老服务端)时,按其余指标当前的选择推', async () => { // 预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged): // 业务日期、去重规则说法不同的一项都不写,有效记录条件用 AND 合成一句写。两个指标分开选时卡上要是各自加粗说 // 「记为……」,审核人会以为同一张表要记两个业务日期,可能为了避开它把本该分开的选择改成一致(2026-10 第 3 轮复核)。 // 卡上按其余指标当前的选择说,选择变了说法跟着变。夹具 merges 是这几种组合在服务端真写一遍的结果 - // (test_dual_check_fixture.py 用 record_choices 核对),peers 换掉「退款率」,和「销售额」落到同一项上 + // (test_dual_check_fixture.py 用 record_choices 核对),peers 换掉「退款率」,和「销售额」落到同一项上。这一节的工件 + // 不带服务端算好的合并预告(catalog_joint,期 4 起才有):卡上照旧规矩推;带了的见下一节 const LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } const open = async (name, opts = {}) => { const m = fx.merges[name] @@ -582,11 +639,11 @@ await section('审批卡:同一次裁决里几个指标落到数据目录的 check('夹具:分开选时服务端一项都不写(record_choices 的真实结果)', fx.merges.time_split.written.length === 0) const { page, errors } = await open('time_split') let g = await previewClauses(metric(page, 'gmv')) - check('还没选:退款率没有选择,销售额照自己的预告说', g.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' + check('还没选:退款率没有选择,销售额照自己的预告说', g.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」' && g.a.write && !g.a.conflict, g.a?.text) await choose(page, 'gmv', 'a') let r = await previewClauses(metric(page, 'refund_rate')) - check('销售额选了原流程:退款率选原流程(同一列)照常记', r.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' + check('销售额选了原流程:退款率选原流程(同一列)照常记', r.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」' && r.a.write && !r.a.conflict, r.a?.text) check('……退款率选盲算(另一列)说清和销售额说法不同、都不记,不再说「记为」', !!r.b?.conflict && !r.b.write && r.b.text.includes('「销售额」') && r.b.text.endsWith('都不记') && !r.b.text.includes('记为'), r.b?.text) @@ -599,7 +656,7 @@ await section('审批卡:同一次裁决里几个指标落到数据目录的 check('……退款率那一句是同一项,不再重复写表名', cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', cur.refund_rate?.text) g = await previewClauses(metric(page, 'gmv')) - check('……销售额那一块里,改选盲算(和退款率同一列)的那一句照常说记', g.b?.text === '采用盲算的结果时记为「created_at」' + check('……销售额那一块里,改选盲算(和退款率同一列)的那一句照常说记', g.b?.text === '采用盲算的结果时会记为「created_at」' && g.b.write && !g.b.conflict, g.b?.text) const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]')) check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择') @@ -636,8 +693,8 @@ await section('审批卡:同一次裁决里几个指标落到数据目录的 const merged = fx.merges.filters.written[0]?.value ?? '' check('夹具:服务端把两边的条件合成一句写', own.gmv !== own.refund_rate && merged.includes(own.gmv) && merged.includes(own.refund_rate), merged) check('有效记录条件不同:两边加粗的那一句都说记、并和另一个指标采用的条件合成一句', - cur.gmv?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件记为「${own.gmv}」,与「退款率」采用的条件用 AND 合成一句` - && cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句` + cur.gmv?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.gmv}」,与「退款率」采用的条件用 AND 合成一句` + && cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句` && cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) // 窄屏:说法不同的那一句更长,也不撑宽 @@ -650,6 +707,114 @@ await section('审批卡:同一次裁决里几个指标落到数据目录的 } }) +await section('审批卡:几个指标落到同一项上时,照完整对照里服务端算好的合并预告说,和真写的一字不差', async () => { + // 期 4:服务端把同一次裁决里落到同一项上的每种选择组合按真写的办法算好(dual_check_facts.preview_joint:归拢、拿掉 + // 驳回过的、对目录),放进完整对照的 catalog_joint。卡上按各指标当前的选择取对应的那一条:会写的说合起来的值、和哪几个 + // 指标一起记入,不写的照服务端的原话说原因。夹具 merges 的 catalog、joint 是服务端按那一组合的目录算的真实输出, + // written 是服务端按那一组选择真写一遍(record_choices)的结果,test_dual_check_fixture.py 核对两者逐字一致 + const open = async (name, opts = {}) => { + const m = fx.merges[name] + const query = [`merge=${name}`, `peer=${m.refund_rate}`, ...(m.gmv ? [`variant=${m.gmv}`] : [])].join('&') + const base = cardWith(m.gmv, { peer: m.refund_rate }) + const artifact = async () => { + const out = await base() + out.json.content.catalog_joint = m.joint + return out + } + const out = await preview('dual-check-approval', { query, artifact, ...opts }) + await out.page.waitForSelector('[data-dual-check][data-state="ready"]') + return out + } + const choose = (page, id, c) => metric(page, id).locator('label', { hasText: CHOICE_LABEL[c] }).click() + const NAME = { gmv: '销售额', refund_rate: '退款率' } + const current = async (page) => Object.fromEntries(await Promise.all(['gmv', 'refund_rate'].map(async (id) => ( + [id, Object.values(await previewClauses(metric(page, id))).find((x) => x.current) ?? null])))) + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) + + for (const [name, m] of Object.entries(fx.merges)) { + const { page, errors } = await open(name) + for (const [id, c] of Object.entries(m.choices)) await choose(page, id, c) + const cur = await current(page) + const [entry] = m.joint.filter((e) => e.members.length === 2 && e.members.every((x) => m.choices[x.metric_id] === x.choice)) + const wrong = [] + for (const id of ['gmv', 'refund_rate']) { + const said = entry?.outcomes?.[id] + const c = cur[id] + const other = NAME[id === 'gmv' ? 'refund_rate' : 'gmv'] + const ok = !!said && !!c?.joint && !c.conflict && !c.joined && c.weight >= 500 && (said.write + // 会写的:值是合起来的那个,就是真写进去的;另一个指标也写时说一起记入 + ? c.write && c.text.includes(`会记为「${previewValue(said.value)}」`) + && m.written.some((w) => w.item === entry.item && same(w.value, said.value)) + && (entry.outcomes[id === 'gmv' ? 'refund_rate' : 'gmv'].write + ? c.together && c.text.endsWith(`,与「${other}」的选择一起记入`) : !c.together) + // 不写的:照服务端的原话说原因,也确实没写进去 + : !c.write && c.reason && c.text === blockedText(CHOICE_LABEL[m.choices[id]], said.reason) + && !m.written.some((w) => w.item === entry.item && same(w.value, said.value))) + if (!ok) wrong.push(`${id}:${c?.text}(服务端 ${JSON.stringify(said)})`) + } + check(`${name}:两个指标当前的那一条和服务端算好的合并结果、真写的结果一致`, !!entry && !wrong.length, wrong.join(';')) + // 反过来:真写进去的每一项,卡上都有指标说「会记为」它 + const told = Object.values(cur).filter((c) => c?.write).map((c) => c.text) + check(`${name}:真写进去的每一项卡上都说了会记为什么`, m.written.every((w) => told.some((t) => t.includes(`会记为「${previewValue(w.value)}」`))), + `${JSON.stringify(m.written)} ‖ ${told.join(' ‖ ')}`) + check(`${name}:页面没有报错`, errors.length === 0, errors.join(';')) + } + + // 分开选(业务日期说法不同):两边都照服务端的原话说不会记入;选择一变,说法跟着换成那一组合的结果 + const { page, errors } = await open('time_split') + for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(page, id, c) + let cur = await current(page) + check('分开选:两边都说不会记入、写明给出了不同的说法', ['gmv', 'refund_rate'].every((id) => cur[id]?.reason + && cur[id].text.includes('给出了不同的说法') && cur[id].text.includes('都未写入数据目录')), JSON.stringify(cur)) + await choose(page, 'refund_rate', 'a') + cur = await current(page) + check('退款率改选原流程(同一列):两边都说会记为「paid_at」,并和对方一起记入', + cur.gmv?.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」,与「退款率」的选择一起记入' + && cur.refund_rate?.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」,与「销售额」的选择一起记入', + `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + const all = await previewClauses(metric(page, 'gmv')) + check('……没选的那一条(销售额采用盲算)按那一组合说:和退款率说法不同,不会记入', all.b?.joint && all.b.reason && !all.b.write + && all.b.text.startsWith('采用盲算的结果时不会记入:') && all.b.text.includes('给出了不同的说法'), all.b?.text) + await choose(page, 'refund_rate', 'neither') + cur = await current(page) + check('退款率改选两边都不对:不再落到同一项上,销售额回到自己的预告', cur.gmv?.write && !cur.gmv.joint && !cur.gmv.together + && cur.gmv.text === '采用原流程的结果时,「orders」(shop)的业务日期会记为「paid_at」', cur.gmv?.text) + check('页面没有报错', errors.length === 0, errors.join(';')) + + // 驳回过的说法:拿出来不写,另一个指标照写,各自照服务端的结果说 + const rej = await open('filters_rejected') + for (const [id, c] of Object.entries(fx.merges.filters_rejected.choices)) await choose(rej.page, id, c) + cur = await current(rej.page) + check('驳回过的条件:销售额说不会记入(曾被人工驳回),退款率说会记为自己的条件、不说一起记入', + !!cur.gmv?.reason && cur.gmv.text.includes('曾被人工驳回') && cur.refund_rate?.write && !cur.refund_rate.together + && cur.refund_rate.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${fx.merges.filters_rejected.written[0]?.value}」`, + `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + + // 工件里的合并预告认不出:这一项回到按其余指标的选择推,不替服务端编 + const broken = await preview('dual-check-approval', { + query: `merge=time_split&peer=${fx.merges.time_split.refund_rate}`, + artifact: async () => { + const out = await cardWith(null, { peer: fx.merges.time_split.refund_rate })() + out.json.content.catalog_joint = [{ table: 'orders', item: 'business_date', members: [{ metric_id: 'gmv' }], outcomes: 'x' }] + return out + }, + }) + await broken.page.waitForSelector('[data-dual-check][data-state="ready"]') + for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(broken.page, id, c) + cur = await current(broken.page) + check('合并预告认不出:回到按其余指标的选择推(说法不同、都不记)', !!cur.gmv?.conflict && !cur.gmv.joint && !!cur.refund_rate?.conflict, + `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + check('……页面没有报错', broken.errors.length === 0, broken.errors.join(';')) + + for (const theme of ['dark', 'light']) { + const narrow = await open('time_split', { width: 390, height: 844, theme }) + for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(narrow.page, id, c) + const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`390px(${theme}):合并预告的原因很长也不横向溢出`, w <= 0, `${w}px`) + await shot(narrow.page, `approval-joint-narrow-${theme}`) + } +}) + await section('审批卡:只有解析失败、兜底原话另有原因时', async () => { // 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面 const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') }) From 11d44ebc8ac6566ff9313edf445ce9320076ecad Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:25:23 +0800 Subject: [PATCH 11/13] =?UTF-8?q?check-verify=20=E8=A1=A5=E4=B8=80?= =?UTF-8?q?=E8=8A=82=EF=BC=9A=E8=AE=B0=E5=BD=95=E9=A1=B5=E7=9A=84=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=E5=88=97=E8=A1=A8=E9=87=8C=E5=8F=8C=E7=9B=B2=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=E5=AF=B9=E7=85=A7=E3=80=81=E5=8F=8D=E6=96=B9=E5=AE=A1?= =?UTF-8?q?=E7=A8=BF=E8=AE=B0=E5=BD=95=E6=9C=89=E5=90=8D=E5=AD=97=E5=92=8C?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 期 3 新加的两种工件此前没登记叫法,工件列表里露的是键名 dual_check、challenge,说明一栏只有一句「工件」。叫法表(pages/runs/model.ts 的 ARTIFACT_KIND_LABEL)已补上,这里守住:种类的叫法、说明(双盲复算带指标个数,没有 meta 的老数据也有一句),两种都算证据。按页面自己加载的那一份取,同 check-evidence 的工件一节 --- scripts/check-verify.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 8371cc3d..7bfbfe10 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -18,6 +18,7 @@ // - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪; // 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端 // 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本; +// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 // 和反方审稿共用的控件(verifyFields.tsx);盲算模型不藏在高级选项里 @@ -1042,6 +1043,27 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅 await shot(rail.page, 'approval-rail-dark') }) +await section('记录页的工件列表:双盲复算对照、反方审稿记录有名字', async () => { + // 工件列表按 pages/runs/model.ts 的 ARTIFACT_KIND_LABEL 写种类:期 3 新加的两种工件没登记时,列表里露出的是键名 + // dual_check、challenge,说明一栏只有一句「工件」。按页面自己加载的那一份取(同 check-evidence 的工件一节) + const { page, errors } = await preview('dual-check-approval') + const got = await page.evaluate(async () => { + const m = await import('/src/pages/runs/model.ts') + return { + dual: m.artifactKindLabel('dual_check'), challenge: m.artifactKindLabel('challenge'), + dualDesc: m.artifactDescription('dual_check', { metrics: 2 }), dualBare: m.artifactDescription('dual_check', null), + challengeDesc: m.artifactDescription('challenge', { report: 'report' }), + evidence: m.isEvidence('dual_check') && m.isEvidence('challenge'), + } + }) + check('种类:双盲复算对照、反方审稿记录,不露键名', got.dual === '双盲复算对照' && got.challenge === '反方审稿记录', JSON.stringify(got)) + check('……说明写清装的是什么:双盲复算带指标个数,没有 meta 的老数据也有一句', got.dualDesc === '双盲复算:2 个指标两边的 SQL、取值与差异说明' + && got.dualBare === '双盲复算:两边的 SQL、取值与差异说明' && got.challengeDesc === '反方审稿:每句结论的判断、理由和反方执行的查询', + JSON.stringify(got)) + check('……两种都算证据(列表里醒目)', got.evidence) + check('……页面没有报错', errors.length === 0, errors.join(';')) +}) + // ---------------------------------------------------------------- 检查器与节点卡片 /** From 62b9361d88e98f2e6f023a98ee6ab66a645ba4c6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 09:31:00 +0800 Subject: [PATCH 12/13] =?UTF-8?q?=E6=9C=9F=204=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE=EF=BC=8C?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E9=A2=84=E5=91=8A=E7=BB=84?= =?UTF-8?q?=E5=90=88=E5=85=88=E7=AE=97=E4=B8=AA=E6=95=B0=E5=86=8D=E5=88=97?= =?UTF-8?q?=E3=80=81=E4=B8=8A=E9=99=90=E6=8C=89=E6=AF=8F=E9=A1=B9=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E6=A0=B8=E6=9F=A5=20agent=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E6=97=A5=E5=BF=97=E4=BB=A3=E7=A0=81=E5=BD=92=E7=B1=BB?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E8=83=BD=E5=8F=8D=E6=9F=A5=E7=9A=84=E5=8F=A5?= =?UTF-8?q?=E6=95=B0=E5=AE=88=E4=BD=8F=E4=B8=8D=E5=A4=9A=E4=BA=8E=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview_joint 以前先把每一项的全部组合列成列表才和 JOINT_LIMIT 比,二十个指标落到同一项上就是几十亿种,在服务进程里(asyncio.to_thread)能把整个服务拖死;现在先按 ∏(1+k)−1−Σk 算出个数(超过上限即停),超了整项跳过、不列。上限也改成每一项各算各的:以前几项共用一份额度,前一项用掉以后,后面没超上限的项也被悄悄跳过,和文档说的「超过的那一项不预告」对不上。补两条测试:二十个指标落到同一项上时一个组合都不多列,两项各 72 种都预告 - review.py 认得核查 agent(双盲复算、反方审稿共用)发的两条:verify_unpriced 同 judge_unpriced 只是配置提醒,不再叫复核模型、给答案加降级说明;verify_limit 记成它自己的 kind(步数上限只有它说),不再落成笼统的 warn - 反方审稿测试补上「未能反查的句数不多于无法判断」的断言(审批卡上的两个数靠这个约定对得上),模块说明改写准:触顶没给出结论的可能查过几次,原因里也有报告未引用数据库查询 - 双盲复算夹具加一种组合 time_rejected:业务日期被驳回过的那一列由退款率采纳(被挡下,带着写到哪),销售额采纳另一列;服务端先归拢、说法不同一项都不写(夹具测试按 record_choices 真写核对),审批卡没有合并预告时的推断按它检查 --- backend/app/engine/dual_check_facts.py | 30 ++-- backend/app/engine/nodes/challenge.py | 7 +- backend/app/engine/review.py | 5 + backend/tests/test_challenge_node.py | 17 ++- backend/tests/test_dual_check_facts.py | 40 +++++ backend/tests/test_review_verify_codes.py | 15 +- frontend/src/dev/fixtures/dual_check.json | 170 ++++++++++++++++++++++ 7 files changed, 259 insertions(+), 25 deletions(-) diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index e3b0a875..d33b839c 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -74,8 +74,9 @@ ASPECT_LABEL = { _VERB = {"count": "计数", "sum": "求和", "avg": "求平均"} #: 写入时版本对不上(别人刚改过这张表)最多重试几次 _RETRIES = 2 -#: 同一次裁决里几个指标落到同一项上时,最多预告多少种组合(preview_joint)。每个指标两边都落在这一项上时是 -#: 3 的指标数次方:四个指标 72 种还在里面,再多的那一项不预告,审批卡回到按其余指标当前的选择有条件地说 +#: 同一次裁决里几个指标落到同一项上时,这一项最多预告多少种组合(preview_joint,每一项各算各的,不共用额度)。 +#: 每个指标两边都落在这一项上时近于 3 的指标数次方:四个指标 72 种还在里面,再多的那一项不预告,审批卡回到按其余指标 +#: 当前的选择有条件地说。组合数先算出来再比(_combos),不把组合列出来比:二十个指标就是几十亿种 JOINT_LIMIT = 128 #: 无效记录的取值:被排除的取值(或码值表里它的意思)里有这些词,才算在排除无效记录 @@ -820,6 +821,18 @@ def _joint_outcome(notes: Mapping[str, Any], members: list[Fact], tables: Mappin return result +def _combos(sides: Sequence[int], limit: int) -> int: + """几个指标落到同一项上时有几种组合(至少两个指标落上来的):每个指标要么没落上来、要么落上来的那几边之一, + ∏(1 + k) − 1 − Σk(k 是这个指标落到这一项上的边数,减去一个都没有、只有一个的)。连乘到超过 limit 能确定的地方 + 就停,返回 limit + 1:只用来和上限比,不需要真算出几十亿。""" + total, cap = 1, limit + 1 + sum(sides) + for k in sides: + total *= 1 + k + if total > cap: + return limit + 1 + return total - 1 - sum(sides) + + def preview_joint(candidates: Sequence[Mapping[str, Any]], catalogs: Mapping[str, tuple[Mapping[str, Any] | None, Mapping[str, Mapping[str, Any]]]] ) -> list[dict[str, Any]]: @@ -829,8 +842,11 @@ def preview_joint(candidates: Sequence[Mapping[str, Any]], candidates 照节点交给真写的先后(不一致的指标的先后),带 metric_id,不带 choice;catalogs 是 {数据源名: (表结构, {表名: notes})},同 preview 的两个参数。每个指标的两边先各按 derive 认出落到哪一项(同 preview),落到同一项上的 - 几个指标,每种「谁选了哪一边」的组合按 _joint_outcome 算一遍:真写时这几个指标就是这样被归拢、写或不写的。组合超过 - JOINT_LIMIT 的那一项整项不预告。永远不抛:算不出来的那一项不预告。""" + 几个指标,每种「谁选了哪一边」的组合按 _joint_outcome 算一遍:真写时这几个指标就是这样被归拢、写或不写的。 + + 上限按每一项算:一项的组合数(_combos,先算个数,不先列出来)超过 JOINT_LIMIT 的,这一项整项不预告,别的项照常 + 预告,几项之间不共用额度。一项不超过 JOINT_LIMIT 种、每个指标最多落到两项上,总的计算量和指标数成正比。 + 永远不抛:算不出来的那一项不预告。""" from itertools import product landed: dict[tuple[str, str, str], dict[int, list[tuple[str, Fact]]]] = {} @@ -844,16 +860,12 @@ def preview_joint(candidates: Sequence[Mapping[str, Any]], if fact is not None: landed.setdefault((fact.source, fact.table, fact.item), {}).setdefault(index, []).append((side, fact)) out: list[dict[str, Any]] = [] - budget = JOINT_LIMIT for (source, table, item), by_metric in landed.items(): - if len(by_metric) < 2: + if len(by_metric) < 2 or _combos([len(sides) for sides in by_metric.values()], JOINT_LIMIT) > JOINT_LIMIT: continue options = [[None, *sides] for _, sides in sorted(by_metric.items())] combos = [[c for c in combo if c is not None] for combo in product(*options)] combos = [c for c in combos if len(c) >= 2] - if len(combos) > budget: - continue - budget -= len(combos) schema, notes = catalogs.get(source, (None, {})) tables = schema.get("tables") if isinstance(schema, Mapping) else None try: diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 40286d99..53cfe6e6 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -23,9 +23,10 @@ 5. 处置:被推翻的按 on_refuted 处置。degrade(默认)在产出里写 issuance_gaps,成果节点的出具契约照单收进缺口、 出具降档(nodes/io.py 的 _upstream_issuance_gaps,所以反方审稿要接在成果节点上游,画布校验会提醒);fail 判节点 失败;flag 只记录。产出和审批卡里的 on_refuted 是实际生效的那一种(只有 degrade / flag,见 _finish);degrades - 说被推翻的到底会不会让出具降档(按降档处置、又接在带出具契约的成果节点上游),unreviewed 是没能反查的句数(核查 - 模型不可用、触顶、数据源不在了……这几句记为无法判断,但根本没查成)。审批卡(human.py 的 _reviewed_reports)附上 - 审稿概况,据这两项说「出具会降档」「N 句没能反查」 + 说被推翻的到底会不会让出具降档(按降档处置、又接在带出具契约的成果节点上游),unreviewed 是未能反查的句数(核查 + 模型不可用、触顶没给出结论、数据源不在了、报告没有引用数据库查询……这几句记为无法判断,但反查没有完成,触顶的 + 可能已经查过几次)。每一句都记为无法判断,所以 unreviewed 不会大于 counts 里的 inconclusive。审批卡(human.py 的 + _reviewed_reports)附上审稿概况,据这两项说「出具会降档」「N 句未能反查」 上限:句数、每句查询次数、每句时长、全局每日金额(核查和裁判各记各的账,engine/spend.py)。都按发起时快照的核查 设置(run.options["verify"]),节点上写了的盖过它:审批停下期间改了设置,恢复、接着跑仍按发起时的那份。没有快照 diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index c9242c0f..7b4cc70b 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -114,6 +114,8 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 双盲复算、反方审稿的核查模型和被核查的模型相同:同 judge_same_model,只关乎独立性,结论本身没受影响 "dual_check_same_model": ("dual_check_same_model", BENIGN), "challenge_same_model": ("challenge_same_model", BENIGN), + # 核查 agent(engine/verify_agent.py,双盲复算、反方审稿共用)估不出金额:同 judge_unpriced,配置上的提醒 + "verify_unpriced": ("verify_unpriced", BENIGN), # 核查没做完、没通过:盲算触顶、两路不一致、指标无法复算;反方审稿触顶、推翻了结论、有句子没能反查、只查了 # 部分数据源。答案照常交付(出具契约另按缺口降档),是缺口不是断裂,按各自的 kind 记,界面说得清是哪一种 "dual_check_limit": ("dual_check_limit", DEGRADED), @@ -123,6 +125,9 @@ WARN_CODES: dict[str, tuple[str, str]] = { "challenge_refuted": ("challenge_refuted", DEGRADED), "challenge_unreviewed": ("challenge_unreviewed", DEGRADED), "challenge_sources_lost": ("challenge_sources_lost", DEGRADED), + # 核查 agent 自己触顶、按已有结果收尾:多半和节点自己的 *_limit 说的是同一次触顶,照样是缺口,记成它自己的 kind, + # 不落成笼统的 warn——步数上限(max_steps)只有它说 + "verify_limit": ("verify_limit", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 8765767f..33ab28c4 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -411,7 +411,8 @@ async def test_a_verdict_without_any_query_is_not_taken(monkeypatch, shop): assert east["verdict"] == "inconclusive" and "「经受住」" in east["rationale"] [warn] = await logs(row.id, "challenge_unreviewed") assert warn["claims_skipped"] == 2 and "没有执行成功任何查询" in warn["reason"] - assert out["unreviewed"] == 2, "一条查询都没执行成功:两句都是没能反查,不是查过了判断不了" + assert out["unreviewed"] == 2, "一条查询都没执行成功:两句都是未能反查,不是查过了判断不了" + assert out["unreviewed"] <= out["counts"]["inconclusive"], "未能反查的每一句都记为无法判断,审批卡上两个数对得上" async def test_a_refutation_that_cites_none_of_its_queries_is_not_taken(monkeypatch, shop): @@ -428,7 +429,7 @@ async def test_a_refutation_that_cites_none_of_its_queries_is_not_taken(monkeypa assert south["verdict"] == "inconclusive" and len(south["queries"]) == 1 and south["rationale_doc"] is None assert "没有引用" in south["rationale"] and "扣除退款后华南仍低于华东。" in south["rationale"] assert east["verdict"] == "stands", "「经受住」不要求引用:它不改变出具" - assert await logs(row.id, "challenge_unreviewed") == [], "查过了,不算没能反查" + assert await logs(row.id, "challenge_unreviewed") == [], "查过了,不算未能反查" assert out["unreviewed"] == 0 @@ -537,7 +538,7 @@ async def test_a_claim_with_part_of_its_sources_gone_says_so(monkeypatch, tmp_pa [lost] = await logs(row.id, "challenge_sources_lost") assert lost["level"] == "warn" and lost["claims"] == 2 and lost["sources"] == [other] assert f"「{other}」" in lost["message"] and "2 句" in lost["message"] - assert await logs(row.id, "challenge_unreviewed") == [], "两句都查过了,不算没能反查" + assert await logs(row.id, "challenge_unreviewed") == [], "两句都查过了,不算未能反查" finally: await engines.invalidate(other_id) @@ -671,8 +672,9 @@ async def test_the_time_limit_applies_to_each_claim_and_still_settles(monkeypatc assert south["verdict"] == "inconclusive" and 1 <= len(south["queries"]) < 20 limits = await logs(row.id, "challenge_limit") assert [(lm["limit"], lm["claims_skipped"]) for lm in limits] == [("timeout_s", 0)] - # 触顶但按查到的结果收了尾的那句,判断是反方自己给的,不算没能反查;另一句一条查询都没执行,算 - assert (await output_of(row.id, "chal"))["unreviewed"] == 1 + # 触顶但按查到的结果收了尾的那句,判断是反方自己给的,不算未能反查;另一句一条查询都没执行,算 + out = await output_of(row.id, "chal") + assert out["unreviewed"] == 1 and out["counts"]["inconclusive"] == 2 async def test_the_daily_cap_stops_the_remaining_claims(monkeypatch, shop): @@ -692,7 +694,8 @@ async def test_the_daily_cap_stops_the_remaining_claims(monkeypatch, shop): "第一句派出去就撞上上限、一次模型都没调用,和后面不再派出的句子是同一回事,同一种说法" [limit] = await logs(row.id, "challenge_limit") assert limit["limit"] == "daily_max_usd" and limit["claims_skipped"] == 2 - assert out["unreviewed"] == 2, "撞上每日金额上限没反查的,也是没能反查" + assert out["unreviewed"] == 2, "撞上每日金额上限没反查的,也是未能反查" + assert out["unreviewed"] <= out["counts"]["inconclusive"] assert len(await logs(row.id, "verify_limit")) == 1, "第一句撞上每日上限之后,后面的句子不再派 agent 去试" assert row.output["_issuance"]["tier"] == "formal" @@ -1014,7 +1017,7 @@ async def test_an_unavailable_model_leaves_the_claims_inconclusive(monkeypatch, # 时间线上看得出根本没审成:不是只有一条「审了 2 句」 [warn] = await logs(row.id, "challenge_unreviewed") assert warn["level"] == "warn" and warn["claims_skipped"] == 2 and "核查模型不可用" in warn["reason"] - assert out["unreviewed"] == 2 + assert out["unreviewed"] == 2 and out["unreviewed"] <= out["counts"]["inconclusive"] [done] = await logs(row.id, "challenge_done") assert "2 句未能反查" in done["message"] diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 9c6299ae..de39d7d9 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -654,6 +654,46 @@ def test_too_many_combinations_are_left_to_the_timeline(): assert len(facts.preview_joint(picked[:3], {"shop": (SCHEMA, {})})) == 3 ** 3 - 1 - 3 * 2 +def test_combinations_are_counted_before_they_are_listed(monkeypatch): + """二十个指标都落到同一项上:组合数是 3 的二十次方量级,得先算出个数、超了就跳过,不能先列出来再比上限—— + 列一遍就要几十 GB 内存,在服务进程里算(asyncio.to_thread)会把整个服务拖死。预告时从 itertools.product 取了 + 多少个组合记下来:这一项不预告,取的不该超过上限""" + import itertools + + from app.engine import dual_check_facts as facts + + drawn = 0 + real = itertools.product + + def guarded(*iterables, **kw): + nonlocal drawn + for combo in real(*iterables, **kw): + drawn += 1 + # 红的时候不至于真把内存吃光:取得比上限多出一截就停 + assert drawn <= facts.JOINT_LIMIT + 64, "列出的组合远多于上限:没有先算个数" + yield combo + + stem = candidate(BY_ORDER_DATE, RIGHT, "a") + picked = [{**stem, "metric": f"指标{i}", "metric_id": f"m{i}"} for i in range(20)] + monkeypatch.setattr(itertools, "product", guarded) + assert facts.preview_joint(picked, {"shop": (SCHEMA, {})}) == [] + assert drawn <= facts.JOINT_LIMIT + + +def test_each_item_has_its_own_limit(): + """上限按每一项算,不是几项共用一份额度:两个数据源的业务日期各有四个指标落上来(各 72 种组合),两项都预告。 + 以前共用 JOINT_LIMIT 的额度,前一项用掉 72 种,后一项明明没超上限也被悄悄跳过""" + from app.engine import dual_check_facts as facts + + picked = [{**candidate(BY_ORDER_DATE, RIGHT, "a", metric=f"指标{i}", source=source), "metric_id": f"{source}-{i}"} + for source in ("shop", "outlet") for i in range(4)] + joint = facts.preview_joint(picked, {"shop": (SCHEMA, {}), "outlet": (SCHEMA, {})}) + per = 3 ** 4 - 1 - 4 * 2 + assert per <= facts.JOINT_LIMIT < 2 * per + assert sorted({e["source"] for e in joint}) == ["outlet", "shop"] + assert len(joint) == 2 * per + + @pytest.mark.parametrize("choices", [("a", "b"), ("b", "b"), ("b", "a"), ("a", None)]) async def test_the_joint_preview_is_what_gets_written(shop, choices): """预告和真写逐字一致:按这组选择,各指标的预告(单独落一项的看 preview,几个落到同一项的看 preview_joint)说写的, diff --git a/backend/tests/test_review_verify_codes.py b/backend/tests/test_review_verify_codes.py index b6f1efa8..8e9ad29f 100644 --- a/backend/tests/test_review_verify_codes.py +++ b/backend/tests/test_review_verify_codes.py @@ -2,8 +2,10 @@ 没登记的代码一律按 degraded、kind 记成笼统的 warn:时间线复核和问数据页说不清是哪一种缺口,模型相同这种只关乎独立性、 不影响结论的提醒也会被当成缺口去叫一次复核模型。这里守住两类: -- 模型相同(dual_check_same_model、challenge_same_model)同 judge_same_model,是配置上的提醒,不进信号; -- 触顶、不一致、无法复算、被推翻、没能反查、只查了部分数据源是缺口,按各自的 kind 记成 degraded。 +- 模型相同(dual_check_same_model、challenge_same_model)同 judge_same_model,核查模型不在价格目录中(verify_unpriced) + 同 judge_unpriced,是配置上的提醒,不进信号; +- 触顶、不一致、无法复算、被推翻、未能反查、只查了部分数据源是缺口,按各自的 kind 记成 degraded。核查 agent 自己触顶 + (verify_limit,双盲复算、反方审稿都会发)也是:步数上限只有它说。 """ from __future__ import annotations @@ -19,16 +21,17 @@ def warn(message: str, code: str) -> dict: return {"type": str(EventType.LOG), "node_id": "gate", "data": {"level": "warn", "message": message, "code": code}} -@pytest.mark.parametrize("code", ["dual_check_same_model", "challenge_same_model"]) -def test_same_model_reminders_do_not_trigger_review(code): - """和 judge_same_model 一样:配置上的提醒,答案本身没受影响,不值得为它叫一次复核模型""" +@pytest.mark.parametrize("code", ["dual_check_same_model", "challenge_same_model", "verify_unpriced"]) +def test_configuration_reminders_do_not_trigger_review(code): + """和 judge_same_model、judge_unpriced 一样:配置上的提醒,答案本身没受影响,不值得为它叫一次复核模型、 + 在答案上多一句降级说明""" assert rv.WARN_CODES[code] == (code, rv.BENIGN) assert rv.scan([warn("核查模型与原流程相同(「mx-1」)", code)], OK_OUTPUT) == [] @pytest.mark.parametrize("code", [ "dual_check_limit", "dual_check_disagreed", "dual_check_unchecked", - "challenge_limit", "challenge_refuted", "challenge_unreviewed", "challenge_sources_lost", + "challenge_limit", "challenge_refuted", "challenge_unreviewed", "challenge_sources_lost", "verify_limit", ]) def test_verify_gaps_are_degraded_signals_of_their_own_kind(code): """核查没做完、没通过:答案能用,但有缺口要说清是哪一种(kind 是代码本身,不是笼统的 warn)""" diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index ba60e4a1..6967faf3 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -1471,6 +1471,176 @@ } } ] + }, + "time_rejected": { + "gmv": null, + "refund_rate": "time", + "choices": { + "gmv": "a", + "refund_rate": "b" + }, + "notes": { + "orders": { + "business_date": { + "value": { + "column": "created_at" + }, + "source": "llm", + "status": "rejected" + } + } + }, + "written": [], + "catalog": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": false, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + }, + "reason": "「orders」的业务日期「created_at」曾被人工驳回,与这次指标「销售额」的选择相反,未写入。如需更改,请到数据目录中修改" + } + }, + "refund_rate": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": false, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + }, + "reason": "「orders」的业务日期「created_at」曾被人工驳回,与这次指标「退款率」的选择相反,未写入。如需更改,请到数据目录中修改" + } + } + }, + "joint": [ + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": true, + "value": { + "column": "paid_at" + } + }, + "refund_rate": { + "write": true, + "value": { + "column": "paid_at" + } + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "a" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「paid_at」;指标「退款率」采纳的是「created_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "a" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + }, + "refund_rate": { + "write": false, + "reason": "同一次裁决里对「orders」的业务日期给出了不同的说法(指标「销售额」采纳的是「created_at」;指标「退款率」采纳的是「paid_at」),无法确定哪一个是数据事实,都未写入数据目录" + } + } + }, + { + "source": "shop", + "table": "orders", + "item": "business_date", + "members": [ + { + "metric_id": "gmv", + "choice": "b" + }, + { + "metric_id": "refund_rate", + "choice": "b" + } + ], + "outcomes": { + "gmv": { + "write": false, + "reason": "「orders」的业务日期「created_at」曾被人工驳回,与这次指标「销售额」、「退款率」的选择相反,未写入。如需更改,请到数据目录中修改" + }, + "refund_rate": { + "write": false, + "reason": "「orders」的业务日期「created_at」曾被人工驳回,与这次指标「销售额」、「退款率」的选择相反,未写入。如需更改,请到数据目录中修改" + } + } + } + ] } }, "catalog_blocked": { From 4c3ba8bc1bd0ffd0faed4c09670cdb1ab77c9d94 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 09:31:28 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E6=9C=9F=204=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE=EF=BC=8C?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E6=A0=B8=E6=9F=A5=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E5=88=86=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E3=80=8D=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E3=80=8D=E4=B8=A4=E7=BB=84=E5=B9=B6=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=A1=B5=E3=80=81=E5=8D=B0=E7=AB=A0=E7=9A=84?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=8C=E5=86=99=E7=9B=AE=E5=BD=95=E9=A2=84?= =?UTF-8?q?=E5=91=8A=E6=8E=A8=E6=96=AD=E6=97=B6=E7=AE=97=E4=B8=8A=E8=A2=AB?= =?UTF-8?q?=E6=8C=A1=E4=B8=8B=E7=9A=84=E5=90=8C=E9=A1=B9=E6=8C=87=E6=A0=87?= =?UTF-8?q?=EF=BC=8C=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E6=A6=82=E5=86=B5?= =?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=B3=95=E4=B8=8E=E6=8E=92=E5=B8=83=E6=94=B9?= =?UTF-8?q?=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 出具缺口:双盲复算的「指标无法复算」是没核成,不再和两路不一致、结论被推翻一起叫「核查未通过」——splitIssuanceGaps 分成 failed「核查未通过」、unfinished「核查未完成」两组,时间线那一行、证据页、成果节点印章的悬停说明都按先未通过、再未完成、最后校验不完整排。check-decode 按服务端 GAP_* 和 _refuted_gap 的原话加出具契约的前缀拼出缺口再分组,服务端改了说法先变红;新预览用例 verify-gaps 画真的 EvidencePane 和成果节点卡片,check-verify 断言两组各列各的、横幅的「校验未全部完成」里没有它们、印章说明的先后,以及 390px 深浅色不溢出、可读(以前去掉这两处的实现所有检查照样全绿) - 时间线:无法复算那一行只说「已记为缺口」,不再一律说「按缺口计入出具」(探索运行不出具,接在侧枝上的缺口进不了出具);dual_check_catalog 带值的写法补上检查,和审批卡「会记为」用同一个函数、一字不差 - 双盲复算审批卡:工件里没有合并预告时(老工件、组合超上限、还没取到),被目录挡下、带着写到哪的同项指标也算进来——业务日期、去重规则说法不同就说都不记(服务端先归拢、轮不到对目录),有效记录条件同项上有被挡下的只说可能记、也可能不记;推出来的条目标 data-inferred,结尾换成「实际写入以时间线上的记录为准」。check-verify 用新夹具 time_rejected 断言销售额不再说「会记为」 - 反方审稿审批卡:「没能」改「未能」,提示不再说「没有真正查询数据」(触顶的查过),原因补上报告未引用数据库查询,时间线的下一步不再建议调高上限(这一条不含触顶的);「N 句未能反查」挪到降档说明后面,不再插在「出具会降档」标记和它的说明中间,句数不超过「无法判断」;不在出具路径上的那句写明是反方审稿节点(审批卡上的「本节点」读着像审批节点)。ChallengeSummary 收 runClass,给了就照它、不另取;check-challenge 的夹具改成和服务端约定一致(未能反查 2 句、无法判断 3 句),另断言给了运行类别时另取失败也照样写降档、不发请求 --- frontend/src/canvas/NodeCard.tsx | 8 +- frontend/src/dev/fixtures/challenge.json | 4 +- frontend/src/dev/fixtures/challenge.tsx | 42 ++++++-- frontend/src/dev/fixtures/dual_check.tsx | 75 +++++++++++++- frontend/src/lib/terms.ts | 64 ++++++++---- frontend/src/pages/runs/EvidencePane.tsx | 31 +++--- frontend/src/run/ChallengeSummary.tsx | 64 +++++++----- frontend/src/run/DualCheckApproval.tsx | 58 ++++++++--- frontend/src/run/decode.ts | 31 ++++-- scripts/check-challenge.mjs | 91 +++++++++++++---- scripts/check-decode.mjs | 95 +++++++++++++---- scripts/check-verify.mjs | 125 ++++++++++++++++++++++- 12 files changed, 551 insertions(+), 137 deletions(-) diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index a065555a..624024f9 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -898,12 +898,14 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps const tier = view.at != null && issuedAt != null && view.at < issuedAt ? undefined : issuance?.tier const tierClass = tier === 'formal' ? 'is-formal' : tier === 'degraded' ? 'is-degraded' : tier === 'withheld' ? 'is-withheld' : '' const detail = issuanceDetail?.tier ? issuanceDetail : undefined - // 双盲复算、反方审稿报上来的缺口是核查查出来的问题(两路不一致、结论被推翻),和「校验没跑全」分开说 - const { verify: verifyGaps, other: gaps } = splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : []) + // 双盲复算、反方审稿报上来的缺口和「校验没跑全」分开说,再分查出了问题(两路不一致、结论被推翻)和没核成(无法复算) + const { failed: verifyFailed, unfinished: verifyUnfinished, other: gaps } = + splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : []) const stampTitle = tier ? [ `出具判定:${issuanceLabel(tier)}`, detail ? `可追溯 ${detail.matched_numbers ?? 0} 个数字 / 核对 ${detail.metrics_checked ?? 0} 个指标` : '', - verifyGaps.length ? `${DUAL_CHECK_TEXT.verifyGaps.head}:${verifyGaps.join(';')}` : '', + verifyFailed.length ? `${DUAL_CHECK_TEXT.verifyGaps.failed}:${verifyFailed.join(';')}` : '', + verifyUnfinished.length ? `${DUAL_CHECK_TEXT.verifyGaps.unfinished}:${verifyUnfinished.join(';')}` : '', // 降档常常不是数字对不上,而是校验根本没跑全(叙述模板渲染为空、指标集为空): // 不写出来,悬停在一个「降档出具」上看不到任何理由 gaps.length ? `校验未全部完成:${gaps.join(';')}` : '', diff --git a/frontend/src/dev/fixtures/challenge.json b/frontend/src/dev/fixtures/challenge.json index 042e3469..6dbb3bf6 100644 --- a/frontend/src/dev/fixtures/challenge.json +++ b/frontend/src/dev/fixtures/challenge.json @@ -14,7 +14,9 @@ "dup": "run-chal-dup", "norecord": "run-chal-norecord", "unreviewed": "run-chal-unreviewed", - "offpath": "run-chal-offpath" + "offpath": "run-chal-offpath", + "over": "run-chal-over", + "given": "run-chal-given" }, "report": { "node_id": "write", diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index e1eb4998..5e8a4ffe 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -3,6 +3,7 @@ import { ReactFlow, ReactFlowProvider } from '@xyflow/react' import { Inspector } from '../../canvas/Inspector' import { NodeCard } from '../../canvas/NodeCard' import { ApprovalCard } from '../../run/RunPanel' +import { ChallengeSummary } from '../../run/ChallengeSummary' import { useCatalog } from '../../store/catalog' import { useStudio } from '../../store/studio' import type { Approval, ApprovalChallenge, ApprovalReport, Provider, Workflow } from '../../types' @@ -22,9 +23,16 @@ import fx from './challenge.json' * - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里 * - ?syn=challenge-approval-clear / -clear-flag:正式运行,没有被推翻的结论(降档 / 只标注),两种都不写降档、不写只标注 * - ?syn=challenge-approval-explore-flag:探索运行配只标注,同样不说任何和出具有关的话 - * - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),不说任何和出具有关的话 - * - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句没能反查(unreviewed),被推翻的会降档(degrades) + * - ?syn=challenge-approval-runfail:老服务端的审批(不带 run_class),运行类别也取不到(检查脚本先答 500):不说任何 + * 和出具有关的话。审批带着运行类别时照它说、不另取,见 challenge-summary-given + * - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句未能反查(unreviewed:被削弱、经受住的那两句换成数据源 + * 已停用、记为无法判断,计数跟着改成无法判断 3 句;工件由检查脚本按同样的改法合成),被推翻的会降档(degrades) + * - ?syn=challenge-approval-unreviewed-over:服务端给的 unreviewed(5)比「无法判断」的句数(1)还多(不该出现): + * 卡上按「无法判断」的句数说,两个数不打架 * - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假) + * - ?syn=challenge-summary-given:反方审稿那一块单独画,审批自带的运行类别(正式运行)由调用方传进来;检查脚本让这次运行的 + * GET 失败,卡上照样写「出具会降档」,也不另发这次请求。审批卡(RunPanel 的 ApprovalCard)把 approval.run_class + * 传给它之后,卡上走的就是这条路 * - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成) * - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null) * @@ -39,12 +47,13 @@ import fx from './challenge.json' const report = fx.report as unknown as ApprovalReport const challenge = fx.challenge as unknown as ApprovalChallenge -function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', reviewed: ApprovalReport): Approval { +/** runClass 为 null:老服务端的审批,不带运行类别 */ +function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory' | null, reviewed: ApprovalReport): Approval { return { id, run_id: runId, node_id: 'review', mode: 'approve', title: '本月月报可以发出吗?', status: 'pending', response: {}, payload: { kind: 'human_node', message: '请审核本月月报。', reports: [reviewed] }, created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''), - workflow_name: '月报', node_label: '主管审批', run_class: runClass, + workflow_name: '月报', node_label: '主管审批', ...(runClass ? { run_class: runClass } : {}), } } @@ -68,6 +77,18 @@ const clear: Partial = { counts: { stands: 1, weakened: 1, refuted: 0, inconclusive: 1 }, items: challenge.items.filter((it) => it.verdict !== 'refuted'), } +/** + * 有两句未能反查:被削弱(u5)、经受住(u8)的那两句换成数据源已停用、没查成,记为无法判断(服务端未能反查的每一句都 + * 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查 + */ +const UNREVIEWED_UNITS = ['u5', 'u8'] +const unreviewed: Partial = { + counts: { stands: 0, weakened: 0, refuted: 1, inconclusive: 3 }, + items: challenge.items.map((it) => (UNREVIEWED_UNITS.includes(it.unit) ? { ...it, verdict: 'inconclusive' } : it)), + artifact: 'f'.repeat(64), + unreviewed: 2, + degrades: true, +} // ---------------------------------------------------------------- 检查器 @@ -201,7 +222,7 @@ export const cases: Record JSX.Element> = { approval={approvalOf('ap-chal-10', RUNS.exploreFlag, 'exploratory', withChallenge({ on_refuted: 'flag' }))} /> ), 'challenge-approval-runfail': () => ( - + ), 'challenge-approval-dup': () => ( JSX.Element> = { ), 'challenge-approval-unreviewed': () => ( + approval={approvalOf('ap-chal-14', RUNS.unreviewed, 'formal', withChallenge(unreviewed))} /> + ), + 'challenge-approval-unreviewed-over': () => ( + + ), + 'challenge-summary-given': () => ( +
+ +
), 'challenge-approval-offpath': () => ( :两个指标的预告换成 merges 里那一种组合按它的目录算的(catalog;和 variant、peer 一起用,换上的 SQL * 照它的 gmv、refund_rate 写)。合起来的预告(joint)在完整对照里,由检查脚本按同一种组合伪造工件 * - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」 + * + * ?syn=verify-gaps:出具缺口里有双盲复算、反方审稿报上来的几条(两路结果不一致、结论被推翻、指标无法复算)和一条 + * 校验没做完的。上面是运行详情的证据页(真的 EvidencePane:证据图、审计接口由检查脚本伪造),下面是同一次运行的成果 + * 节点卡片(出具印章的悬停说明):两处都把核查的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下 */ const approval = (patch: Partial = {}): Approval => ({ ...(data.approval as unknown as Approval), @@ -103,6 +108,73 @@ function InspectorCase() { ) } +/** verify-gaps 用例的运行和出具明细(库、指标、地名都是虚构的) */ +const VERIFY_GAPS = { + run: 'run-verify-gaps', + gaps: { + failed: ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'], + unfinished: ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'], + other: ['叙述模板渲染为空(路径可能有误)'], + }, +} +const GAPS = VERIFY_GAPS.gaps +const GAPS_ISSUANCE = { + tier: 'degraded', matched_numbers: 3, metrics_checked: 2, missing_required: [], missing_expected: [], unmatched_numbers: [], + // 缺口的先后照服务端(出具契约按上游节点的先后收):核查的和别的混在一起,分组靠前缀认 + gaps: [GAPS.failed[0], GAPS.other[0], GAPS.unfinished[0], GAPS.failed[1]], +} +const GAPS_RUN: Run = { + id: VERIFY_GAPS.run, workflow_id: null, workflow_name: '周报(预览)', status: 'succeeded', input: {}, + output: { _issuance: GAPS_ISSUANCE }, error: null, usage: {} as Run['usage'], run_class: 'formal', +} +const GAPS_GRAPH = { + nodes: [ + { id: 'dc', type: 'dual_check', position: { x: 0, y: 40 }, data: { label: '复核周报口径', config: {} } }, + { id: 'done', type: 'output', position: { x: 300, y: 40 }, data: { label: '成果', config: {} } }, + ], + edges: [{ id: 'g1', source: 'dc', target: 'done' }], +} as unknown as GraphSpec + +/** + * 证据页和成果节点卡片:图放进 studio store,再按一次运行的事件灌进去(出具事件带着完整的明细,卡片的印章按它画、 + * 悬停说明按它写)。只在这个用例渲染时改 store + */ +function VerifyGapsCase() { + useState(() => { + const { nodes, edges } = toFlow(GAPS_GRAPH) + const st = useStudio.getState() + st.clearRun() + useStudio.setState({ nodes, edges, selectedId: null, run: { ...GAPS_RUN, status: 'running', output: {} }, runSnapshot: null }) + const at = Date.now() / 1000 - 5 + const events: RunEvent[] = [ + { seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 2 } }, + { seq: 2, type: 'node.started', node_id: 'dc', ts: at + 0.1, data: {} }, + { seq: 3, type: 'node.finished', node_id: 'dc', ts: at + 1, data: { duration_ms: 900 } }, + { seq: 4, type: 'node.started', node_id: 'done', ts: at + 1.1, data: {} }, + { seq: 5, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { ...GAPS_ISSUANCE, unmatched: 0 } }, + { seq: 6, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 200 } }, + { seq: 7, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output: { _issuance: GAPS_ISSUANCE }, duration_ms: 1400 } }, + ] + for (const e of events) useStudio.getState().applyEvent(e) + return true + }) + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + return ( +
+
+ undefined} refreshKey="" /> +
+
+ + + +
+
+ ) +} + export const cases: Record JSX.Element> = { 'dual-check-approval': () => (
@@ -123,4 +195,5 @@ export const cases: Record JSX.Element> = {
), 'dual-check-inspector': () => , + 'verify-gaps': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index b83b3c54..dedf7245 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3336,8 +3336,8 @@ export const WHATIF_TEXT = { /** * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算的日志。核查的上限叫法 - * (limitLabel)和出具缺口里「核查未通过」那一组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的时间线说法在 - * CHALLENGE_TEXT.log。 + * (limitLabel)和出具缺口里「核查未通过」「核查未完成」那两组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的 + * 时间线说法在 CHALLENGE_TEXT.log。 * * 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为 * 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口, @@ -3492,8 +3492,9 @@ export const DUAL_CHECK_TEXT = { * 同一次裁决里几个指标按当前的选择落到同一张表同一项上时,服务端写目录前把它们归拢(dual_check_facts._merged): * 说法相同的写一次,有效记录条件用 AND 合成一句,业务日期、去重规则说法不同的都不写,驳回过的拿出来不写。对照工件 * 里有服务端把每种组合算好的结果(catalog_joint)时照它说:会写的是合起来的那个值,并写明和哪几个指标一起记 - * (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择有条件地说(conflict、joined), - * tail 里说明预告是按当前的选择算的 + * (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择推(conflict、joined、pending), + * 被目录挡下的同项指标也算进来,推不准的(pending)只说可能,结尾换成 tailInferred:推出来的不是服务端算好的结果, + * 不能和 tail 一样只在「审批期间目录有改动」时才不准 */ catalogPreview: { head: '记入数据目录:', @@ -3512,10 +3513,22 @@ export const DUAL_CHECK_TEXT = { conflictSame: (choice: string, metrics: string) => `${choice}时与${metrics}的选择说法不同,都不记`, /** 有效记录条件接在 write / writeSame 后面:和这些指标当前采用的条件合成一句写 */ joined: (metrics: string) => `,与${metrics}采用的条件用 AND 合成一句`, + /** + * 没有算好的合并结果、同一项上又有被目录挡下的指标(有效记录条件):挡下的原因卡上认不出(驳回过的会被拿掉, + * 别的原因会连这一边一起挡下),写不写说不准,只说可能。value 同 write + */ + pending: (choice: string, where: string, value: string, metrics: string) => + `${choice}时,${where}可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`, + /** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */ + pendingSame: (choice: string, value: string, metrics: string) => + `${choice}时可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`, /** 「退款率」、「订单数」;多于 3 个只列前 3 个 */ metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、') + (names.length > 3 ? `等 ${names.length} 个指标` : ''), tail: '写入的项记为人工确认的事实。以上按发起审批时的数据目录和各指标当前的选择预告,审批期间数据目录有改动时,以时间线上的记录为准', + /** 有一条是按其余指标当前的选择推出来的(没有算好的合并结果):推断不一定准,实际写入一律以时间线为准 */ + tailInferred: '写入的项记为人工确认的事实。几个指标落到同一项上时,以上是按各指标当前的选择推断的结果,' + + '实际写入以时间线上的记录为准', }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, @@ -3589,17 +3602,23 @@ export const DUAL_CHECK_TEXT = { /** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */ limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`), limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', - /** dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列) */ - unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,按缺口计入出具` - : '双盲复算有指标无法复算,按缺口计入出具'), + /** + * dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列)。只说记为缺口, + * 不说出具:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说(同 gaps) + */ + unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,已记为缺口` + : '双盲复算有指标无法复算,已记为缺口'), uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行', }, /** * 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、 - * 证据页把它们另起一组,不和「校验没跑全」混在一起——它们是核查查出来的问题,不是校验没做完 + * 证据页把它们另起,不和「校验没跑全」混在一起,再按是查出了问题还是没核成分两组(run/decode.ts 的 splitIssuanceGaps): + * failed 是核查查出来的问题(两路结果不一致、结论被推翻),unfinished 是没核成的(指标无法复算:合并查询、遮罩列的 + * 输入做不了复算,盲算触顶、出错)——没核过的数不能说成「未通过」,读的人会以为这个数被查出了错 */ verifyGaps: { - head: '核查未通过', + failed: '核查未通过', + unfinished: '核查未完成', }, } @@ -3689,11 +3708,17 @@ export const CHALLENGE_TEXT = { degrade: '出具会降档', degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具', flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位', - /** 按降档处置,但节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具 */ - degradeOff: '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位', - /** 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了等,这几句根本没查成,只是记为无法判断 */ - unreviewed: (n: number) => `${formatNumber(n)} 句没能反查,已记为无法判断`, - unreviewedHint: '核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线', + /** + * 按降档处置,但反方审稿节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具。这句画在人工审批 + * 的卡上(画布上也会在审批节点旁弹出),主语写明是反方审稿节点,不说「本节点」——那里的本节点是审批节点 + */ + degradeOff: '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位', + /** + * 服务端的 unreviewed:反查没有完成的句数(核查模型不可用、触顶没给出结论、数据源已不存在或已停用、报告没有引用 + * 数据库查询……),都记为无法判断。触顶的可能已经查过几次,不能说成「没有查询数据」;不会多于「无法判断」的句数 + */ + unreviewed: (n: number) => `${formatNumber(n)} 句未能反查,已记为无法判断`, + unreviewedHint: '反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,或报告未引用数据库查询,原因见运行时间线', /** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */ noRecord: '反方审稿记录未保存,无法查看理由和查询', more: (n: number) => `另有 ${formatNumber(n)} 句未列出`, @@ -3734,10 +3759,13 @@ export const CHALLENGE_TEXT = { refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', - /** challenge_unreviewed:有句子没能反查(核查模型不可用、数据源不在了……),记为无法判断 */ - unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论没能反查,已记为无法判断` - : '反方审稿有结论没能反查,已记为无法判断'), - unreviewedNext: '这几句没有真正查询数据。请检查核查模型和数据源是否可用,或在「设置 → 偏好设置 → 核查」中调高上限后重新运行', + /** + * challenge_unreviewed:有句子未能反查(核查模型不可用、整理证据出错、报告没有引用数据库查询、数据源不在了、一条 + * 查询都没执行成功),记为无法判断。触顶的不在这里(由 challenge_limit 说),所以建议里不提调高上限 + */ + unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论未能反查,已记为无法判断` + : '反方审稿有结论未能反查,已记为无法判断'), + unreviewedNext: '原因见展开内容。请检查核查模型和数据源是否可用,修正后重新运行;报告未引用数据库查询的结论无法反查', /** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */ sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'), sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`, diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 44c10062..a0b7b887 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -312,24 +312,31 @@ function useAudit(runId: string, key: string): AuditState { } /** - * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的一组「核查未通过」。那几条是核查查出来的问题(两路结果不一致、 - * 结论被推翻、指标无法复算),横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来, - * 紧跟在它下面单列,颜色和横幅里的缺口一样 + * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」 + * (指标无法复算)。横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,紧跟在它下面单列, + * 颜色和横幅里的缺口一样(分组见 run/decode.ts 的 splitIssuanceGaps)。导出给预览用例(dev/fixtures/dual_check.tsx) */ -function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { - const { verify, other } = useMemo( +export function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { + const { failed, unfinished, other } = useMemo( () => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance]) - const shown = useMemo(() => (verify.length ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + const verify = failed.length + unfinished.length > 0 + const shown = useMemo(() => (verify ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + const group = (kind: 'failed' | 'unfinished', gaps: string[]) => gaps.length > 0 && ( +
+
{DUAL_CHECK_TEXT.verifyGaps[kind]}
+
    + {gaps.map((g) =>
  • · {g}
  • )} +
+
+ ) return (
- {verify.length > 0 && ( -
-
{DUAL_CHECK_TEXT.verifyGaps.head}
-
    - {verify.map((g) =>
  • · {g}
  • )} -
+ {group('failed', failed)} + {group('unfinished', unfinished)}
)}
diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index f8f7892b..1e850522 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -14,40 +14,50 @@ export interface ChallengeSummaryProps { /** 这张审批卡审的报告(payload.reports,和 ApprovalReports 拿到的是同一份) */ reports: ApprovalReport[] runId: string + /** + * 这次运行的类别(审批自带的 approval.run_class,同 ApprovalReports 的 runClass)。给了就照它说、不再另取;没给 + * (老服务端的审批没有这一项)才按运行 id 取一次 + */ + runClass?: string | null } /** - * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),有句子根本没反查成时 - * 另起一行说「N 句没能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行 - * 过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 + * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句, + * 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方 + * 的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 * * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 * 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它(按降档处置、又接 * 在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档处置却不在出具 - * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别不由审批卡传进来,按运行 id 从证据 store - * 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新拿到焦点时再取)。 + * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别照审批卡给的(runClass,审批自带的那一项, + * 卡顶写的也是它);没给时按运行 id 从证据 store 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新 + * 拿到焦点时再取)。 * * 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。 */ /** - * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 没能反查的句数,degrades 被推翻的会不会让出具降档。 + * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 未能反查的句数,degrades 被推翻的会不会让出具降档。 * 老服务端都不给 */ type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } +type RunClass = 'formal' | 'exploratory' +const runClassOf = (v: unknown): RunClass | null => (v === 'formal' || v === 'exploratory' ? v : null) -export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) { +export function ChallengeSummary({ reports, runId, runClass }: ChallengeSummaryProps) { const reviewed = useMemo(() => reports.flatMap((r) => { const challenge = challengeOf(r) return challenge ? [{ report: r, challenge }] : [] }), [reports]) if (!reviewed.length) return null - return + return } -function ChallengeList({ reviewed, runId }: { - reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string +function ChallengeList({ reviewed, runId, given }: { + reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string; given: RunClass | null }) { - const run = useRunClass(runId) + // 审批卡给了运行类别就照它,不另取:另取的那一次失败了,正式运行里的「出具会降档」会无端消失,而卡顶照样写着正式运行 + const fetched = useRunClass(given ? '' : runId) + const run = given ?? fetched return ( <> {reviewed.map(({ report, challenge }) => ( @@ -104,11 +114,11 @@ function challengeOf(report: ApprovalReport): Challenge | null { } /** - * 这次运行的类别:取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具有关的话都不说(卡顶照审批自带的运行 - * 类别写着,这里再猜一个只会和它对不上)。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发), - * 取到了正式运行,降档的提示随之出现 + * 这次运行的类别(审批卡没给时才取,runId 为空就不取):取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具 + * 有关的话都不说。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发),取到了正式运行,降档的提示 + * 随之出现 */ -function useRunClass(runId: string): 'formal' | 'exploratory' | null { +function useRunClass(runId: string): RunClass | null { const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined)) const load = useEvidence((s) => s.loadRunClass) useEffect(() => { if (runId) void load(runId) }, [runId, load]) @@ -119,7 +129,7 @@ function useRunClass(runId: string): 'formal' | 'exploratory' | null { window.addEventListener('focus', again) return () => window.removeEventListener('focus', again) }, [status, runId, load]) - return status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null + return status === 'ok' ? runClassOf(slot?.data) : null } // ---------------------------------------------------------------- 取工件 @@ -212,7 +222,7 @@ const focusLost = () => !document.activeElement || document.activeElement === do // ---------------------------------------------------------------- 卡片 function ChallengeBlock({ report, challenge, run }: { - report: ApprovalReport; challenge: Challenge; run: 'formal' | 'exploratory' | null + report: ApprovalReport; challenge: Challenge; run: RunClass | null }) { const [open, setOpen] = useState>(() => new Set()) const [all, setAll] = useState(false) @@ -226,7 +236,8 @@ function ChallengeBlock({ report, challenge, run }: { const flagged = challenge.on_refuted === 'flag' // 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算 const degrades = challenge.degrades ?? !flagged - const unreviewed = challenge.unreviewed ?? 0 + // 未能反查的每一句都记为无法判断(服务端的约定),不会多于概况里的「无法判断」:对不上时按概况的数说,两个数不打架 + const unreviewed = Math.min(challenge.unreviewed ?? 0, counts.inconclusive) // 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件 const extra = all && detail.data ? detail.data.claims.filter((c) => !items.some((it) => sameClaim(c, it))) : [] const rows: ChallengeItem[] = [...items, ...extra] @@ -283,14 +294,8 @@ function ChallengeBlock({ report, challenge, run }: { ) : (
{CHALLENGE_TEXT.none}
)} - {/* 根本没反查成的几句:「无法判断」里分不出查了数据不够和一条都没查,单独说一次 */} - {unreviewed > 0 && ( -

- {CHALLENGE_TEXT.unreviewed(unreviewed)} - 。{CHALLENGE_TEXT.unreviewedHint} -

- )} - {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */} + {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行)。 + 紧跟在概况那一行(「出具会降档」的标记)下面,中间不插别的话 */} {refuted && !flagged && degrades && (
{CHALLENGE_TEXT.degradeHint}
)} @@ -300,6 +305,13 @@ function ChallengeBlock({ report, challenge, run }: { {refuted && flagged && (
{CHALLENGE_TEXT.flagNote}
)} + {/* 反查没完成的几句:「无法判断」里分不出查了数据不够和反查没做完,单独说一次 */} + {unreviewed > 0 && ( +

+ {CHALLENGE_TEXT.unreviewed(unreviewed)} + 。{CHALLENGE_TEXT.unreviewedHint} +

+ )} {rows.length > 0 && (
    {rows.map((item, i) => { diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 048e0ad2..94a9fd44 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -213,9 +213,15 @@ interface Clause { joint: boolean /** 合并结果里和别的指标一起记入 */ together: boolean - /** 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined) */ + /** + * 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined)、同一项上有被 + * 目录挡下的指标,写不写说不准、只说可能(pending)。inferred:这一条是推出来的(以上几种,以及推的路上照单个指标的 + * 预告说的),结尾换成「以时间线上的记录为准」的一般说法 + */ conflict: boolean joined: boolean + pending: boolean + inferred: boolean } /** @@ -224,12 +230,20 @@ interface Clause { * 那一边认出了写到哪的;被目录挡下的也算——服务端照样并进来): * - 只有它自己:照它自己的预告说,会写的说会记为什么,不写的说原因; * - 还有别的:照对照工件里服务端为这几个成员算好的那一条(catalog_joint)说,会写的是合起来的值、写明和谁一起记, - * 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时按旧办法推:会写的一边,业务日期、去重规则说法 - * 不同说都不记,有效记录条件说合成一句,结尾说以时间线上的记录为准 + * 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时照服务端归拢的先后推(inferred),被目录挡下的 + * 同项指标也算进来——服务端先归拢、后对目录: + * - 业务日期、去重规则:有一个说法不同(不论它会不会被目录挡下),归拢就失败,都不记;说法都一样时结果和单个指标 + * 一样(同一个说法对同一份目录),照自己的预告说; + * - 有效记录条件:自己被挡下的,合起来也写不进去(驳回过的会被拿掉,别的原因连合起来的整句一起挡下),照自己的原因说; + * 同项上有别的指标被挡下时说不准(挡下它的若是驳回,拿掉它照写;若是别的原因,这一边也一起挡下),只说可能; + * 都会写、条件不同的说合成一句 + * 推出来的不是服务端算好的结果,结尾换成以时间线上的记录为准的一般说法 */ function clausesOf(view: MetricView, views: MetricView[], choices: Record, joint: JointEntry[]): Clause[] { const P = T.catalogPreview - const blank = { write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false } + const blank = { + write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false, pending: false, inferred: false, + } const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), ...blank }) const out: Clause[] = [] for (const side of ['a', 'b'] as const) { @@ -266,19 +280,28 @@ function clausesOf(view: MetricView, views: MetricView[], choices: Record { - const theirs = o.preview?.[choices[o.id] as 'a' | 'b'] - return theirs?.write && !sameFact(mine.target!.item, theirs.raw, mine.raw) - }).map((o) => o.name))] - if (differ.length && mine.target!.item !== 'valid_filter') { + // 没有算好的合并结果:照服务端归拢的先后推。同项上的指标不论会写还是被挡下都带着本来要写的说法(raw),一起算 + const item = mine.target!.item + const theirs = peers.map((o) => ({ name: o.name, side: o.preview![choices[o.id] as 'a' | 'b']! })) + // 认不出本来要写什么(没带 raw)的不算说法不同:说不准,落到下面的「可能」里 + const differ = [...new Set(theirs.filter((t) => t.side.raw != null && !sameFact(item, t.side.raw, mine.raw)).map((t) => t.name))] + const held = [...new Set(theirs.filter((t) => !t.side.write).map((t) => t.name))] + const inferred = { inferred: true } + if (differ.length && item !== 'valid_filter') { + // 业务日期、去重规则说法不同:归拢这一步就不写了,轮不到对目录,挡下的那个也一样 const names = P.metricNames(differ) - out.push({ side, ...blank, conflict: true, where: !short, + out.push({ side, ...blank, ...inferred, conflict: true, where: !short, text: short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], mine.target!.where, names) }) continue } - out.push(differ.length ? writing(mine.value, { joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value)) + if (!mine.write) { out.push({ ...blocked(mine.reason), ...inferred }); continue } + if (held.length) { + const names = P.metricNames(held) + out.push({ side, ...blank, ...inferred, pending: true, where: !short, + text: short ? P.pendingSame(T.choices[side], mine.value, names) : P.pending(T.choices[side], mine.target!.where, mine.value, names) }) + continue + } + out.push(differ.length ? writing(mine.value, { ...inferred, joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value, inferred)) } out.push(skip('neither')) return out @@ -607,10 +630,12 @@ function MetricBlock({ view, name, choice, clauses, onChoose }: { /** * 服务端预告的「记入数据目录」:三个选择逐条一句(clausesOf),审核人当前选的那一条加重——选之前三条都看得到,选了之后 - * 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里 + * 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里。有一条 + * 是推出来的(没有算好的合并结果)时,结尾换成「实际写入以时间线上的记录为准」的一般说法 */ function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: DualCheckChoice }) { const P = T.catalogPreview + const inferred = clauses.some((c) => c.inferred) return (
    {P.head}
    @@ -619,12 +644,13 @@ function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: D
  • + data-conflict={c.conflict ? '' : undefined} data-joined={c.joined ? '' : undefined} + data-pending={c.pending ? '' : undefined} data-inferred={c.inferred ? '' : undefined}> {c.text}
  • ))}
-

{P.tail}

+

{inferred ? P.tailInferred : P.tail}

) } diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index cceb4d8c..b3286247 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -718,7 +718,7 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: /** * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。 - * 无法复算(dual_check_unchecked)、没能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 + * 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 * 期 4 补上的:以前认不得,只能照原话显示 */ const VERIFY_LOG_CODES = new Set([ @@ -734,13 +734,23 @@ const CL = CHALLENGE_TEXT.log /** * 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀 * (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。 - * 它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),时间线、成果卡片、证据页另起一组「核查未通过」, - * 不和「校验没跑全」的缺口混在一起。其余的照旧 + * 时间线、成果卡片、证据页把它们另起,不和「校验没跑全」的缺口混在一起,再分两组(叫法在 DUAL_CHECK_TEXT.verifyGaps): + * - failed「核查未通过」:核查查出来的问题——两路结果不一致(审核人选了盲算、两边都不对、没作出选择),结论被推翻; + * - unfinished「核查未完成」:没核成的——指标无法复算(nodes/dual_check.py 的 GAP_UNCHECKED「指标「X」无法复算:原因」)。 + * 合并查询、遮罩列的输入做不了复算,盲算触顶、出错的也没核过,说成「未通过」读的人会以为这个数被查出了错。 + * 其余的照旧 */ const VERIFY_GAP_KINDS = [NODE_TYPE_LABEL.dual_check, NODE_TYPE_LABEL.challenge] -export function splitIssuanceGaps(gaps: readonly string[]): { verify: string[]; other: string[] } { - const isVerify = (g: string) => VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`)) - return { verify: gaps.filter(isVerify), other: gaps.filter((g) => !isVerify(g)) } +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +/** 双盲复算报的「无法复算」:前缀(节点名可有可无)之后紧跟 GAP_UNCHECKED 的开头 */ +const UNCHECKED_GAP = new RegExp(`^${escapeRe(NODE_TYPE_LABEL.dual_check)}(?:「.*?」)?:指标「.+?」无法复算:`) +export function splitIssuanceGaps(gaps: readonly string[]): { failed: string[]; unfinished: string[]; other: string[] } { + const out = { failed: [] as string[], unfinished: [] as string[], other: [] as string[] } + for (const g of gaps) { + const verify = VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`)) + ;(!verify ? out.other : UNCHECKED_GAP.test(g) ? out.unfinished : out.failed).push(g) + } + return out } const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) /** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */ @@ -1140,7 +1150,7 @@ function explainLog(code: string | undefined, message: string, d: Record (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)), } const RUNS = fx.runs +// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用 const RUN_CLASS = { [RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal', [RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal', [RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal', - [RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', + [RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', [RUNS.over]: 'formal', +} +// challenge-approval-unreviewed 的工件:同第一个用例,被削弱(u5)、经受住(u8)的两句换成数据源已停用、没查成 +// (服务端 unreached 的写法:记为无法判断,理由是系统说的,没有理由文档和查询) +const UNREVIEWED_ART = 'f'.repeat(64) +const UNREVIEWED_UNITS = ['u5', 'u8'] +const UNREVIEWED_CONTENT = { + ...fx.artifacts[ART], + claims: fx.artifacts[ART].claims.map((c) => (UNREVIEWED_UNITS.includes(c.unit) ? { + ...c, verdict: 'inconclusive', rationale_doc: null, queries: [], + rationale: '这句引用的数据源「北仓库」已不存在或已停用,没有可以反查的数据源,这句未反查', + } : c)), } const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位' const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具' -const DEGRADE_OFF = '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位' -const UNREVIEWED = (n) => `${n} 句没能反查,已记为无法判断。核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线` +// 主语写明是反方审稿节点:这句画在人工审批的卡上,「本节点」读着像审批节点 +const DEGRADE_OFF = '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位' +const UNREVIEWED = (n) => `${n} 句未能反查,已记为无法判断。反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,` + + '或报告未引用数据库查询,原因见运行时间线' const FAILED = '无法获取反方审稿的详细内容' // F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面 const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份' @@ -152,6 +168,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { hits.artifacts.push(id) if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } }) if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } }) + if (id === UNREVIEWED_ART) return route.fulfill({ json: { id, content: UNREVIEWED_CONTENT } }) const content = fx.artifacts[id] return content ? route.fulfill({ json: { id, content } }) : route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } }) @@ -354,9 +371,9 @@ await section('正式运行、没有被推翻的结论:不写降档', async () }) await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => { - // 取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的 - // 运行类别写着正式运行,两处说法打架。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行 - // 降档提示才出现 + // 老服务端的审批不带运行类别,只能按运行 id 取。取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为 + // 正式运行……会降档」外加重试。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行降档 + // 提示才出现。审批带着运行类别时照它说、不另取,见下一节 const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail') const block = page.locator('[data-challenge="write"]') await block.waitFor() @@ -386,31 +403,67 @@ await section('运行类别取不到:不说任何和出具有关的话,重 await ctx.close() }) -await section('有句子根本没反查成:单独说一行,不混在「无法判断」里', async () => { - // 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了……这几句记为无法判断,可一条查询都没执行。「无法判断 2 句」 - // 分不出是查了、数据不够,还是根本没查,审批卡单独说 +await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => { + // 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里 + // 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的 + // GET 一律答 404 + const { ctx, page, hits, errors } = await open('challenge-summary-given') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await page.waitForTimeout(500) + check('给了运行类别(正式运行):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block)) + check('……不另取运行类别', !hits.runs.includes(RUNS.given), hits.runs.join('、')) + check('……页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => { + // 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断 + // 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它 const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed') const block = page.locator('[data-challenge="write"]') await block.waitFor() await runClassSettled(page, hits, RUNS.unreviewed) const line = block.locator('[data-challenge-unreviewed]') - check('写明几句没能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2) + check('写明几句未能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2) && (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有') - check('……概况照服务端的计数写,不另算', (await text(block.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句') + const inconclusive = await text(block.locator('[data-challenge-count="inconclusive"]')) + check('……概况照服务端的计数写(无法判断 3 句:两句未能反查、一句查过了数据不够),未能反查的不多于无法判断', + inconclusive === '无法判断 3 句' && 2 <= Number(inconclusive.match(/\d+/)?.[0]), inconclusive) + check('……用「未能」,不说「没能」「真正」', !/没能|真正/.test(await text(block))) // degrades 为真、正式运行、有被推翻的:照常写降档 check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0) - check('没能反查:页面没有报错', errors.length === 0, errors.join(';')) + // 降档的说明紧跟在概况那一行(「出具会降档」的标记)下面,未能反查的那一行在它后面 + const order = await block.evaluate((el) => [...el.children].map((c) => (c.hasAttribute('data-challenge-counts') ? 'counts' + : c.hasAttribute('data-challenge-degrade-note') ? 'note' : c.hasAttribute('data-challenge-unreviewed') ? 'unreviewed' : '')).filter(Boolean)) + check('……降档的说明紧跟在降档标记那一行下面,未能反查的那一行排在后面', order.join(',') === 'counts,note,unreviewed', order.join(',')) + // 点开一句未能反查的:理由是系统说的原因,没有查询 + await toggle(page, 'u5').click() + await detail(page, 'u5').locator('[data-challenge-rationale-text]').waitFor() + check('点开未能反查的一句:理由写明数据源已停用、这句未反查', (await text(detail(page, 'u5').locator('[data-challenge-rationale-text]'))) + .includes('这句未反查'), await text(detail(page, 'u5'))) + check('未能反查:页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() // 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行 for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) { const other = await open(syn) await other.page.locator('[data-challenge="write"]').waitFor() await runClassSettled(other.page, other.hits, run) - check(`${syn}:没有没能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0 - && !(await text(other.page.locator('[data-challenge="write"]'))).includes('没能反查')) + check(`${syn}:没有未能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0 + && !(await text(other.page.locator('[data-challenge="write"]'))).includes('未能反查')) await other.ctx.close() } + // 服务端给的 unreviewed 比「无法判断」还多(不该出现):按「无法判断」的句数说,两个数不打架 + const over = await open('challenge-approval-unreviewed-over') + await over.page.locator('[data-challenge="write"]').waitFor() + await runClassSettled(over.page, over.hits, RUNS.over) + const capped = over.page.locator('[data-challenge-unreviewed]') + check('unreviewed 比「无法判断」多:按「无法判断」的句数说', (await capped.getAttribute('data-challenge-unreviewed')) === '1' + && (await text(capped)).startsWith('1 句未能反查') && (await text(over.page.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句', + await text(capped)) + await over.ctx.close() }) await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => { @@ -423,8 +476,8 @@ await section('按降档处置、却不在出具路径上(degrades 为假) check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0 && !(await text(block)).includes('出具会降档'), await text(block)) const off = page.locator('[data-challenge-degrade-off]') - check('写明本节点不在带出具契约的成果节点上游、不影响出具档位', await off.count() === 1 && (await text(off)) === DEGRADE_OFF, - await off.count() ? await text(off) : '没有') + check('写明反方审稿节点不在带出具契约的成果节点上游、不影响出具档位(不说「本节点」)', await off.count() === 1 && (await text(off)) === DEGRADE_OFF + && !(await text(off)).includes('本节点'), await off.count() ? await text(off) : '没有') check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0) check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() @@ -775,7 +828,7 @@ await section('窄屏与深浅色', async () => { check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) await ctx.close() } - // 几行说明(降档原因、没能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清 + // 几行说明(降档原因、未能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清 for (const theme of ['dark', 'light']) { const notes = [ ['challenge-approval', '[data-challenge-degrade-note]'], diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 570ba6cf..1e803546 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2181,10 +2181,11 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结 check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null)) }) -await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => { +await section('核查类日志与出具缺口(期 4):无法复算、未能反查、只查了部分数据源说人话,核查报上来的缺口另起两组', async () => { // 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成 - // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准—— - // 它们是核查查出来的问题,不是校验没做完 + // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准。 + // 核查报上来的另起,再分两组:查出了问题的(两路结果不一致、结论被推翻)是「核查未通过」,没核成的(指标无法复算) + // 是「核查未完成」——没核过的数说成「未通过」,读的人会以为这个数被查出了错 const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) const events = [ E(1, 'run.started', null, { total: 3 }), @@ -2202,7 +2203,7 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 E(9, 'node.started', 'done', { node_type: 'output', label: '成果' }), E(10, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 2, matched_numbers: 3, gaps: ['双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」', - '叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致'] }), + '叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致', '双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询'] }), E(11, 'node.finished', 'done', { duration_ms: 5 }), E(12, 'run.finished', null, { status: 'succeeded' }), ] @@ -2215,12 +2216,16 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 check('……标题里没有内部代码,原话留在展开区', NEW.every((c) => !/dual_check_|challenge_/.test(`${by(c)?.title}${by(c)?.sub ?? ''}${by(c)?.next ?? ''}`)) && by('challenge_unreviewed')?.detail === events[5].data.message) const un = by('dual_check_unchecked') - check('dual_check_unchecked:几个指标无法复算、计入缺口,副标题点名带原因(没名字的写 id),提醒色', - un?.title === '双盲复算有 2 个指标无法复算,按缺口计入出具' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限' - && un.level === 'warn' && !!un.next?.includes('核查'), `${un?.title} / ${un?.sub}`) + // 只说记为缺口,不说「计入出具」:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说 + check('dual_check_unchecked:几个指标无法复算、记为缺口(不提出具),副标题点名带原因(没名字的写 id),提醒色', + un?.title === '双盲复算有 2 个指标无法复算,已记为缺口' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限' + && un.level === 'warn' && !!un.next?.includes('核查') && !`${un.title}${un.sub}`.includes('出具'), `${un?.title} / ${un?.sub}`) const ur = by('challenge_unreviewed') - check('challenge_unreviewed:几句没能反查、记为无法判断,原因放副标题', ur?.title === '反方审稿有 2 句结论没能反查,已记为无法判断' - && ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('没有真正查询数据'), `${ur?.title} / ${ur?.sub}`) + // 用「未能」(同服务端原话「未能反查」),不说「没有真正查询数据」(触顶的句子查过);这一条不含触顶的句子 + // (由 challenge_limit 说),下一步不提调高上限 + check('challenge_unreviewed:几句未能反查、记为无法判断,原因放副标题,下一步不提上限', ur?.title === '反方审稿有 2 句结论未能反查,已记为无法判断' + && ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('核查模型') && !!ur.next?.includes('数据源') + && !ur.next.includes('上限') && !/没能|真正/.test(`${ur.title}${ur.next}`), `${ur?.title} / ${ur?.sub} / ${ur?.next}`) const lost = by('challenge_sources_lost') check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源' && lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'), @@ -2229,24 +2234,74 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 const issuance = steps.find((s) => s.kind === 'issuance') const title = issuance?.title ?? '' const detail = issuance?.detail ?? '' - check('出具那一行:核查报上来的两条归在「核查未通过」下', detail.split('\n').includes( - '核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail) - check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', detail.split('\n').some((l) => l.startsWith('校验不完整:') - && l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了')), detail) - check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', detail.split('\n').some((l) => l.startsWith('校验不完整:') - && l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail) const lines = detail.split('\n') - check('……核查那一组排在校验不完整前面,标题以档位开头', title.startsWith('降档出具:核查未通过:') - && lines.findIndex((l) => l.startsWith('核查未通过:')) < lines.findIndex((l) => l.startsWith('校验不完整:')), title) + check('出具那一行:核查查出了问题的两条归在「核查未通过」下', lines.includes( + '核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail) + check('……无法复算的那条归在「核查未完成」下,不在「核查未通过」下', lines.includes( + '核查未完成:双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询') + && !lines.some((l) => l.startsWith('核查未通过:') && l.includes('无法复算')), detail) + check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', lines.some((l) => l.startsWith('校验不完整:') + && l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了') && !l.includes('无法复算')), detail) + check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', lines.some((l) => l.startsWith('校验不完整:') + && l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail) + const at = (head) => lines.findIndex((l) => l.startsWith(head)) + check('……先「核查未通过」、再「核查未完成」、最后「校验不完整」,标题以档位开头', title.startsWith('降档出具:核查未通过:') + && at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验不完整:'), title) check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail) // splitIssuanceGaps:成果卡片、证据页按同一个函数分组 const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', - '缺少必需指标:gmv', '反方审稿报告没写完']) - check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({ - verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'], + '缺少必需指标:gmv', '反方审稿报告没写完', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择', + '双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对']) + check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算;无法复算的单列一组', JSON.stringify(split) === JSON.stringify({ + failed: ['反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择', + '双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'], + unfinished: ['双盲复算:指标「订单数」无法复算:输入来自合并查询'], other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split)) + // 和服务端的说法对得上:双盲复算、反方审稿报缺口的原话(nodes/dual_check.py 的 GAP_*、nodes/challenge.py 的 + // _refuted_gap)加上出具契约的前缀(nodes/io.py 的 _upstream_issuance_gaps:节点名就是类型叫法时只写「类型:」), + // 按服务端的写法拼出来再分组。服务端改了说法(比如「无法复算」换了个词),这里先变红 + const dcPy = readFileSync(`${root}backend/app/engine/nodes/dual_check.py`, 'utf8') + const chPy = readFileSync(`${root}backend/app/engine/nodes/challenge.py`, 'utf8') + const ioPy = readFileSync(`${root}backend/app/engine/nodes/io.py`, 'utf8') + const gapOf = (name) => dcPy.match(new RegExp(`^${name} = "([^"]+)"$`, 'm'))?.[1] + const fill = (tpl) => tpl?.replace('{name}', '订单数').replace('{reason}', '输入来自合并查询') + const refuted = chPy.match(/return f"(推翻了 \{len\(claims\)\} 句结论:\{_named\(claims\)\})"/)?.[1] + check('读出了服务端报缺口的写法和前缀的写法', ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].every((n) => !!gapOf(n)) + && !!refuted && ioPy.includes('prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:"'), + ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].map((n) => `${n}=${gapOf(n)}`).join(' / ')) + const prefixed = (kind, title, body) => (title === kind ? `${kind}:` : `${kind}「${title}」:`) + body + const served = mod.splitIssuanceGaps([ + ...['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED'].map((n) => prefixed('双盲复算', '复核', fill(gapOf(n)))), + prefixed('双盲复算', '双盲复算', fill(gapOf('GAP_UNCHECKED'))), + prefixed('反方审稿', '反方审稿', refuted?.replace('{len(claims)}', '1').replace('{_named(claims)}', '「东区增长主要来自新客」')), + ]) + check('……两路不一致(选了盲算、两边都不对、没作出选择)和被推翻的归「核查未通过」,无法复算的归「核查未完成」', + served.failed.length === 4 && served.unfinished.length === 1 && served.unfinished[0].startsWith('双盲复算:指标「订单数」无法复算:') + && served.other.length === 0, JSON.stringify(served)) + + // dual_check_catalog 的 written 带了写进去的值(期 4):时间线写「…记为「值」」,值的写法和审批卡上的「会记为「值」」 + // 同一个函数(catalogPatch 的 patchValueText),两处一字不差 + const cp = await import(await load('/src/run/catalogPatch.ts')) + const { DUAL_CHECK_TEXT: DCT } = await import(await load('/src/lib/terms.ts')) + const valued = flatten(mod.decodeRun([ + E(1, 'node.started', 'dc', { node_type: 'dual_check' }), + E(2, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入', + written: [{ source: 'shop', table: 'orders', item: 'business_date', value: { column: 'paid_at' } }, + { source: 'shop', table: 'refunds', item: 'valid_filter', value: "status <> 'test'" }], skipped: [] }), + E(3, 'node.finished', 'dc', {}), + ])).find((s) => s.code === 'dual_check_catalog') + const date = cp.patchValueText('business_date', { column: 'paid_at' }) + const cond = cp.patchValueText('valid_filter', "status <> 'test'") + check('dual_check_catalog 带值:业务日期写列名、有效记录条件写原句,「哪张表的哪一项记为「值」」', + valued?.title === '已将 2 项人工确认的事实记入数据目录' + && valued.sub === `「orders」(shop)的业务日期记为「${date}」;「refunds」(shop)的有效记录条件记为「${cond}」` + && date === 'paid_at' && cond === "status <> 'test'", `${valued?.title} / ${valued?.sub}`) + const card = DCT.approval.catalogPreview.write('采用盲算的结果', DCT.log.catalogItem('orders', 'shop', '业务日期'), date) + check('……值和审批卡上「会记为「值」」的写法一字不差', card.endsWith(`会记为「${date}」`) + && (valued?.sub ?? '').startsWith(`${DCT.log.catalogItem('orders', 'shop', '业务日期')}记为「${date}」`), `${card} / ${valued?.sub}`) + // 老日志缺字段、字段类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数 const odd = [['dual_check_unchecked', { metrics: 'x' }], ['challenge_unreviewed', { claims_skipped: 'x', reason: 3 }], ['challenge_sources_lost', { claims: null, sources: 'x' }]] diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7bfbfe10..7cb95b43 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -17,7 +17,11 @@ // 节点设置时有条件地说; // - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪; // 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端 -// 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本; +// 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的 +// 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目 +// 标出,老工件照旧纯文本; +// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」 +// 「核查未完成」两组,不放在「校验未全部完成」下; // - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 @@ -456,16 +460,23 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 }) /** - * 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined}。reason:不写、照服务端 - * 的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;conflict、joined:没有合并预告时 - * 按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句) + * 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined, pending, inferred}。 + * reason:不写、照服务端的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入; + * conflict、joined、pending:没有合并预告时按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句、同项上有 + * 被挡下的指标说不准只说可能);inferred:这一条是推出来的 */ const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')] .map((s) => [s.dataset.dualCatalogSide, { text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight), reason: s.dataset.reason != null, joint: s.dataset.joint != null, together: s.dataset.together != null, - conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, + conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, pending: s.dataset.pending != null, + inferred: s.dataset.inferred != null, }]))) +/** 一块预告的结尾:{kind: exact(照服务端算好的说)| inferred(有推出来的), text} */ +const previewTail = async (loc) => { + const tail = loc.locator('[data-dual-catalog-tail]') + return { kind: await tail.getAttribute('data-dual-catalog-tail'), text: await text(tail) } +} /** 不写的一条:选择的叫法加服务端的原话 */ const blockedText = (label, reason) => `${label}时不会记入:${reason}` const CHOICE_LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } @@ -662,6 +673,10 @@ await section('审批卡:几个指标落到同一项上、工件里没有合 const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]')) check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择') && note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note) + const tail = await previewTail(metric(page, 'gmv')) + check('……推出来的:结尾不说只有目录被改了才会不一致,说实际写入以时间线上的记录为准', tail.kind === 'inferred' + && tail.text.includes('推断') && tail.text.endsWith('实际写入以时间线上的记录为准') && !tail.text.includes('审批期间数据目录有改动时'), + `${tail.kind} / ${tail.text}`) // 退款率改选两边都不对:不再落到同一项上,销售额照常记 await choose(page, 'refund_rate', 'neither') g = await previewClauses(metric(page, 'gmv')) @@ -698,6 +713,42 @@ await section('审批卡:几个指标落到同一项上、工件里没有合 && cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句` && cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + // 被目录挡下的同项指标也算进来(2026-10 复核):目录里人工驳回过业务日期 created_at(夹具 time_rejected)。销售额采用 + // 原流程(paid_at,单独看会写),退款率采用盲算(created_at,单独看被驳回挡下,但带着写到哪)。服务端先归拢:说法不同, + // 一项都不写,轮不到对目录。以前只看会写的同伴,销售额照样加粗说「会记为 paid_at」 + const tr = fx.merges.time_rejected + check('夹具:退款率那一边被驳回挡下、带着写到哪,销售额那一边单独看会写,服务端一项都不写', + tr.catalog.refund_rate.b.write === false && tr.catalog.refund_rate.b.table === 'orders' && tr.catalog.gmv.a.write === true + && tr.written.length === 0, JSON.stringify(tr.catalog)) + const rj = await preview('dual-check-approval', { + query: `merge=time_rejected&peer=${tr.refund_rate}`, artifact: cardWith(tr.gmv, { peer: tr.refund_rate }), + }) + await rj.page.waitForSelector('[data-dual-check][data-state="ready"]') + await chooseAll(rj.page, 'time_rejected') + cur = await current(rj.page) + check('……两边加粗的那一句都说说法不同、都不记,销售额不再说「会记为」', ['gmv', 'refund_rate'].every((id) => cur[id]?.conflict + && !cur[id].write && cur[id].inferred && !cur[id].text.includes('会记为')), JSON.stringify(cur)) + check('……退款率被挡下的那一句照归拢的结果说(和销售额说法不同),不照它单独的驳回原因说', + cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', cur.refund_rate?.text) + const rjTail = await previewTail(metric(rj.page, 'gmv')) + check('……结尾换成实际写入以时间线上的记录为准', rjTail.kind === 'inferred' && rjTail.text.endsWith('实际写入以时间线上的记录为准'), rjTail.text) + + // 有效记录条件、同项上有被挡下的指标(夹具 filters_rejected:销售额采纳的条件曾被驳回):挡下它的若是驳回,服务端 + // 拿掉它、退款率照写;若是别的原因(和已有条件合不起来),退款率也一起挡下。卡上认不出是哪一种,退款率那一句只说可能 + const fr = fx.merges.filters_rejected + const fp = await preview('dual-check-approval', { + query: `merge=filters_rejected&peer=${fr.refund_rate}&variant=${fr.gmv}`, artifact: cardWith(fr.gmv, { peer: fr.refund_rate }), + }) + await fp.page.waitForSelector('[data-dual-check][data-state="ready"]') + await chooseAll(fp.page, 'filters_rejected') + cur = await current(fp.page) + check('有效记录条件、同项上有被挡下的:退款率说可能记为自己的条件、也可能不记,不说定了「会记为」', !!cur.refund_rate?.pending + && !cur.refund_rate.write && cur.refund_rate.inferred && cur.refund_rate.text === '采用原流程的结果时,「orders」(shop)的有效记录条件' + + `可能记为「${fr.catalog.refund_rate.a.value}」,也可能不记:要与「销售额」的选择合起来才能确定`, cur.refund_rate?.text) + check('……销售额(被驳回挡下)照它的原因说不会记入,和服务端一致(确实没写)', !!cur.gmv?.reason && !cur.gmv.write + && cur.gmv.text === blockedText(CHOICE_LABEL.a, fr.catalog.gmv.a.reason) + && !fr.written.some((w) => w.value === fr.catalog.gmv.a.value), cur.gmv?.text) + // 窄屏:说法不同的那一句更长,也不撑宽 for (const theme of ['dark', 'light']) { const narrow = await open('time_split', { width: 390, height: 844, theme }) @@ -765,6 +816,10 @@ await section('审批卡:几个指标落到同一项上时,照完整对照 const { page, errors } = await open('time_split') for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(page, id, c) let cur = await current(page) + const exact = await previewTail(metric(page, 'gmv')) + check('照服务端算好的合并结果说:结尾只说审批期间目录有改动时以时间线为准,不说是推断的', exact.kind === 'exact' + && exact.text.includes('审批期间数据目录有改动时') && !exact.text.includes('推断') + && !Object.values(cur).some((c) => c?.inferred), `${exact.kind} / ${exact.text}`) check('分开选:两边都说不会记入、写明给出了不同的说法', ['gmv', 'refund_rate'].every((id) => cur[id]?.reason && cur[id].text.includes('给出了不同的说法') && cur[id].text.includes('都未写入数据目录')), JSON.stringify(cur)) await choose(page, 'refund_rate', 'a') @@ -1043,6 +1098,66 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅 await shot(rail.page, 'approval-rail-dark') }) +await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { + // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」 + // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻) + // 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例 + // verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造 + const RUN = 'run-verify-gaps' + const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'] + const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'] + const OTHER = ['叙述模板渲染为空(路径可能有误)'] + const extra = async (req, url) => { + if (url.pathname === `/api/runs/${RUN}/evidence`) { + return { json: { run_id: RUN, mode: 'none', note: '这次运行没有报告文档', seal: { sealed: true, ok: true, manifest_seq: 7 } } } + } + if (url.pathname === `/api/runs/${RUN}/evidence/audit`) return { status: 404, json: { detail: 'Not Found' } } + return null + } + const items = (els) => els.map((el) => el.textContent.replace(/^·\s*/, '').trim()) + for (const [width, theme] of [[1280, 'dark'], [390, 'dark'], [390, 'light']]) { + const what = `${width}px(${theme})` + const { page, errors } = await preview('verify-gaps', { extra, width, height: 900, theme }) + await page.waitForSelector('[data-evidence-verify-gaps]') + const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-verify-group]')].map((g) => ({ + kind: g.dataset.evidenceVerifyGroup, head: g.firstElementChild?.textContent.trim(), + items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()), + }))) + check(`${what} 证据页:横幅下面另起两组,先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算)`, + JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过', items: FAILED }, + { kind: 'unfinished', head: '核查未完成', items: UNFINISHED }]), JSON.stringify(groups)) + const banner = await page.locator('[data-evidence-issuance] [data-issuance-gaps] li').evaluateAll(items) + check(`${what} ……横幅的「校验未全部完成」里只剩校验没做完的那条`, JSON.stringify(banner) === JSON.stringify(OTHER) + && (await text(page.locator('[data-evidence-issuance] [data-issuance-gaps]'))).startsWith('校验未全部完成'), JSON.stringify(banner)) + // 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的 + const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp') + await stamp.waitFor() + const lines = ((await stamp.getAttribute('title')) ?? '').split('\n') + const at = (head) => lines.findIndex((l) => l.startsWith(head)) + check(`${what} 成果节点印章:悬停说明先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`, + lines[at('核查未通过:')] === `核查未通过:${FAILED.join(';')}` && lines[at('核查未完成:')] === `核查未完成:${UNFINISHED.join(';')}` + && lines[at('校验未全部完成:')] === `校验未全部完成:${OTHER.join(';')}` + && at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验未全部完成:'), lines.join(' / ')) + const w = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`${what}:证据页不横向溢出`, w <= 0, `${w}px`) + const c = await page.evaluate(() => { + const box = document.querySelector('[data-evidence-verify-gaps]') + const bgOf = (el) => { + for (let n = el; n && n !== document.documentElement; n = n.parentElement) { + const v = getComputedStyle(n).backgroundColor + if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v + } + return 'rgba(0, 0, 0, 0)' + } + return { fg: getComputedStyle(box).color, bg: bgOf(box), base: getComputedStyle(document.body).backgroundColor } + }) + const ratio = contrast(c.fg, c.bg, c.base) + check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`) + await shot(page, `verify-gaps-${width}-${theme}`) + check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) + } +}) + await section('记录页的工件列表:双盲复算对照、反方审稿记录有名字', async () => { // 工件列表按 pages/runs/model.ts 的 ARTIFACT_KIND_LABEL 写种类:期 3 新加的两种工件没登记时,列表里露出的是键名 // dual_check、challenge,说明一栏只有一句「工件」。按页面自己加载的那一份取(同 check-evidence 的工件一节)