From 52baa0d65cf1bdbff3979f8b029868c2131a8f0f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 16:10:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=201=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E9=AB=98=E4=BA=AE=E6=8C=89?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9C=9F=E5=AE=9E=E7=9A=84=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E8=BE=93=E5=87=BA=E5=81=9A=EF=BC=8C=E3=80=8C=E8=AE=B0?= =?UTF-8?q?=E5=85=A5=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E3=80=8D=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9C=9F=E4=BC=9A=E5=86=99?= =?UTF-8?q?=E6=97=B6=E6=9C=89=E6=9D=A1=E4=BB=B6=E5=9C=B0=E8=AF=B4=EF=BC=8C?= =?UTF-8?q?=E5=90=8E=E6=9E=9C=E5=92=8C=E6=8F=90=E7=A4=BA=E6=8C=89=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E5=A4=84=E7=BD=AE=E8=AF=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批卡 - 对照两条 SQL:text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文逐行对不上,原来按「两边逐行相等」高亮在真实输出上一处都标不出。改成两段原文按行做一次 LCS(不看空白、大小写和行尾逗号分号)高亮不同的行,一行都对不上时不乱标;text_diff 分原流程、盲算两栏,每行高亮(窄栏上下排)。 - 「只差一个方面、会记入数据目录」:照服务端 dual_check_facts 的门槛收紧——不等价、没用兜底、差异恰好一条(几个输入都对不上时合并后至少两条,所以也就是只有一个输入对不上),且是时间列、筛选条件或聚合;按这三种分别有条件地说会记成哪一项,选「两边都不对」不记、已有人工确认的不覆盖、以时间线为准。时间范围、两条筛选、关联、解析失败、多个输入合并都不再说。 - 后果和提交后的提示:payload 不带 on_reject,从这次运行的图快照里按节点 id 读「未采用原流程时」;判为失败时写明采用盲算或都不对会终止运行、提示也照实说,缺省时不再挂那句,读不到时有条件地说、提示不下结论。 - 只有解析失败时和摘要阶段一样按兜底说(固定那句 + 解析的原话),默认展开对照;兜底的原话另有原因(比较超时)时附在下面,一边没有 SQL 时不说「两条 SQL 的写法不同」。 - 认不出任何指标时留一个「驳回并终止」(approved 为假),运行不再一直挂着。 时间线 - dual_check_catalog 的 item 是目录项的键(business_date 等),按数据目录的叫法写成「「orders」(shop)的业务日期」,认不出的键不显示。 - dual_check_limit 的 metric 是指标 ID,不再当名字加引号。 - challenge_skipped 的 no_report 是报告文档读不出来(报告节点找不到时节点直接报错),说法和下一步改成去查报告节点有没有执行成功、在不在执行到的分支上。 检查器 - 字段名和服务端校验报错的叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL)对齐,处置选项以服务端的短说法开头;check-verify 在服务端合入后逐项比对。 - 盲算模型移出高级选项:时间线「模型相同」的定位要找得到它。 - 上游有几张口径卡时写明必须选(服务端校验会挡住发起运行),不再说「建议指定」。 - 「跟随设置」的当前值每次打开检查器都重取,改了设置回到画布不再是旧值。 测试 - 夹具里的差异说明换成 explain_sql_diff 对同一对 SQL 的真实输出,新增几对 SQL 的变体;backend/tests/test_dual_check_fixture.py 核对夹具和真实输出一致,并可重新生成。 - check-verify 补真实形状下的高亮、各种差异下「记入数据目录」说不说、只有解析失败和兜底原因、按处置说后果和提示、认不出指标时的驳回、叫法比对、多张口径卡、设置重取;check-decode 改用服务端真实的日志形状。check-verify 的耗时估计按实测改成 45 秒。 --- backend/tests/test_dual_check_fixture.py | 109 ++++++++ frontend/src/canvas/DualCheckFields.tsx | 25 +- frontend/src/canvas/nodeDefs.ts | 9 +- frontend/src/dev/fixtures/dual_check.json | 157 ++++++++++- frontend/src/dev/fixtures/dual_check.tsx | 11 +- frontend/src/lib/terms.ts | 81 ++++-- frontend/src/run/DualCheckApproval.tsx | 319 +++++++++++++++------ frontend/src/run/decode.ts | 13 +- scripts/check-all.mjs | 2 +- scripts/check-decode.mjs | 44 ++- scripts/check-verify.mjs | 325 ++++++++++++++++++++-- 11 files changed, 914 insertions(+), 181 deletions(-) create mode 100644 backend/tests/test_dual_check_fixture.py diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py new file mode 100644 index 00000000..d620a077 --- /dev/null +++ b/backend/tests/test_dual_check_fixture.py @@ -0,0 +1,109 @@ +"""双盲复算审批卡的前端夹具(frontend/src/dev/fixtures/dual_check.json)要和服务端的真实输出对得上。 + +审批卡上「对照两条 SQL」的高亮、「只差一个方面会记入数据目录」的提示,都是照 explain_sql_diff 的输出画的。它的 +text_diff 是规范化之后的行(sqlglot pretty:列名带表名、条件并成一行),和原文的行对不上;夹具要是照原文手写, +check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 复核时发现)。所以这里按夹具里的两条 SQL 重新算一遍 +差异说明,逐个指标、逐个变体和夹具里写的比。sql_semantics 改了说法或规范形式,这里先变红,按下面的办法重新生成。 + +审批 payload 的摘要照双盲复算节点的写法核对:diff 是前 3 条差异的原话,fallback 是「除了兜底和解析失败,一条具体 +差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite), +两边的叫法是「原流程」「盲算」,同节点调用时一样。 + +重新生成夹具里的差异说明(只改 diff、摘要的 diff 和 fallback,其余原样): + cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from app.data.sql_semantics import explain_sql_diff + +FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json" +DIALECT = "sqlite" +LABELS = ("原流程", "盲算") +#: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS) +SUMMARY_DIFFS = 3 + + +def _diff(original: str, blind: str) -> dict[str, Any]: + """工件里的 diff 只留界面用得到的几项:条目只有 aspect、text,不带两边的写法和 detail(同节点写工件时)。""" + out = explain_sql_diff(original, blind, DIALECT, labels=LABELS).as_dict() + return {"equivalent": out["equivalent"], "items": [{"aspect": i["aspect"], "text": i["text"]} for i in out["items"]], + "fallback": out["fallback"], "text_diff": out["text_diff"]} + + +def _differing(metric: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]: + """两边取值不同的输入(按 path 配对):节点只比这些输入的 SQL。""" + blind = {i["path"]: i for i in metric["blind"]["inputs"]} + return [(i, blind[i["path"]]) for i in metric["original"]["inputs"] if i["value"] != blind[i["path"]]["value"]] + + +def _summary_of(metric: dict[str, Any]) -> dict[str, Any]: + items = metric["diff"]["items"] + original, blind = metric["original"]["value"], metric["blind"]["value"] + return { + "diff": [i["text"] for i in items[:SUMMARY_DIFFS]], + "fallback": not any(i["aspect"] not in ("other", "parse") for i in items), + "delta_pct": None if original == 0 else round((blind - original) / abs(original) * 100, 2), + "original_rendered": metric["original"]["rendered"], + "blind_rendered": metric["blind"]["rendered"], + } + + +def _load() -> dict[str, Any]: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def test_card_metrics_carry_the_real_diff(): + fx = _load() + for metric in fx["card"]["metrics"]: + pairs = _differing(metric) + # 夹具里每个指标只有一个输入对不上:节点这时直接用这一处的差异说明,几处时才会合并、加前缀 + assert len(pairs) == 1, metric["metric_id"] + (a, b), = pairs + assert metric["diff"] == _diff(a["sql"], b["sql"]), metric["metric_id"] + + +def test_summary_follows_the_card(): + fx = _load() + summary = {s["metric_id"]: s for s in fx["approval"]["payload"]["summary"]} + assert list(summary) == [m["metric_id"] for m in fx["card"]["metrics"]] + for metric in fx["card"]["metrics"]: + got = summary[metric["metric_id"]] + assert {k: got[k] for k in _summary_of(metric)} == _summary_of(metric), metric["metric_id"] + + +def test_variants_carry_the_real_diff(): + fx = _load() + assert fx["variants"] + for name, variant in fx["variants"].items(): + original, blind = variant["sql"] + assert variant["diff"] == _diff(original, blind), name + + +def test_text_diff_is_not_the_raw_text(): + """守住这次的教训:至少有一处规范化之后的行和原文的任何一行都不一样(真实输出就是这样),夹具不再照原文手写。""" + fx = _load() + gmv = next(m for m in fx["card"]["metrics"] if m["metric_id"] == "gmv") + raw = {" ".join(line.split()).lower() for side in ("original", "blind") for line in gmv[side]["inputs"][0]["sql"].split("\n")} + shown = [" ".join(line[2:].split()).lower() for line in gmv["diff"]["text_diff"]] + assert shown and not any(line in raw for line in shown) + + +def regenerate() -> None: + fx = _load() + for metric in fx["card"]["metrics"]: + (a, b), = _differing(metric) + metric["diff"] = _diff(a["sql"], b["sql"]) + summary = {s["metric_id"]: s for s in fx["approval"]["payload"]["summary"]} + for metric in fx["card"]["metrics"]: + summary[metric["metric_id"]].update(_summary_of(metric)) + for variant in fx["variants"].values(): + variant["diff"] = _diff(*variant["sql"]) + FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + regenerate() diff --git a/frontend/src/canvas/DualCheckFields.tsx b/frontend/src/canvas/DualCheckFields.tsx index 79d411e1..0b0870a5 100644 --- a/frontend/src/canvas/DualCheckFields.tsx +++ b/frontend/src/canvas/DualCheckFields.tsx @@ -95,24 +95,27 @@ export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContex // ---------------------------------------------------------------- 设置里的默认值 /** - * 设置里核查一组的值:相对误差、启用的「跟随设置」要写出眼下是多少。整个会话取一次(同检查器的运行默认值), - * 取不到就不写,不挡编辑 + * 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲 + * 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得 + * 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑 */ 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(() => { - if (verifyValue !== undefined) return - verifyPending ??= api.settings.get().then( - (s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null), - () => { verifyPending = null; return null }, // 这次没取到,下次打开再试 - ) let alive = true - void verifyPending.then((v) => { if (alive) setValue(v) }) + void fetchVerify().then((v) => { if (alive) setValue(v) }) return () => { alive = false } }, []) - return verifyValue ?? value + return value } /** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */ @@ -144,6 +147,8 @@ function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldR {/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */} {current && !known && } + {/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */} + {!current && upstream.length > 1 && {T.cardChoose(upstream.length)}} {current && known && !upstream.some((n) => n.id === current) && {T.cardNotUpstream}} {current && !known && {T.cardMissing(current)}} @@ -163,7 +168,7 @@ function MetricsField({ id, nodeId, value, config, describedBy, onChange }: Fiel if (!target.node || metrics === undefined) { return (
- {target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard} + {target.upstream.length > 1 && !target.card ? T.cardChoose(target.upstream.length) : T.metricsNoCard}
) } diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 3cfc8ea8..8aae5f85 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -695,7 +695,8 @@ export const NODE_DEFS: Record = { description: DUAL_CHECK_TEXT.description, hasTarget: true, sources: [{ id: 'out', label: '' }], // 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点, - // 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看 + // 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看。 + // 盲算模型不放进高级选项:时间线上「模型相同」那一行的「打开设置」要定位到它,高级选项收着时定位不到 fields: [ { key: 'card', label: DUAL_CHECK_TEXT.field.card, type: 'select', help: DUAL_CHECK_TEXT.help.card, render: dualCheckFields.card }, { @@ -712,16 +713,16 @@ export const NODE_DEFS: Record = { }, { // 没写和写 degrade 是一回事:选项从 degrade 排起,没写时下拉框落在它上面,正好是缺省 - key: 'on_reject', label: DUAL_CHECK_TEXT.field.onReject, type: 'select', help: DUAL_CHECK_TEXT.help.onReject, + key: 'on_reject', label: DUAL_CHECK_TEXT.field.on_reject, type: 'select', help: DUAL_CHECK_TEXT.help.on_reject, options: [ { value: 'degrade', label: DUAL_CHECK_TEXT.onReject.degrade }, { value: 'fail', label: DUAL_CHECK_TEXT.onReject.fail }, ], unknownLabel: DUAL_CHECK_TEXT.onRejectUnknown, }, - { key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', advanced: true, render: dualCheckFields.provider }, + { key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', render: dualCheckFields.provider }, { - key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', advanced: true, help: DUAL_CHECK_TEXT.help.model, + key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', help: DUAL_CHECK_TEXT.help.model, render: dualCheckFields.model, }, { diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 75a1da27..41d52bc5 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 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。", + "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说", "approval": { "id": "ap-dc-0001", "run_id": "run-dc-0001", @@ -25,7 +25,7 @@ "blind_rendered": "51,388.3元", "delta_pct": 12.5, "diff": [ - "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" + "原流程按 paid_at 筛时间,盲算按 created_at" ], "fallback": false }, @@ -36,7 +36,9 @@ "original_rendered": "2.35%", "blind_rendered": "3.10%", "delta_pct": 31.91, - "diff": [], + "diff": [ + "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" + ], "fallback": true } ] @@ -83,15 +85,13 @@ "items": [ { "aspect": "time", - "text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" + "text": "原流程按 paid_at 筛时间,盲算按 created_at" } ], "fallback": false, "text_diff": [ - "- WHERE paid_at >= '2026-09-01'", - "- AND paid_at < '2026-09-08'", - "+ WHERE created_at >= '2026-09-01'", - "+ AND created_at < '2026-09-08'" + "- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'", + "+ orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'" ] } }, @@ -107,7 +107,7 @@ { "path": "vars.kpi.refunds", "value": 47, - "sql": "SELECT COUNT(*) AS refunds FROM refunds r JOIN orders o ON o.id = r.order_id WHERE o.status <> 'test'", + "sql": "SELECT COUNT(*) AS refunds\nFROM refunds r\nWHERE EXISTS (\n SELECT 1 FROM orders o\n WHERE o.id = r.order_id AND o.status <> 'test'\n)", "source": "shop", "alias": "Q2" }, @@ -127,10 +127,10 @@ { "path": "vars.kpi.refunds", "value": 62, - "sql": "SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)", + "sql": "SELECT COUNT(*) AS refunds\nFROM refunds r\nWHERE r.order_id IN (SELECT id FROM orders)", "source": "shop", "alias": "B2", - "rationale": "退款笔数按有退款记录的订单去重计数" + "rationale": "退款笔数:订单表中能找到对应订单的退款记录数" }, { "path": "vars.kpi.orders", @@ -152,8 +152,12 @@ ], "fallback": true, "text_diff": [ - "- SELECT COUNT(*) AS refunds FROM refunds AS r JOIN orders AS o ON o.id = r.order_id WHERE o.status <> 'test'", - "+ SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)" + "- EXISTS(", + "+ refunds.order_id IN (", + "- 1", + "+ \"orders@1\".id", + "- WHERE", + "- \"orders@1\".id = refunds.order_id AND \"orders@1\".status <> 'test'" ] } } @@ -370,5 +374,132 @@ "timeout_s": 120 }, "daily_max_usd": 2.0 + }, + "variants": { + "time_range": { + "sql": [ + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "time_range", + "text": "原流程的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-09-08',盲算是 paid_at >= '2026-09-01' AND paid_at < '2026-09-09'" + } + ], + "fallback": false, + "text_diff": [ + "- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'", + "+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'" + ] + } + }, + "filter_extra": { + "sql": [ + "SELECT COUNT(*) AS orders\nFROM orders\nWHERE status <> 'test'", + "SELECT COUNT(*) AS orders\nFROM orders" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "filter", + "text": "原流程排除了 status = 'test' 的行,盲算没有" + } + ], + "fallback": false, + "text_diff": [ + "- WHERE", + "- orders.status <> 'test'" + ] + } + }, + "two_filters": { + "sql": [ + "SELECT COUNT(*) AS orders\nFROM orders\nWHERE channel = 'web'\n AND status <> 'refunded'", + "SELECT COUNT(*) AS orders\nFROM orders" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "filter", + "text": "原流程只保留 channel = 'web' 的行,盲算没有这个条件" + }, + { + "aspect": "filter", + "text": "原流程排除了 status = 'refunded' 的行,盲算没有" + } + ], + "fallback": false, + "text_diff": [ + "- WHERE", + "- orders.channel = 'web' AND orders.status <> 'refunded'" + ] + } + }, + "join": { + "sql": [ + "SELECT COUNT(*) AS orders\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id", + "SELECT COUNT(*) AS orders\nFROM orders o\nJOIN refunds r ON r.order_id = o.id" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "join", + "text": "原流程用 LEFT JOIN 关联 refunds 表,盲算用 INNER JOIN" + } + ], + "fallback": false, + "text_diff": [ + "- FROM orders AS orders", + "- LEFT JOIN refunds AS refunds", + "+ FROM orders AS orders, refunds AS refunds", + "+ WHERE", + "- ON orders.id = refunds.order_id", + "+ orders.id = refunds.order_id" + ] + } + }, + "dedup": { + "sql": [ + "SELECT COUNT(DISTINCT user_id) AS buyers\nFROM orders", + "SELECT COUNT(user_id) AS buyers\nFROM orders" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "aggregate", + "text": "原流程对 user_id 去重计数,盲算没去重" + } + ], + "fallback": false, + "text_diff": [ + "- COUNT(DISTINCT orders.user_id)", + "+ COUNT(orders.user_id)" + ] + } + }, + "parse": { + "sql": [ + "SELECT SUM(amount) AS gmv\nFROM orders", + "SELEC SUM(amount) AS gmv\nFROM orders WHERE" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "parse", + "text": "盲算:SQL 无法解析(第 1 行第 10 列附近),没有逐项比较" + } + ], + "fallback": false, + "text_diff": [] + } + } } } diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx index ebcf4b72..7a45b2ea 100644 --- a/frontend/src/dev/fixtures/dual_check.tsx +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -14,11 +14,13 @@ import data from './dual_check.json' * - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排) * - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排) * - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具 + * - ?syn=dual-check-approval-empty:摘要里一个指标都没有(老服务端、写坏了的 payload),只能驳回 * - ?syn=dual-check-inspector:一张小画布(真的节点卡片)加 360px 的检查器,选中双盲复算节点;检查器改的是 * studio store 里的节点配置,卡片上的摘要跟着变 * - * 完整对照的工件(GET /api/artifacts/{id})、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说 - * 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流 + * 完整对照的工件(GET /api/artifacts/{id})、这次运行的图快照(GET /api/runs/{id}/graph,卡上按它说节点的 + * 「未采用原流程时」)、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说无法加载完整对照、 + * 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流 */ const approval = (patch: Partial = {}): Approval => ({ ...(data.approval as unknown as Approval), @@ -78,5 +80,10 @@ export const cases: Record JSX.Element> = { ), + 'dual-check-approval-empty': () => ( +
+ +
+ ), 'dual-check-inspector': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 62689a44..8d4a6cdd 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3347,15 +3347,20 @@ export const DUAL_CHECK_TEXT = { blind: '盲算', // ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx) + /** + * 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在 + * 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」 + * 「裁判模型」的写法 + */ field: { - card: '要复算的口径卡', + card: '口径卡', metrics: '复算的指标', definitions: '口径说明', provider: '盲算模型 · 接入', - model: '盲算模型 · 模型', + model: '盲算模型', tolerance: '允许的相对误差', - onReject: '审核人不采用原流程的结果时', - enabled: '启用', + on_reject: '未采用原流程时', + enabled: '启用双盲复算', }, help: { card: '留空则复算上游的口径卡。只能复算本节点上游的口径卡:运行到本节点时,它的结果已经算出', @@ -3365,12 +3370,16 @@ export const DUAL_CHECK_TEXT = { model: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,' + '不与设置合并。建议与原流程使用的模型不同,同一模型不易发现自身的错误', tolerance: '两边的值相差不超过这个比例时算一致,例如 0.001 表示千分之一,0 表示必须完全相同。留空则使用设置中的值', - onReject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理', + on_reject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理', enabled: '关闭时本节点不复算,直接放行,并在时间线上记录一条说明', }, - /** 口径卡下拉留空那一项:上游没有、只有一张、有好几张 */ + /** + * 口径卡下拉留空那一项:上游没有、只有一张、有好几张。好几张时服务端校验要求选一张(不选会挡住发起运行), + * 写成要选,不写「建议」 + */ cardDefault: (name: string | null, upstream: number) => (upstream === 0 ? '默认:上游的口径卡(上游没有口径卡)' - : upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `默认:上游的口径卡(共 ${upstream} 张,建议指定)`), + : upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `请选择要复算的口径卡(上游有 ${upstream} 张)`), + cardChoose: (n: number) => `上游有 ${n} 张口径卡,请选择要复算的那一张`, cardUpstream: '上游的口径卡', cardOther: '其他口径卡(需连接到本节点上游)', /** 画布上一张口径卡的叫法:节点名,口径名不同时附在后面 */ @@ -3411,10 +3420,11 @@ export const DUAL_CHECK_TEXT = { }, tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`), toleranceInvalid: '请填写不小于 0 的数', - onReject: { degrade: '记为出具缺口,出具降档(默认)', fail: '判为失败,运行终止' } as Record, + /** 选项以服务端报错里的短说法(schema.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */ + onReject: { degrade: '计入缺口、出具降档(默认)', fail: '判为失败,运行终止' } as Record, onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, enabled: { follow: '跟随设置', on: '开启', off: '关闭' }, - /** 「跟随设置」那一项附上设置里眼下是开是关(设置还没取回来就不附) */ + /** 「跟随设置」那一项附上设置里现在是开是关(设置还没取到就不附) */ enabledFollow: (on: boolean | null | undefined) => (on == null ? '跟随设置' : `跟随设置(当前${on ? '开启' : '关闭'})`), // ---- 节点卡片的摘要:复算哪张卡、几个指标 @@ -3436,6 +3446,7 @@ export const DUAL_CHECK_TEXT = { deltaZero: '原流程值为 0,无法计算相差比例', deltaHint: '相差比例 =(盲算值 − 原流程值)÷ |原流程值|', diffHead: '差异说明', + /** 一条具体差异都没有时的固定说法(服务端兜底说明的前半句);兜底的原话另有原因(比较超时、解析失败)时附在下面 */ fallbackOnly: '两条 SQL 的写法不同,未能归纳成具体差异', equivalent: '两条 SQL 的写法等价,差异出在取用的结果上,请对照两边取用的值', loading: '正在加载完整对照…', @@ -3448,8 +3459,19 @@ export const DUAL_CHECK_TEXT = { source: (s: string) => `数据源 ${s}`, inputs: '输入取值', textDiff: '规范化后不同的行', + textDiffNone: '这一边没有不同的行', rationale: '盲算的说明', - catalog: '两边只差一个方面:选择任一边的结果后,这项口径会作为已确认的事实记入数据目录', + /** + * 两边只差一处、又可能对得上数据目录里的一项事实时,提交前说清选择会被记下来(门槛同服务端的 dual_check_facts: + * 只有一个输入对不上、恰好一条差异、没有兜底,而且是时间列、筛选条件或聚合)。一边多出条件、只差去不去重、目录里 + * 已有人工确认的说法这几条只有服务端查得了,所以有条件地说,以时间线上的记录为准。卡上是「方面那句 + tail」 + */ + catalog: { + time: '两边只差在按哪一列筛时间:采用一边的结果后,这一边筛时间所用的列会作为该表的业务日期', + filter: '两边只差一个筛选条件:若这个条件只有一边有,采用有它的一边时,它会作为该表的有效记录条件', + aggregate: '两边只差在聚合方式:若只差是否去重,采用一边的结果后,它的做法会作为该表的去重规则', + tail: ',以人工确认的事实记入数据目录。选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,以时间线上的记录为准', + }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, /** 三个选择的后果,按运行类别说:探索运行不出具,不提出具;老服务端没给类别的照实说「正式运行」 */ @@ -3458,13 +3480,27 @@ export const DUAL_CHECK_TEXT = { : runClass === 'exploratory' ? '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口' : '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口,正式运行的出具随之降档'), - consequenceFail: '节点设为「判为失败」时,选择后两项会终止运行', + /** + * 节点的「未采用原流程时」设为判为失败(从这次运行的图快照里读到的):采用盲算或都不对时节点失败、运行终止, + * 不再说出具降档 + */ + consequenceFail: '采用原流程的结果:沿用原流程的值,运行继续。采用盲算的结果或两边都不对:节点的「未采用原流程时」' + + '设为「判为失败」,提交后运行将终止', + /** 读不到节点怎么设的(图快照取不到、节点在子工作流里):降档那句后面有条件地补一句 */ + consequenceMaybeFail: '如节点的「未采用原流程时」设为「判为失败」,选择后两项会终止运行', note: '备注(可选)', notePlaceholder: '说明裁决的依据,会随裁决记入运行记录', submit: '提交裁决', pending: (n: number) => `还有 ${n} 个指标未选择`, + /** 提交后的提示:全采用原流程、或节点按缺省处置时运行继续;判为失败时运行终止;读不到节点设置时不下结论 */ done: '已提交裁决,运行继续', + doneFail: '已提交裁决,运行将按节点设置终止', + doneUnknown: '已提交裁决', empty: '审批内容不完整,没有可以裁决的指标', + /** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */ + reject: '驳回并终止', + emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因', + rejected: '已驳回,本次运行终止', }, // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 @@ -3476,7 +3512,7 @@ export const DUAL_CHECK_TEXT = { limitDailyNext: '今日核查费用已达上限。请在「设置 → 偏好设置 → 核查」中调高每日金额上限,或等到次日再运行', log: { skippedDisabled: '双盲复算未启用,已跳过', - skippedDisabledNext: '如需复算,请在节点的「启用」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算', + skippedDisabledNext: '如需复算,请在节点的「启用双盲复算」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算', skippedNoInputs: '没有输入来自查询的指标,已跳过复算', skippedNoInputsNext: '双盲复算只复算输入来自查询结果的指标。请检查节点的「复算的指标」,或确认口径卡的输入取自查询', skipped: '双盲复算已跳过', @@ -3491,19 +3527,30 @@ export const DUAL_CHECK_TEXT = { resolvedB: (n: number) => `采用盲算 ${formatNumber(n)} 个`, resolvedNeither: (n: number) => `两边都不对 ${formatNumber(n)} 个`, gaps: (n: number) => (n ? `${formatNumber(n)} 项记为缺口` : '没有缺口'), - catalog: (n: number) => `已将 ${formatNumber(n)} 项裁决记入数据目录`, - catalogNone: '裁决未记入数据目录', + catalog: (n: number) => `已将 ${formatNumber(n)} 项人工确认的事实记入数据目录`, + catalogNone: '本次裁决未写入数据目录', + /** + * 写进数据目录的一项:「orders」(shop)的业务日期。item 是目录项的中文叫法(认不出的键不传,只写表); + * 老版本给的一句中文也从这里过 + */ + catalogItem: (table?: string, source?: string, item?: string) => { + const where = table ? `「${table}」${source ? `(${source})` : ''}` : '' + return where && item ? `${where}的${item}` : where || item || '' + }, catalogSkipped: (n: number) => `${formatNumber(n)} 项未写入`, catalogNext: '可在「数据 → 数据目录」中查看或修改', - limit: (label: string, metric?: string) => (metric ? `复算指标「${metric}」时已达${label},该指标未完成盲算` : `双盲复算已达${label}`), + /** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */ + limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`), limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', }, /** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */ challengeLog: { skippedDisabled: '反方审稿未启用,已跳过', skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', - skippedNoReport: '没有找到要审的报告,已跳过反方审稿', - skippedNoReportNext: '请在反方审稿节点的上游连接报告撰写节点,或在节点中指定要审的报告', + // no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、 + // 保存失败,或者它在这次没有执行到的分支上 + skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿', + skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它', skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', skipped: '反方审稿已跳过', sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index de12f7ba..6771c061 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useMemo, useState } from 'react' import type { CSSProperties } from 'react' -import { AlertTriangle, Check, ChevronRight } from 'lucide-react' +import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { useCatalog } from '../store/catalog' @@ -15,6 +15,11 @@ import type { const T = DUAL_CHECK_TEXT.approval const CHOICES: DualCheckChoice[] = ['a', 'b', 'neither'] +/** 不算具体差异的两种:兜底说明(other)和解析失败(parse)。服务端摘要的 fallback 也是这么算的 */ +const NOT_CONCRETE = new Set(['other', 'parse']) +/** 能对上数据目录里一项事实的三种差异:时间列 → 业务日期,筛选条件 → 有效记录条件,聚合 → 去重规则 */ +type CatalogAspect = 'time' | 'filter' | 'aggregate' +const CATALOG_ASPECTS = new Set(['time', 'filter', 'aggregate']) export interface DualCheckApprovalProps { /** payload.kind 为 'dual_check' 的审批:双盲复算两路对不上、交给人裁决 */ @@ -34,21 +39,26 @@ interface MetricView { blind: string | null /** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */ deltaPct: number | null | undefined - /** 具体差异,一条一句(兜底说明不算) */ + /** 具体差异,一条一句(兜底说明、解析失败不算) */ items: string[] - /** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */ + /** 一条具体差异都没有:卡上不列条目,默认展开对照,审核人只能对照原文判断 */ fallbackOnly: boolean + /** fallbackOnly 时写不写那句固定的「两条 SQL 的写法不同,未能归纳成具体差异」(一边没有 SQL 时不写,只写原因) */ + fallbackHead: boolean + /** 兜底、解析失败的原话里固定那句以外的说法(比较超时、解析出错的位置……),附在下面 */ + notes: string[] /** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */ rest: string | null /** 两条 SQL 等价,差在取用的结果上 */ equivalent: boolean - /** 只差一个方面:审核人的选择会记入数据目录 */ - single: boolean + /** 两边只差一处、又可能对得上数据目录的一项事实:审核人的选择可能被记进数据目录(见 catalogOf) */ + catalog: CatalogAspect | null full: DualCheckMetric | null } const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) +const hasSql = (v: unknown): boolean => typeof v === 'string' && !!v.trim() /** payload.summary 里认得出的项(老服务端、写坏了的项跳过,不让整张卡崩掉) */ function summaryOf(raw: unknown): DualCheckSummaryItem[] { @@ -64,6 +74,27 @@ function cardOf(content: unknown): DualCheckCard | null { return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') } } +/** + * 兜底、解析失败的原话 → standard:其中有那句固定的兜底说明(几个输入合并时前面带「输入「…」:」);notes:其余的 + * 说法,去掉句末的「请对照原文」(对照就在下面),去重 + */ +function notesOf(texts: string[]): { standard: boolean; notes: string[] } { + const plain = [...new Set(texts.map((t) => t.replace(/,?请对照原文。?$/, '').trim()).filter(Boolean))] + const isStandard = (t: string) => t.replace(/^输入「[^」]*」:/, '') === T.fallbackOnly + return { standard: plain.some(isStandard), notes: plain.filter((t) => !isStandard(t)) } +} + +/** + * 审核人的选择会不会被记进数据目录:照服务端写目录的门槛(dual_check_facts.derive)——两条 SQL 不等价、没用兜底、 + * 差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成一份(每个输入至少一条), + * 所以「恰好一条」也就是只有一个输入对不上。后面还有只有服务端查得了的条件(一边多出条件、只差去不去重、目录里 + * 已有人工确认的说法),卡上有条件地说 + */ +function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: string }[]): CatalogAspect | null { + if (metric.diff?.equivalent === true || metric.diff?.fallback === true || items.length !== 1) return null + return CATALOG_ASPECTS.has(items[0].aspect) ? items[0].aspect as CatalogAspect : null +} + function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] { const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m])) // 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面 @@ -79,30 +110,39 @@ function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): M deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct), } if (f) { - const items = (Array.isArray(f.diff?.items) ? f.diff.items : []).filter((i) => !!i && typeof i.text === 'string' && !!i.text) - // aspect 为 other 的是兜底说明,不算具体差异;只有它的时候卡上写那句固定的说法、默认展开对照 - const concrete = items.filter((i) => i.aspect !== 'other') + const items = (Array.isArray(f.diff?.items) ? f.diff.items : []) + .filter((i) => !!i && typeof i.text === 'string' && !!i.text && typeof i.aspect === 'string') + const concrete = items.filter((i) => !NOT_CONCRETE.has(i.aspect)) const other = items.filter((i) => i.aspect === 'other') const equivalent = f.diff?.equivalent === true - const aspects = new Set(concrete.map((i) => i.aspect)) + const fallbackOnly = !equivalent && !concrete.length + const { standard, notes } = notesOf(items.filter((i) => NOT_CONCRETE.has(i.aspect)).map((i) => i.text)) + // 对不上的输入里没有一个两边都有 SQL 的(节点的原话「有一边没有可比较的 SQL」):不说「两条 SQL 的写法不同」 + const comparable = pairsOf(f).some((p) => p.differs && hasSql(p.original?.sql) && hasSql(p.blind?.sql)) return { ...base, items: concrete.map((i) => i.text), - fallbackOnly: !equivalent && !concrete.length, + fallbackOnly, + fallbackHead: !notes.length || standard || comparable, + notes: fallbackOnly ? notes : [], rest: concrete.length && other.length ? other.map((i) => i.text).join(';') : null, equivalent, - // 「只差一个方面」以服务端为准(它决定写不写数据目录),这里只在没用兜底、具体差异都落在同一方面时提示 - single: !equivalent && f.diff?.fallback !== true && aspects.size === 1 && !aspects.has('parse'), + catalog: catalogOf(f, items), full: f, } } + // 只有摘要:服务端把兜底和解析失败都算作 fallback,diff 里是前几条差异的原话 + const texts = Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [] + const fallbackOnly = s?.fallback === true return { ...base, - items: Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [], - fallbackOnly: s?.fallback === true, + items: fallbackOnly ? [] : texts, + fallbackOnly, + fallbackHead: true, + notes: fallbackOnly ? notesOf(texts).notes : [], rest: null, equivalent: false, - single: false, + catalog: null, full: null, } }) @@ -117,13 +157,40 @@ function deltaText(pct: number | null | undefined): string | null { return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`) } +/** + * 这次运行里节点的「未采用原流程时」:审批 payload 不带它,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读。 + * 没写、写了认不出的按缺省的 degrade(节点执行时同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到 + * 时是 unknown,卡上有条件地说 + */ +type OnReject = 'degrade' | 'fail' | 'unknown' | 'loading' +function useOnReject(runId: string, nodeId: string | undefined): OnReject { + const key = `${runId}\n${nodeId ?? ''}` + const [got, setGot] = useState<{ key: string; value: OnReject } | null>(null) + useEffect(() => { + if (!runId || !nodeId) return + let alive = true + api.runs.graph(runId).then( + (res) => { + const node = Array.isArray(res?.graph?.nodes) ? res.graph.nodes.find((n) => n?.id === nodeId) : undefined + const value: OnReject = !node ? 'unknown' : node.data?.config?.on_reject === 'fail' ? 'fail' : 'degrade' + if (alive) setGot({ key, value }) + }, + () => { if (alive) setGot({ key, value: 'unknown' }) }, + ) + return () => { alive = false } + }, [runId, nodeId, key]) + if (!runId || !nodeId) return 'unknown' + return got?.key === key ? got.value : 'loading' +} + /** * 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它,通用的输入框、备注和 * 「提交 / 驳回并终止」不画——mode 是 input,通用那套会把一个字符串当 value 发出去)。 * * 先按 payload.summary 画:两个值、相差多少、摘要里的差异,选择也能先做;完整对照(card_artifact)到了再补上盲算模型、 * 全部具体差异和「对照两条 SQL」。取不到完整对照时照实说,按摘要照样能裁决。每个指标三选一、缺一个都不能提交, - * 回复走审批接口:value 为 {choices, note} + * 回复走审批接口:value 为 {choices, note}。摘要和工件里都认不出指标时没法裁决,只留「驳回并终止」(approved 为假, + * 节点按人工驳回失败),免得运行一直挂着 */ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) { const toast = useToast() @@ -133,6 +200,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const payload = approval.payload ?? {} const summary = useMemo(() => summaryOf(payload.summary), [payload.summary]) const cardId = str(payload.card_artifact) ?? '' + const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id)) const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null) useEffect(() => { @@ -157,8 +225,9 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const [busy, setBusy] = useState(false) const pending = views.filter((v) => !choices[v.id]).length - const submit = async () => { - if (busy || pending || !views.length) return + /** 提交裁决(approved 为真,value 是 {choices, note})或驳回(认不出指标时的出口) */ + const send = async (approved: boolean) => { + if (busy || (approved && (pending || !views.length))) return setBusy(true) try { const value: DualCheckReply = { @@ -166,11 +235,13 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro note: note.trim(), } // 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, note, value} - await api.approvals.decide(approval.id, { approved: true, note: value.note, value }) + await api.approvals.decide(approval.id, approved ? { approved: true, note: value.note, value } : { approved: false, note: value.note }) await refreshApprovals() if (onResolved) await onResolved(approval.run_id) else await attachRun(approval.run_id) - toast(T.done, 'ok') + // 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论 + const rejected = Object.values(value.choices).some((c) => c !== 'a') + toast(!approved ? T.rejected : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok') } catch (e) { toast.error(e) } finally { @@ -178,6 +249,14 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro } } + const noteField = ( +
+ +