From b0cf23dddafed69bde0a02fbeb4320a35a26634e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:11:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=A1=E7=9A=84=E5=86=99=E7=9B=AE=E5=BD=95=E9=A2=84?= =?UTF-8?q?=E5=91=8A=E5=92=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=80=90=E5=AD=97?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=8C=E7=9B=B2=E7=AE=97=E7=9A=84=E7=90=86?= =?UTF-8?q?=E7=94=B1=E6=8C=89=E7=90=86=E7=94=B1=E6=96=87=E6=A1=A3=E7=94=BB?= =?UTF-8?q?=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') })