From 4d29b67cef272fdbd689fce81fd9c6f4a2f5a453 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 13:54:45 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=88=E4=B8=80=EF=BC=89=EF=BC=9A=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E8=A7=A3=E8=AF=BB=E5=8F=8C=E7=9B=B2=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=E5=92=8C=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E7=9A=84?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=97=A5=E5=BF=97=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=E7=B1=BB=E5=9E=8B=E5=92=8C=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decode.ts:约定里的 12 个日志代码(dual_check_* 7 个、challenge_* 5 个)按 code 和结构化字段说成标题、副标题和下一步, 不从原话里认;后端发成 info 的(一致、裁决、记入数据目录、审稿完成)也上时间线,否则这两种节点在时间线上什么都不说; 有缺口、有推翻时用提醒色,完整对照和审稿记录的工件可下钻;老日志缺字段、类型不对时不崩、不瞎填数 - check-decode:每个代码一条断言,另有换一种取值和缺字段两组 - types.ts 末尾加双盲复算的审批 payload、对照工件、节点配置和设置里核查一组的类型 - terms.ts:DUAL_CHECK_TEXT 写全检查器、节点摘要、审批卡、时间线的文案(反方审稿日志的说法暂放 challengeLog); VERIFY_SETTING_TEXT 顶层和服务端 VERIFY_SETTING_LABEL 的键和叫法一字不差,其余放 section - 去掉双盲复算节点说明里的「功能开发中」和节点卡片上的占位摘要 --- frontend/src/canvas/NodeCard.tsx | 2 - frontend/src/canvas/nodeDefs.ts | 2 +- frontend/src/lib/terms.ts | 253 ++++++++++++++++++++++++++++++- frontend/src/run/decode.ts | 150 +++++++++++++++++- frontend/src/types.ts | 121 +++++++++++++++ scripts/check-decode.mjs | 149 ++++++++++++++++++ 6 files changed, 668 insertions(+), 9 deletions(-) diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 60f46f14..98aa696d 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -95,8 +95,6 @@ function summarize(type: NodeType, config: Record): string { case 'subgraph': return config.workflow_id ? '嵌套工作流' : '未选择工作流' // 还是占位的两种:卡片上直接说尚未生效,免得以为放上去就会执行 - case 'dual_check': - return DUAL_CHECK_TEXT.pending case 'challenge': return CHALLENGE_TEXT.pending default: diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index dc6e7a54..6a8677d2 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -662,7 +662,7 @@ export const NODE_DEFS: Record = { // 说明里写明尚未生效,检查器顶上就是这一句;各自的工作包补上字段时把这句去掉 dual_check: { type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows, - description: `${DUAL_CHECK_TEXT.description}。${DUAL_CHECK_TEXT.pending}`, + description: DUAL_CHECK_TEXT.description, hasTarget: true, sources: [{ id: 'out', label: '' }], fields: [], defaults: {}, diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 6dceec1f..64c37b30 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3334,10 +3334,182 @@ export const WHATIF_TEXT = { empty: '这个指标的计算式没有引用任何输入,无可推演', } -/** 双盲复算节点:节点库说明、检查器和运行结果 */ +/** + * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算和反方审稿的日志。 + * + * 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为 + * 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口, + * 卡上要说清;探索运行不出具,出具相关的话只对正式运行说 + */ export const DUAL_CHECK_TEXT = { description: '另起一路独立计算,与原结果逐项比对', - pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`, + original: '原流程', + blind: '盲算', + + // ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx) + field: { + card: '要复算的口径卡', + metrics: '复算的指标', + definitions: '口径说明', + provider: '盲算模型 · 接入', + model: '盲算模型 · 模型', + tolerance: '允许的相对误差', + onReject: '审核人不采用原流程的结果时', + enabled: '启用', + }, + help: { + card: '留空则复算上游的口径卡。只能复算本节点上游的口径卡:运行到本节点时,它的结果已经算出', + metrics: '默认复算口径卡中输入来自查询结果的全部指标,取消勾选可以只复算其中一部分。输入不是来自查询的指标无法盲算', + definitions: '给盲算模型看的业务定义,例如「销售额按支付时间统计,不含退款」。盲算模型看不到原流程的 SQL,' + + '只能依据指标名称、这里的说明和数据目录编写查询,说明越具体,比对越有意义', + model: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,' + + '不与设置合并。建议与原流程使用的模型不同,同一模型不易发现自身的错误', + tolerance: '两边的值相差不超过这个比例时算一致,例如 0.001 表示千分之一,0 表示必须完全相同。留空则使用设置中的值', + onReject: '盲算与原流程不一致时运行暂停,由审核人逐个指标选择采用哪一边。选择「采用盲算的结果」或「两边都不对」时按这里处理', + enabled: '关闭时本节点不复算,直接放行,并在时间线上记录一条说明', + }, + /** 口径卡下拉留空那一项:上游没有、只有一张、有好几张 */ + cardDefault: (name: string | null, upstream: number) => (upstream === 0 ? '默认:上游的口径卡(上游没有口径卡)' + : upstream === 1 && name ? `默认:上游的口径卡「${name}」` : `默认:上游的口径卡(共 ${upstream} 张,建议指定)`), + cardUpstream: '上游的口径卡', + cardOther: '其他口径卡(需连接到本节点上游)', + /** 画布上一张口径卡的叫法:节点名,口径名不同时附在后面 */ + cardOption: (label: string, caliber?: string | null) => (caliber && caliber !== label ? `${label}(${caliber})` : label), + cardGone: (id: string) => `${id}(不在画布上)`, + cardNotUpstream: '所选口径卡不在本节点上游,运行到本节点时还没有它的结果。请把它连接到本节点之前', + cardMissing: (id: string) => `口径卡「${id}」不在画布上,请重新选择`, + metricsAll: '全部指标', + metricsDefault: '未指定:复算全部输入来自查询的指标', + metricsPicked: (n: number, total: number) => `已选 ${n} / ${total} 个指标`, + metricsNoCard: '先选择要复算的口径卡', + metricsNone: '这张口径卡还没有定义指标', + metricsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时按引用的版本确定:将复算其中输入来自查询的全部指标', + metricsStale: (ids: string[]) => `已选的指标不在这张口径卡中:${ids.join('、')}`, + metricsClear: '清除已选', + metricOption: (name: string | null | undefined, id: string) => (name && name !== id ? `${name}(${id})` : id), + definitionPlaceholder: (name: string) => `「${name}」的口径说明`, + definitionsNone: '选择要复算的指标后,可以为每个指标写一段口径说明', + providerFollow: '跟随设置', + providerFromModel: '按模型名找接入', + providerGone: (name: string) => `${name}(不存在)`, + providerOff: '(已停用)', + modelFollow: '跟随设置', + modelOfProvider: '留空则使用该接入的默认模型', + /** + * 节点上只填了盲算模型、接入留空时实际用哪个接入(verify_model_spec:哪一级写了就整组用哪一级,不跨级拼; + * 再按模型名找接入)。found:按模型名找到的接入;fallback:认不出时去调的默认接入 + */ + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => { + if (found && !found.enabled) return `只填了模型:「${model}」属于已停用的接入「${found.name}」,盲算调用将失败。请启用该接入或选择其他接入` + if (found) return `只填了模型:将按模型名使用接入「${found.name}」。接入和模型都未填写时,才跟随设置中的核查模型` + return `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用。` + + '如该模型不属于默认接入,请选择对应的接入' + }, + tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`), + toleranceInvalid: '请填写不小于 0 的数', + onReject: { degrade: '记为出具缺口,出具降档(默认)', fail: '判为失败,运行终止' } as Record, + enabled: { follow: '跟随设置', on: '开启', off: '关闭' }, + /** 「跟随设置」那一项附上设置里眼下是开是关(设置还没取回来就不附) */ + enabledFollow: (on: boolean | null | undefined) => (on == null ? '跟随设置' : `跟随设置(当前${on ? '开启' : '关闭'})`), + + // ---- 节点卡片的摘要:复算哪张卡、几个指标 + summary: { + card: (name: string, picked: number | null, total: number | null) => `复算「${name}」 · ${ + picked != null ? `${picked} 个指标` : total != null ? `全部 ${total} 个指标` : '全部指标'}`, + noCard: '复算上游的口径卡 · 尚未连接口径卡', + off: '已关闭', + }, + + // ---- 审批卡(run/DualCheckApproval.tsx) + approval: { + intro: (n: number) => `盲算与原流程有 ${n} 个指标不一致,请逐个选择采用哪一边的结果`, + model: (model: string) => `盲算模型:${model}`, + sameModel: '盲算模型与原流程相同,复算缺乏独立性', + /** delta_pct 已经乘过 100、带正负号写好(「+12.5%」) */ + delta: (text: string) => `相差 ${text}`, + deltaZero: '原流程值为 0,无法计算相差比例', + deltaHint: '相差比例 =(盲算值 − 原流程值)÷ |原流程值|', + diffHead: '差异说明', + fallbackOnly: '两条 SQL 的写法不同,未能归纳成具体差异', + equivalent: '两条 SQL 的写法等价,差异出在取用的结果上,请对照两边取用的值', + loading: '正在加载完整对照…', + loadFailed: '无法加载完整对照,以下仅为摘要', + compare: '对照两条 SQL', + compareClose: '收起对照', + sqlOriginal: '原流程的 SQL', + sqlBlind: '盲算的 SQL', + sqlMissing: '没有记录 SQL', + source: (s: string) => `数据源 ${s}`, + inputs: '输入取值', + textDiff: '规范化后不同的行', + rationale: '盲算的说明', + catalog: '两边只差一个方面:选择任一边的结果后,这项口径会作为已确认的事实记入数据目录', + choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, + choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, + /** 三个选择的后果,按运行类别说:探索运行不出具,不提出具;老服务端没给类别的照实说「正式运行」 */ + consequence: (runClass: string | null | undefined) => (runClass === 'formal' + ? '采用原流程的结果:沿用原流程的值,不影响出具。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为出具缺口,出具降档' + : runClass === 'exploratory' + ? '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口' + : '采用原流程的结果:沿用原流程的值。采用盲算的结果或两边都不对:报告中的数仍取自原流程,这个指标记为缺口,正式运行的出具随之降档'), + consequenceFail: '节点设为「判为失败」时,选择后两项会终止运行', + note: '备注(可选)', + notePlaceholder: '说明裁决的依据,会随裁决记入运行记录', + submit: '提交裁决', + pending: (n: number) => `还有 ${n} 个指标未选择`, + done: '已提交裁决,运行继续', + empty: '审批内容不完整,没有可以裁决的指标', + }, + + // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 + /** 日志里的 limit 字段(达到了哪个上限) */ + limitLabel: { + max_queries: '查询次数上限', timeout_s: '时长上限', max_cost_usd: '金额上限', daily_max_usd: '每日金额上限', max_claims: '句数上限', + } as Record, + limitAny: '上限', + limitDailyNext: '今日核查费用已达上限。请在「设置 → 偏好设置 → 核查」中调高每日金额上限,或等到次日再运行', + log: { + skippedDisabled: '双盲复算未启用,已跳过', + skippedDisabledNext: '如需复算,请在节点的「启用」中选择「开启」,或在「设置 → 偏好设置 → 核查」中启用双盲复算', + skippedNoInputs: '没有输入来自查询的指标,已跳过复算', + skippedNoInputsNext: '双盲复算只复算输入来自查询结果的指标。请检查节点的「复算的指标」,或确认口径卡的输入取自查询', + skipped: '双盲复算已跳过', + sameModel: (model?: string) => (model ? `盲算模型与原流程同为「${model}」,复算缺乏独立性` : '盲算模型与原流程相同,复算缺乏独立性'), + sameModelNext: '请在节点的「盲算模型」中指定其他模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', + agreed: (n?: number) => (n ? `双盲复算一致:${formatNumber(n)} 个指标与盲算结果相符` : '双盲复算一致:与盲算结果相符'), + disagreed: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标与盲算结果不一致,等待审核人裁决` + : '双盲复算有指标与盲算结果不一致,等待审核人裁决'), + disagreedNext: '请在审批卡上对照两边的 SQL,逐个指标选择采用哪一边的结果', + resolved: (parts: string[]) => (parts.length ? `审核人已裁决:${parts.join(',')}` : '审核人已裁决'), + resolvedA: (n: number) => `采用原流程 ${formatNumber(n)} 个`, + resolvedB: (n: number) => `采用盲算 ${formatNumber(n)} 个`, + resolvedNeither: (n: number) => `两边都不对 ${formatNumber(n)} 个`, + gaps: (n: number) => (n ? `${formatNumber(n)} 项记为缺口` : '没有缺口'), + catalog: (n: number) => `已将 ${formatNumber(n)} 项裁决记入数据目录`, + catalogNone: '裁决未记入数据目录', + catalogSkipped: (n: number) => `${formatNumber(n)} 项未写入`, + catalogNext: '可在「数据 → 数据目录」中查看或修改', + limit: (label: string, metric?: string) => (metric ? `复算指标「${metric}」时已达${label},该指标未完成盲算` : `双盲复算已达${label}`), + limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', + }, + /** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */ + challengeLog: { + skippedDisabled: '反方审稿未启用,已跳过', + skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', + skippedNoReport: '没有找到要审的报告,已跳过反方审稿', + skippedNoReportNext: '请在反方审稿节点的上游连接报告撰写节点,或在节点中指定要审的报告', + skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', + skipped: '反方审稿已跳过', + sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` + : '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'), + sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', + done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'), + verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record, + refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'), + refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', + limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), + limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', + }, } /** 反方审稿节点:节点库说明、检查器和运行结果 */ @@ -3346,5 +3518,78 @@ export const CHALLENGE_TEXT = { pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`, } -/** 设置页里复核套件(双盲复算、反方审稿等)的默认值一栏 */ -export const VERIFY_SETTING_TEXT = {} +/** + * 设置页「核查」一节(双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限)。 + * + * 顶层是各项的叫法,和服务端 api/settings.py 的 VERIFY_SETTING_LABEL 一字不差(保存时 422 的报错写的就是这些, + * 界面上的字段名得对得上),嵌套的项写成「组.项」;check-verify 从 settings.py 读出来逐一比对。 + * 这一节其余的标题、说明、今日花费放在 section 里 + */ +export const VERIFY_SETTING_TEXT = { + provider: '核查模型 · 接入', + model: '核查模型 · 模型', + daily_max_usd: '核查每日金额上限(美元)', + dual_check: '双盲复算', + 'dual_check.enabled': '双盲复算 · 启用', + 'dual_check.tolerance': '双盲复算 · 允许的相对误差', + 'dual_check.max_queries': '双盲复算 · 每项最多查询次数', + 'dual_check.timeout_s': '双盲复算 · 每项时长上限(秒)', + challenge: '反方审稿', + 'challenge.enabled': '反方审稿 · 启用', + 'challenge.max_claims': '反方审稿 · 每份报告最多审几句', + 'challenge.max_queries': '反方审稿 · 每句最多查询次数', + 'challenge.timeout_s': '反方审稿 · 每句时长上限(秒)', + inquiry: '审批质询', + 'inquiry.enabled': '审批质询 · 启用', + 'inquiry.max_queries': '审批质询 · 每次最多查询次数', + 'inquiry.timeout_s': '审批质询 · 每次时长上限(秒)', + section: { + title: '核查', + hint: '双盲复算、反方审稿和审批质询都由只读的 Agent 查询数据来核对:三项共用核查模型和每日金额上限,' + + '各自可以单独开关、设置上限。节点中已设置的项以节点为准。', + model: '核查模型', + /** 接入和模型都没填:服务端沿用证据裁判模型的取值链(verify_model_spec)。醒目地说一句,并给出建议 */ + notSet: '未单独配置,将使用证据裁判的模型', + notSetAdvice: '建议配置一个与撰写报告、查询数据的模型都不同的模型:同一模型核对自身的输出时,不易发现其中的错误', + differ: '建议与撰写报告、查询数据的模型不同:同一模型不易发现自身的错误', + providerDefault: '跟随证据裁判模型', + providerFromModel: '按模型名找接入', + providerGone: (name: string) => `模型接入「${name}」不存在:核查调用将失败`, + providerGoneOption: (name: string) => `${name}(不存在)`, + providerOff: '(已停用)', + modelDefault: '留空:用接入的默认模型', + modelFollow: '留空:跟随证据裁判模型', + modelOf: (model: string) => `留空则使用 ${model}`, + /** 只填了核查模型、接入留空时实际用哪个接入(同裁判的说法,只是上一级是证据裁判模型) */ + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => { + if (found && !found.enabled) return `只填了模型:「${model}」属于已停用的接入「${found.name}」,核查调用将失败。请启用该接入或选择其他接入` + if (found) return `只填了模型:将按模型名使用接入「${found.name}」。「跟随证据裁判模型」仅在接入和模型都留空时生效` + return `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用。` + + '如该模型不属于默认接入,请选择对应的接入。「跟随证据裁判模型」仅在接入和模型都留空时生效' + }, + parts: { + dual_check: '另起一路,由盲算模型在看不到原流程 SQL 的情况下独立查询,再与原流程的结果逐项比对', + challenge: '从报告中挑出关键结论,由反方模型查询数据尝试推翻', + inquiry: '审批人在批准前就报告提问,由核查模型查询数据作答,问答随运行一并封存', + } as Record, + toleranceHint: '两边的值相差不超过这个比例时算一致,例如 0.000001 表示百万分之一,0 表示必须完全相同', + unlimited: '不限', + /** 某项设成不限时写明它还受什么约束,不要悄悄生效。by:眼下还设着上限的相关项 */ + unlimitedNote: (kind: 'queries' | 'claims' | 'timeout' | 'daily', by: string[]) => { + if (kind === 'timeout') return '不设上限,时长仅受模型接口自身的超时约束' + const what = kind === 'queries' ? '查询次数' : kind === 'claims' ? '句数' : '每日核查费用' + return by.length ? `不设上限,${what}仅受${by.join('、')}约束` : `不设上限,${what}不受其他上限约束` + }, + bound: { timeout: '时长上限', daily: '每日金额上限', claims: '报告中关键结论的数量', queries: '各项的查询次数上限', timeouts: '各项的时长上限' }, + invalid: { + count: '请填写正整数,或勾选「不限」', + seconds: '请填写大于 0 的秒数,或勾选「不限」', + usd: '请填写大于 0 的金额,或勾选「不限」', + tolerance: '请填写不小于 0 的数', + }, + spend: (usd: string, calls: number) => `今日已花费 $${usd}(${formatNumber(calls)} 次调用)`, + spendNone: '今日尚无核查调用', + unpriced: (n: number) => `另有 ${formatNumber(n)} 次调用无法估算金额(模型不在价格目录中),未计入`, + saveInvalid: '核查设置中有无效值,请修正后再保存', + }, +} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 051a01ed..ee736b74 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -3,6 +3,7 @@ import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, + DUAL_CHECK_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -714,13 +715,34 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: return reason ? `${head}:${reason}` : head } +/** + * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 + * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒 + */ +const VERIFY_LOG_CODES = new Set([ + 'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved', + 'dual_check_catalog', 'dual_check_limit', + 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit', +]) + +const DC = DUAL_CHECK_TEXT +const CL = DUAL_CHECK_TEXT.challengeLog +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +/** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */ +const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[㐀-鿿]/.test(v) ? v : undefined) +/** 个数:给的是数就取整,给的是清单就数一下,别的(缺了、写坏了)当不知道 */ +const countOf = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) + : Array.isArray(v) ? v.length : undefined) + /** * 几种「看着跑完了、其实没做成」的状况(NI-3/4/5)。后端这几条日志是写给排查的,原样 * 贴出来是一串标记和术语,而且不说该去哪儿改——这里说成人话,并给出下一步。 - * 认不得的 code 返回 null,照原话显示 + * 认不得的 code 返回 null,照原话显示。 + * + * 双盲复算、反方审稿那几条(VERIFY_LOG_CODES)另带 level(该不该用提醒色)和 artifact(完整对照、审稿记录可下钻) */ function explainLog(code: string | undefined, message: string, d: Record = {}, -): Pick | null { +): Pick | null { const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : []) switch (code) { @@ -971,6 +993,117 @@ function explainLog(code: string | undefined, message: string, d: Record str(m.name) ?? str(m.id)).filter((n): n is string => !!n) + return { + title: DC.log.disagreed(list.length || undefined), + sub: names.length ? names.map((n) => `「${n}」`).join('') : undefined, + next: DC.log.disagreedNext, + level: 'warn', + ...(str(d.card_artifact) ? { artifact: str(d.card_artifact) } : {}), + } + } + case 'dual_check_resolved': { + // choices 是 {指标 id: a | b | neither};gaps 是记成缺口的个数(给成清单的也数得出来) + const picked = isRecord(d.choices) ? Object.values(d.choices) : [] + const n = (c: string) => picked.filter((v) => v === c).length + const parts = [ + n('a') ? DC.log.resolvedA(n('a')) : '', n('b') ? DC.log.resolvedB(n('b')) : '', n('neither') ? DC.log.resolvedNeither(n('neither')) : '', + ].filter(Boolean) + const gaps = countOf(d.gaps) + // 没给缺口数时按约定推:采用盲算、两边都不对都会记缺口 + const warn = gaps != null ? gaps > 0 : n('b') + n('neither') > 0 + return { title: DC.log.resolved(parts), sub: gaps != null ? DC.log.gaps(gaps) : undefined, level: warn ? 'warn' : 'info' } + } + case 'dual_check_catalog': { + // written:[{source, table, item}] 写进数据目录的已确认事实;skipped:[{reason}] 没写的(已有已确认的不覆盖……) + const written = Array.isArray(d.written) ? d.written.filter(isRecord) : [] + const skipped = Array.isArray(d.skipped) ? d.skipped.filter(isRecord) : [] + const what = written.map((w) => { + const table = str(w.table) + const where = table ? `${table}${str(w.source) ? `(${str(w.source)})` : ''}` : '' + return [where, str(w.item)].filter(Boolean).join(' · ') + }).filter(Boolean) + const why = skipped.map((s) => str(s.reason)).filter((r): r is string => !!r) + const rest = skipped.length ? `${DC.log.catalogSkipped(skipped.length)}${why.length ? `:${why.join(';')}` : ''}` : '' + return { + title: written.length ? DC.log.catalog(written.length) : DC.log.catalogNone, + sub: [what.join(';'), rest].filter(Boolean).join(';') || undefined, + next: written.length ? DC.log.catalogNext : undefined, + level: 'info', + } + } + case 'dual_check_limit': { + const which = str(d.limit) + return { + title: DC.log.limit((which && DC.limitLabel[which]) || DC.limitAny, str(d.metric)), + next: which === 'daily_max_usd' ? DC.limitDailyNext : DC.log.limitNext, + level: 'warn', + } + } + // ---- 反方审稿:同上,文案在 DUAL_CHECK_TEXT.challengeLog(时间线的解读统一在这里做) + case 'challenge_skipped': { + const reason = str(d.reason) + if (reason === 'disabled') { + return { title: CL.skippedDisabled, next: CL.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' } + } + if (reason === 'no_report') return { title: CL.skippedNoReport, next: CL.skippedNoReportNext, fix: 'canvas', level: 'warn' } + if (reason === 'no_claims') return { title: CL.skippedNoClaims, level: 'info' } + return { title: CL.skipped, sub: cjkText(d.reason), level: 'info' } + } + case 'challenge_same_model': + return { title: CL.sameModel(str(d.model)), next: CL.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' } + case 'challenge_done': { + // counts:{stands, weakened, refuted, inconclusive},四种结论各几句;为 0 的不写 + const c = isRecord(d.counts) ? d.counts : {} + const keys = ['stands', 'weakened', 'refuted', 'inconclusive'] + const nums = keys.map((k) => countOf(c[k]) ?? 0) + const total = nums.reduce((a, b) => a + b, 0) + const parts = keys.map((k, i) => (nums[i] ? `${CL.verdict[k]} ${formatNumber(nums[i])}` : '')).filter(Boolean) + return { + title: CL.done(total || undefined), + sub: parts.length ? parts.join(' · ') : undefined, + level: nums[2] > 0 ? 'warn' : 'info', + ...(str(d.artifact) ? { artifact: str(d.artifact) } : {}), + } + } + case 'challenge_refuted': { + const items = Array.isArray(d.items) ? d.items.filter(isRecord) : [] + const texts = items.map((i) => str(i.text)).filter((t): t is string => !!t) + return { + title: CL.refuted(items.length || undefined), + sub: texts.length ? texts.map((t) => `「${t}」`).join('') : undefined, + next: CL.refutedNext, + level: 'warn', + } + } + case 'challenge_limit': { + const which = str(d.limit) + const skipped = countOf(d.claims_skipped) + return { + title: CL.limit((which && DC.limitLabel[which]) || DC.limitAny, skipped || undefined), + next: which === 'daily_max_usd' ? DC.limitDailyNext : CL.limitNext, + level: 'warn', + } + } default: return null } @@ -2146,6 +2279,19 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt break } } + // 双盲复算、反方审稿的日志按 code 和结构化字段解读:info 的也上时间线,没有原话的老日志照样说得出来。 + // 后端标了 warn / error 的照它的来,explainLog 认为该提醒的(有缺口、有推翻)也用提醒色 + if (typeof d.code === 'string' && VERIFY_LOG_CODES.has(d.code)) { + const said = explainLog(d.code, message, d) + if (said) { + const tone = level === 'error' ? 'error' : level === 'warn' || said.level === 'warn' ? 'warn' : 'info' + push({ + id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: d.code, ...said, level: tone, + ...(message ? { detail: message } : {}), + }, nodeId) + break + } + } // 其余 info 是给排查用的,不进主流程——但 warn/error 用户必须看到 if (level === 'info' || !message) break const code = typeof d.code === 'string' && d.code ? d.code : undefined diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bd414929..4c86beb0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3480,3 +3480,124 @@ export interface RunComparison { /** 运行接口回的运行带着发起它的定时任务(RunOut.schedule_id);Run 上没写这一项,对比页的运行卡要用 */ export type RunWithSchedule = Run & { schedule_id?: string | null } + +// --------------------------------------------------------------------------- +// 双盲复算:审批 payload、完整对照工件、节点配置,以及设置里「核查」一组(期 3 前后端共用的约定) +// --------------------------------------------------------------------------- + +/** 审核人对一个指标的裁决:a 采用原流程的结果,b 采用盲算的结果,neither 两边都不对 */ +export type DualCheckChoice = 'a' | 'b' | 'neither' + +/** 双盲复算挂起时 payload.summary 的一项:审批卡先用它画,完整对照另从 card_artifact 取 */ +export interface DualCheckSummaryItem { + metric_id: string + name: string + unit?: string | null + original_rendered: string | null + blind_rendered: string | null + /** (盲算值 − 原流程值) ÷ |原流程值| × 100,保留两位小数(12.5 表示相差 12.5%);原流程值为 0 时为 null */ + delta_pct: number | null + /** 具体差异,一条一句中文,最多 3 条 */ + diff: string[] + /** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */ + fallback: boolean +} + +/** payload.kind 为 dual_check 的审批 payload(审批的 mode 是 input) */ +export interface DualCheckPayload { + kind: 'dual_check' + node_id: string + title: string + /** 完整对照放在这个工件里(GET /api/artifacts/{id}),内容见 DualCheckCard */ + card_artifact: string + summary: DualCheckSummaryItem[] +} + +/** 审核人的回复:POST /approvals/{id}/decide 的 value */ +export interface DualCheckReply { + choices: Record + note: string +} + +/** 指标的一个输入:取自哪条查询的哪一格(alias 是查询快照的编号) */ +export interface DualCheckInput { + path: string + value: unknown + sql?: string | null + source?: string | null + alias?: string | null +} + +/** 盲算那一边的输入多一段盲算模型自己的说明 */ +export interface DualCheckBlindInput extends DualCheckInput { + rationale?: string | null +} + +export interface DualCheckSide { + value: number | null + rendered: string | null + inputs: I[] +} + +/** 两条 SQL 差在哪(服务端 sql_semantics.explain_sql_diff)。aspect 为 other 的是兜底说明,parse 是解析不了 */ +export interface DualCheckDiff { + equivalent: boolean + items: { aspect: string; text: string }[] + /** 用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分) */ + fallback: boolean + /** 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的 */ + text_diff: string[] +} + +export interface DualCheckMetric { + metric_id: string + name: string + unit?: string | null + decimals?: number | null + original: DualCheckSide + blind: DualCheckSide + diff: DualCheckDiff + /** 相差比例,口径同 DualCheckSummaryItem.delta_pct(有的话) */ + delta_pct?: number | null +} + +/** card_artifact 工件的内容 */ +export interface DualCheckCard { + metrics: DualCheckMetric[] + /** 盲算用的模型 */ + model?: string | null + /** 盲算模型和原流程的模型相同 */ + same_model?: boolean +} + +/** 双盲复算节点的配置。留空的项:card 取上游的口径卡,metrics 取输入来自查询的全部指标,其余跟随设置 */ +export interface DualCheckConfig { + card?: string + metrics?: string[] + definitions?: Record + provider?: string + model?: string + tolerance?: number + on_reject?: 'degrade' | 'fail' + /** null 或不写:跟随设置 */ + enabled?: boolean | null +} + +/** 设置里的 verify 一组(GET /settings 给的是补齐默认之后的完整一组;上限为 null 表示不限) */ +export interface VerifySettings { + provider: string | null + model: string | null + dual_check: { enabled: boolean; tolerance: number; max_queries: number | null; timeout_s: number | null } + challenge: { enabled: boolean; max_claims: number | null; max_queries: number | null; timeout_s: number | null } + inquiry: { enabled: boolean; max_queries: number | null; timeout_s: number | null } + daily_max_usd: number | null +} + +/** GET /settings/verify/spend:今天(本地日期)核查花了多少。unpriced_calls 是估不出金额、没算进 usd 的调用次数 */ +export interface VerifySpend { + date?: string + usd?: number + calls?: number + unpriced_calls?: number + daily_max_usd?: number | null +} diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index d6f1a459..2a3c28f8 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2018,5 +2018,154 @@ await section('并行查询配对(A1)、查询的 SQL 检查(tool.end.chec check('……下一步说改来源查询的 SQL', !!m?.next?.includes('来源查询的 SQL'), m?.next) }) +await section('双盲复算与反方审稿的日志(期 3):按 code 和结构化字段说人话,info 也上时间线,老日志缺字段不崩', async () => { + // 期 3 前后端共用的约定:后端发 log 时带 code 和结构化字段,时间线按 code 解读、不从原话里认。原话照旧留在展开区。 + // 一致、裁决、记入数据目录这几条后端发的是 info:不上时间线的话,双盲复算节点就是一个跑了几十秒、什么都没说的黑盒 + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const CARD = 'c0ffee'.repeat(10) + 'beef' + const DONE = 'facade'.repeat(10) + 'f00d' + const events = [ + E(1, 'run.started', null, { total: 5 }), + E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }), + E(3, 'log', 'dc', { level: 'warn', code: 'dual_check_same_model', model: 'mx-1', message: '盲算模型与原流程相同(mx-1)' }), + E(4, 'log', 'dc', { level: 'info', code: 'dual_check_agreed', metrics: 2, message: '2 个指标与盲算一致' }), + E(5, 'log', 'dc', { level: 'warn', code: 'dual_check_disagreed', metrics: [{ id: 'gmv', name: '销售额' }, { id: 'aov', name: '' }], + card_artifact: CARD, message: '2 个指标与盲算不一致,已暂停等待审核' }), + E(6, 'log', 'dc', { level: 'warn', code: 'dual_check_limit', limit: 'max_queries', metric: '客单价', message: '已达查询次数上限' }), + E(7, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'a', aov: 'neither' }, gaps: 1, message: '审核人已裁决' }), + E(8, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '裁决已记入数据目录', + written: [{ source: 'shop', table: 'orders', item: '业务日期:支付时间 paid_at' }], + skipped: [{ reason: '已有已确认的有效行过滤,未覆盖' }] }), + E(9, 'node.finished', 'dc', { duration_ms: 3000 }), + // 没有原话的老日志(只有 code 和结构化字段)照样解读 + E(10, 'node.started', 'dc2', { node_type: 'dual_check', label: '复核订单数' }), + E(11, 'log', 'dc2', { level: 'info', code: 'dual_check_skipped', reason: 'disabled' }), + E(12, 'node.finished', 'dc2', { duration_ms: 5 }), + E(13, 'node.started', 'dc3', { node_type: 'dual_check', label: '复核退款' }), + E(14, 'log', 'dc3', { level: 'info', code: 'dual_check_skipped', reason: 'no_inputs', message: '没有可复算的指标' }), + E(15, 'node.finished', 'dc3', { duration_ms: 5 }), + E(16, 'node.started', 'ch', { node_type: 'challenge', label: '反方审稿' }), + E(17, 'log', 'ch', { level: 'warn', code: 'challenge_same_model', model: 'wm-2', message: '审稿模型与撰写模型相同' }), + E(18, 'log', 'ch', { level: 'warn', code: 'challenge_limit', limit: 'timeout_s', claims_skipped: 2, message: '已达时长上限' }), + E(19, 'log', 'ch', { level: 'warn', code: 'challenge_refuted', message: '2 句结论被推翻', + items: [{ unit: 'u3', text: '增长主要来自新客' }, { unit: 'u5', text: '退款率下降' }] }), + E(20, 'log', 'ch', { level: 'info', code: 'challenge_done', counts: { stands: 3, weakened: 1, refuted: 2, inconclusive: 0 }, + artifact: DONE, message: '反方审稿完成' }), + E(21, 'node.finished', 'ch', { duration_ms: 9000 }), + E(22, 'node.started', 'ch2', { node_type: 'challenge', label: '反方审稿二' }), + E(23, 'log', 'ch2', { level: 'info', code: 'challenge_skipped', reason: 'disabled', message: '反方审稿已关闭' }), + E(24, 'node.finished', 'ch2', { duration_ms: 5 }), + E(25, 'run.finished', null, { status: 'succeeded' }), + ] + const all = flatten(mod.decodeRun(events)) + const by = (code, node) => all.find((s) => s.code === code && (!node || s.nodeId === node)) + const CODES = ['dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved', + 'dual_check_catalog', 'dual_check_limit', 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit'] + check('约定里的 12 个日志代码各有一行(info 的也在)', CODES.every((c) => by(c)), CODES.filter((c) => !by(c)).join('、')) + check('……都挂在自己的节点下,不是平铺', CODES.every((c) => all.some((s) => s.kind === 'node' && s.children?.some((x) => x.code === c)))) + check('……标题里没有内部代码', !all.some((s) => /dual_check_|challenge_/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`))) + check('……有原话的,原话留在展开区', by('dual_check_agreed')?.detail === '2 个指标与盲算一致' + && by('challenge_done')?.detail === '反方审稿完成', `${by('dual_check_agreed')?.detail} / ${by('challenge_done')?.detail}`) + + const same = by('dual_check_same_model') + check('dual_check_same_model:点名模型、缺乏独立性、提醒色,入口落到节点的盲算模型', same?.title === '盲算模型与原流程同为「mx-1」,复算缺乏独立性' + && same.level === 'warn' && !!same.next?.includes('盲算模型') && same.fix === 'canvas' && same.fixField === 'model', + `${same?.title} / ${same?.level} / ${same?.fixField}`) + const agreed = by('dual_check_agreed') + check('dual_check_agreed:几个指标相符,不用提醒色', agreed?.title === '双盲复算一致:2 个指标与盲算结果相符' && agreed.level === 'info', + `${agreed?.title} / ${agreed?.level}`) + const dis = by('dual_check_disagreed') + check('dual_check_disagreed:几个指标不一致、等待裁决,副标题点名(没名字的写 id)', dis?.title === '双盲复算有 2 个指标与盲算结果不一致,等待审核人裁决' + && dis.sub === '「销售额」「aov」' && dis.level === 'warn', `${dis?.title} / ${dis?.sub}`) + check('……下一步说去审批卡上逐个选,完整对照的工件可下钻', !!dis?.next?.includes('审批卡') && dis.artifact === CARD, `${dis?.next} / ${dis?.artifact}`) + const lim = by('dual_check_limit') + check('dual_check_limit:哪个指标、哪个上限、去哪儿调', lim?.title === '复算指标「客单价」时已达查询次数上限,该指标未完成盲算' + && lim.level === 'warn' && !!lim.next?.includes('核查'), `${lim?.title} / ${lim?.next}`) + const res = by('dual_check_resolved') + check('dual_check_resolved:按选择计数(为 0 的不写),缺口放副标题', res?.title === '审核人已裁决:采用原流程 1 个,两边都不对 1 个' + && res.sub === '1 项记为缺口' && res.level === 'warn', `${res?.title} / ${res?.sub} / ${res?.level}`) + const cat = by('dual_check_catalog') + check('dual_check_catalog:记入几项、写的是什么、没写入的照实说', cat?.title === '已将 1 项裁决记入数据目录' + && !!cat.sub?.includes('orders') && cat.sub.includes('业务日期:支付时间 paid_at') && cat.sub.includes('1 项未写入') + && cat.sub.includes('已有已确认的有效行过滤,未覆盖') && !!cat.next?.includes('数据目录') && cat.level === 'info', `${cat?.title} / ${cat?.sub}`) + const off = by('dual_check_skipped', 'dc2') + check('dual_check_skipped(disabled,没有原话):照样上时间线,说怎么开启', off?.title === '双盲复算未启用,已跳过' + && !!off.next?.includes('启用') && off.level === 'info' && off.detail === undefined, `${off?.title} / ${off?.next} / ${off?.detail}`) + const none = by('dual_check_skipped', 'dc3') + check('dual_check_skipped(no_inputs):没有可复算的指标是提醒,入口落到「复算的指标」', none?.title === '没有输入来自查询的指标,已跳过复算' + && none.level === 'warn' && none.fix === 'canvas' && none.fixField === 'metrics', `${none?.title} / ${none?.level} / ${none?.fixField}`) + const csame = by('challenge_same_model') + check('challenge_same_model:点名模型、缺乏独立性', csame?.title === '反方审稿模型与报告撰写模型同为「wm-2」,审稿缺乏独立性' + && csame.level === 'warn' && !!csame.next, csame?.title) + const climit = by('challenge_limit') + check('challenge_limit:哪个上限、几句没审、去哪儿调', climit?.title === '反方审稿已达时长上限,2 句结论未审' && climit.level === 'warn' + && !!climit.next?.includes('反方审稿'), `${climit?.title} / ${climit?.next}`) + const ref = by('challenge_refuted') + check('challenge_refuted:推翻了几句,句子放副标题', ref?.title === '反方审稿推翻了 2 句结论' && ref.sub === '「增长主要来自新客」「退款率下降」' + && ref.level === 'warn' && !!ref.next, `${ref?.title} / ${ref?.sub}`) + const done = by('challenge_done') + check('challenge_done:审了几句、四种结论各几句(为 0 的不写),有推翻时是提醒、工件可下钻', done?.title === '反方审稿完成:审阅了 6 句结论' + && done.sub === '经受住 3 · 被削弱 1 · 被推翻 2' && done.level === 'warn' && done.artifact === DONE, `${done?.title} / ${done?.sub} / ${done?.level}`) + const coff = by('challenge_skipped') + check('challenge_skipped(disabled):未启用、怎么开启', coff?.title === '反方审稿未启用,已跳过' && coff.level === 'info' && !!coff.next, + `${coff?.title} / ${coff?.level}`) + check('……节点照常完成', all.filter((s) => s.kind === 'node').every((s) => s.status === 'done')) + + // 换一种取值:每日金额上限、选择全是原流程、缺口是一份清单、推翻 0 句、跳过原因是一句中文 + const alt = flatten(mod.decodeRun([ + E(1, 'node.started', 'dc', { node_type: 'dual_check' }), + E(2, 'log', 'dc', { level: 'warn', code: 'dual_check_limit', limit: 'daily_max_usd', message: '今日核查费用已达上限' }), + E(3, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'a', aov: 'a' }, gaps: [], message: '审核人已裁决' }), + E(4, 'log', 'dc', { level: 'info', code: 'dual_check_resolved', choices: { gmv: 'b' }, gaps: ['指标「销售额」采用了盲算的结果'], message: '审核人已裁决' }), + E(5, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', written: [], skipped: [{ reason: '差异不止一个方面' }], message: '未写入' }), + E(6, 'node.finished', 'dc', {}), + E(7, 'node.started', 'ch', { node_type: 'challenge' }), + E(8, 'log', 'ch', { level: 'info', code: 'challenge_done', counts: { stands: 4, weakened: 0, refuted: 0, inconclusive: 1 }, message: '完成' }), + E(9, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: '报告节点没有产出文档', message: '跳过' }), + E(10, 'log', 'ch', { level: 'info', code: 'challenge_skipped', reason: 'no_claims', message: '跳过' }), + E(11, 'node.finished', 'ch', {}), + ])) + const daily = alt.find((s) => s.code === 'dual_check_limit') + check('每日金额上限:不点名指标,下一步说今日费用已达上限', daily?.title === '双盲复算已达每日金额上限' && !!daily.next?.includes('今日核查费用'), + `${daily?.title} / ${daily?.next}`) + const [allA, oneB] = alt.filter((s) => s.code === 'dual_check_resolved') + check('全部采用原流程:没有缺口,不用提醒色', allA?.title === '审核人已裁决:采用原流程 2 个' && allA.sub === '没有缺口' && allA.level === 'info', + `${allA?.title} / ${allA?.sub} / ${allA?.level}`) + check('缺口给的是一份清单:照样数出几项', oneB?.title === '审核人已裁决:采用盲算 1 个' && oneB.sub === '1 项记为缺口' && oneB.level === 'warn', + `${oneB?.title} / ${oneB?.sub}`) + const cat0 = alt.find((s) => s.code === 'dual_check_catalog') + check('一项都没写入数据目录:标题照实说,原因放副标题', cat0?.title === '裁决未记入数据目录' && !!cat0.sub?.includes('差异不止一个方面'), + `${cat0?.title} / ${cat0?.sub}`) + const calm = alt.find((s) => s.code === 'challenge_done') + check('没有推翻:不用提醒色,0 句的不写', calm?.title === '反方审稿完成:审阅了 5 句结论' && calm.sub === '经受住 4 · 无法判断 1' + && calm.level === 'info', `${calm?.title} / ${calm?.sub} / ${calm?.level}`) + const [said, noClaims] = alt.filter((s) => s.code === 'challenge_skipped') + check('跳过原因是一句中文:照写在副标题;认得的原因说人话', said?.title === '反方审稿已跳过' && said.sub === '报告节点没有产出文档' + && noClaims?.title === '报告中没有需要审的关键结论,已跳过反方审稿', `${said?.title} / ${said?.sub} / ${noClaims?.title}`) + + // 老日志缺字段、字段类型不对:不崩,标题照样说人话,不出 undefined、NaN、[object Object],不瞎填数 + const odd = [ + ['dual_check_skipped', {}], ['dual_check_same_model', { model: 7 }], ['dual_check_agreed', { metrics: 'x' }], + ['dual_check_disagreed', { metrics: 'x', card_artifact: 3 }], ['dual_check_resolved', { choices: null, gaps: 'x' }], + ['dual_check_catalog', { written: 'x', skipped: { reason: 1 } }], ['dual_check_limit', { limit: 9 }], + ['challenge_skipped', { reason: ['x'] }], ['challenge_same_model', {}], ['challenge_done', { counts: [1, 2] }], + ['challenge_refuted', { items: {} }], ['challenge_limit', { claims_skipped: 'x' }], + ] + let broken = null + let steps = [] + try { + steps = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'dual_check' }), + ...odd.map(([code, data], i) => E(i + 2, 'log', 'x', { level: 'warn', code, ...data })), E(99, 'node.finished', 'x', {})])) + } catch (e) { + broken = String(e?.message ?? e) + } + check('缺字段的老日志:不抛异常', broken === null, broken ?? '') + const rows = odd.map(([code]) => steps.find((s) => s.code === code)) + check('……每个代码照样一行,标题不空', rows.every((s) => s && s.title), odd.filter((_, i) => !rows[i]?.title).map(([c]) => c).join('、')) + check('……不出 undefined、NaN、[object Object],也不瞎填数', !rows.some((s) => /undefined|NaN|object Object|\d/.test(`${s?.title}${s?.sub ?? ''}`)), + rows.filter((s) => /undefined|NaN|object Object|\d/.test(`${s?.title}${s?.sub ?? ''}`)).map((s) => `${s?.title}|${s?.sub}`).join(' ; ')) + check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null)) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) From cac34c90dd192e76efe4441d238ef0c4b745038a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:07:57 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=88=E4=BA=8C=EF=BC=89=EF=BC=9A=E4=B8=A4?= =?UTF-8?q?=E8=B7=AF=E5=AF=B9=E4=B8=8D=E4=B8=8A=E6=97=B6=E7=9A=84=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=A1=EF=BC=8C=E4=B8=89=E9=80=89=E4=B8=80=E8=A3=81?= =?UTF-8?q?=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run/DualCheckApproval.tsx:期 0 的空插槽换成真组件。先按 payload.summary 画两个值、相差比例、摘要里的差异, 完整对照(card_artifact)到了再补盲算模型、全部具体差异和「对照两条 SQL」;只有兜底说明时写明未能归纳成具体差异 并默认展开对照;对照区先列每个输入两边各取到多少,只对照不同的输入,两条 SQL 按卡片宽度左右并排或上下排, text_diff 里有的行在原文中高亮,规范化后对不上原文的不乱标、另列规范化后不同的行;只差一个方面时说明选择会记入 数据目录;每个指标三选一(原生单选,键盘可操作),缺一个都不能提交,回复走审批接口,value 为 {choices, note}; 后果按运行类别说,写明「采用盲算」不替换报告里的数,探索运行不提出具;完整对照取不到时按摘要照样能裁决 - RunPanel:payload.kind 为 dual_check 时不画通用的输入框、备注和「提交 / 驳回并终止」,署名那行保留, onResolved 交给 DualCheckApproval;其他审批卡不变 - 预览用例 dev/fixtures/dual_check.tsx(夹具 dual_check.json 与检查脚本共用,数据虚构) - check-verify:期 0 的占位换成真检查——核查设置的叫法和服务端逐项比对,审批卡的摘要与补全、具体差异、只有兜底、 并排与高亮、不选全不能提交、请求体、取不到完整对照、探索运行、390px 与右栏不溢出、深浅色对比度 --- frontend/src/dev/fixtures/dual_check.json | 154 ++++++++ frontend/src/dev/fixtures/dual_check.tsx | 43 ++ frontend/src/run/DualCheckApproval.tsx | 454 +++++++++++++++++++++- frontend/src/run/RunPanel.tsx | 71 ++-- scripts/check-verify.mjs | 369 +++++++++++++++++- 5 files changed, 1047 insertions(+), 44 deletions(-) create mode 100644 frontend/src/dev/fixtures/dual_check.json create mode 100644 frontend/src/dev/fixtures/dual_check.tsx diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json new file mode 100644 index 00000000..d20f0086 --- /dev/null +++ b/frontend/src/dev/fixtures/dual_check.json @@ -0,0 +1,154 @@ +{ + "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。", + "approval": { + "id": "ap-dc-0001", + "run_id": "run-dc-0001", + "node_id": "dc", + "mode": "input", + "title": "双盲复算:2 个指标不一致", + "status": "pending", + "response": {}, + "workflow_name": "门店周报", + "node_label": "复核周报口径", + "run_class": "formal", + "payload": { + "kind": "dual_check", + "node_id": "dc", + "title": "双盲复算:2 个指标不一致", + "card_artifact": "d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000d0ab1e5c0ffee000", + "summary": [ + { + "metric_id": "gmv", + "name": "销售额", + "unit": "元", + "original_rendered": "45,678.5元", + "blind_rendered": "51,388.3元", + "delta_pct": 12.5, + "diff": ["原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"], + "fallback": false + }, + { + "metric_id": "refund_rate", + "name": "退款率", + "unit": "", + "original_rendered": "2.35%", + "blind_rendered": "3.10%", + "delta_pct": 31.91, + "diff": [], + "fallback": true + } + ] + } + }, + "card": { + "model": "blind-model-x", + "same_model": false, + "metrics": [ + { + "metric_id": "gmv", + "name": "销售额", + "unit": "元", + "decimals": 1, + "original": { + "value": 45678.5, + "rendered": "45,678.5元", + "inputs": [ + { + "path": "vars.kpi.gmv", + "value": 45678.5, + "sql": "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'", + "source": "shop", + "alias": "Q1" + } + ] + }, + "blind": { + "value": 51388.3, + "rendered": "51,388.3元", + "inputs": [ + { + "path": "vars.kpi.gmv", + "value": 51388.3, + "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": "销售额按下单时间统计本周全部订单金额之和" + } + ] + }, + "diff": { + "equivalent": false, + "items": [ + { "aspect": "time", "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'" + ] + } + }, + { + "metric_id": "refund_rate", + "name": "退款率", + "unit": "", + "decimals": 4, + "original": { + "value": 0.0235, + "rendered": "2.35%", + "inputs": [ + { + "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'", + "source": "shop", + "alias": "Q2" + }, + { + "path": "vars.kpi.orders", + "value": 2000, + "sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'", + "source": "shop", + "alias": "Q3" + } + ] + }, + "blind": { + "value": 0.031, + "rendered": "3.10%", + "inputs": [ + { + "path": "vars.kpi.refunds", + "value": 62, + "sql": "SELECT COUNT(DISTINCT order_id) AS refunds FROM refunds WHERE order_id IN (SELECT id FROM orders)", + "source": "shop", + "alias": "B2", + "rationale": "退款笔数按有退款记录的订单去重计数" + }, + { + "path": "vars.kpi.orders", + "value": 2000, + "sql": "SELECT COUNT(*) AS orders FROM orders WHERE status <> 'test'", + "source": "shop", + "alias": "B3", + "rationale": "订单总数,不含测试订单" + } + ] + }, + "diff": { + "equivalent": false, + "items": [ + { "aspect": "other", "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" } + ], + "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)" + ] + } + } + ] + } +} diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx new file mode 100644 index 00000000..940398ff --- /dev/null +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -0,0 +1,43 @@ +import type { JSX } from 'react' +import { ApprovalCard } from '../../run/RunPanel' +import type { Approval } from '../../types' +import data from './dual_check.json' + +/** + * 双盲复算的预览用例,scripts/check-verify.mjs 按它们检查(夹具在同目录的 dual_check.json,检查脚本读的是同一份): + * - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排) + * - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排) + * - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具 + * + * 完整对照的工件(GET /api/artifacts/{id})和审批回复接口由检查脚本用 page.route 伪造,直接打开时卡上照实说 + * 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流 + */ +const approval = (patch: Partial = {}): Approval => ({ + ...(data.approval as unknown as Approval), + created_at: new Date(Date.now() - 180_000).toISOString().replace('Z', ''), + ...patch, +}) + +function Card({ value }: { value: Approval }) { + return ( +
+ { (window as any).__dualResolved = id }} /> +
+ ) +} + +export const cases: Record JSX.Element> = { + 'dual-check-approval': () => ( +
+ ), + 'dual-check-approval-rail': () => ( +
+ +
+ ), + 'dual-check-approval-exploratory': () => ( +
+ +
+ ), +} diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 3632e494..de12f7ba 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -1,15 +1,459 @@ -import type { Approval } from '../types' +import { useEffect, useId, useMemo, useState } from 'react' +import type { CSSProperties } from 'react' +import { AlertTriangle, Check, ChevronRight } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../api/client' +import { useCatalog } from '../store/catalog' +import { useStudio } from '../store/studio' +import { Spinner, useToast } from '../components/ui' +import { NONE } from '../lib/format' +import { DUAL_CHECK_TEXT } from '../lib/terms' +import type { + Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply, + DualCheckSummaryItem, +} from '../types' + +const T = DUAL_CHECK_TEXT.approval +const CHOICES: DualCheckChoice[] = ['a', 'b', 'neither'] export interface DualCheckApprovalProps { /** payload.kind 为 'dual_check' 的审批:双盲复算两路对不上、交给人裁决 */ approval: Approval + /** + * 提交之后谁来重新接上运行的事件流,同 ApprovalCard 的 onResolved:对话页、运行页各自接,没给就走画布的 attachRun。 + * 两处都接会开两条订阅,各收一份事件 + */ + onResolved?: (runId: string) => void | Promise +} + +/** 一个不一致的指标在卡上要画的东西:先由 payload.summary 填,完整对照(card_artifact)到了再补全 */ +interface MetricView { + id: string + name: string + original: string | null + blind: string | null + /** 已乘过 100;null 是原流程值为 0(约定),undefined 是没给 */ + deltaPct: number | null | undefined + /** 具体差异,一条一句(兜底说明不算) */ + items: string[] + /** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */ + fallbackOnly: boolean + /** 具体差异之外还有未能归纳的部分(服务端那句「另有未能归纳的差异」) */ + rest: string | null + /** 两条 SQL 等价,差在取用的结果上 */ + equivalent: boolean + /** 只差一个方面:审核人的选择会记入数据目录 */ + single: boolean + 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) + +/** payload.summary 里认得出的项(老服务端、写坏了的项跳过,不让整张卡崩掉) */ +function summaryOf(raw: unknown): DualCheckSummaryItem[] { + return Array.isArray(raw) + ? raw.filter((s): s is DualCheckSummaryItem => !!s && typeof s === 'object' && typeof s.metric_id === 'string' && !!s.metric_id) + : [] +} + +/** 工件内容像不像一份对照:至少得有 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 + return { ...card, metrics: card.metrics.filter((m) => !!m && typeof m === 'object' && typeof m.metric_id === 'string') } +} + +function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] { + const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m])) + // 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面 + const ids = [...new Set([...summary.map((s) => s.metric_id), ...full.keys()])] + return ids.map((id) => { + const s = summary.find((x) => x.metric_id === id) + const f = full.get(id) ?? null + const base = { + id, + name: str(s?.name) ?? str(f?.name) ?? id, + original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null, + blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null, + 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 other = items.filter((i) => i.aspect === 'other') + const equivalent = f.diff?.equivalent === true + const aspects = new Set(concrete.map((i) => i.aspect)) + return { + ...base, + items: concrete.map((i) => i.text), + fallbackOnly: !equivalent && !concrete.length, + 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'), + full: f, + } + } + return { + ...base, + items: Array.isArray(s?.diff) ? s.diff.filter((t): t is string => typeof t === 'string' && !!t) : [], + fallbackOnly: s?.fallback === true, + rest: null, + equivalent: false, + single: false, + full: null, + } + }) +} + +/** 相差比例:服务端给的已乘过 100、保留两位(12.5 是相差 12.5%),带正负号;小到两位小数写不出来的写「<0.01%」 */ +function deltaText(pct: number | null | undefined): string | null { + if (pct === undefined) return null + if (pct === null) return T.deltaZero + const abs = Math.abs(pct) + const sign = pct > 0 ? '+' : pct < 0 ? '-' : '' + return T.delta(abs < 0.01 ? `${sign}<0.01%` : `${sign}${abs.toLocaleString('en-US', { maximumFractionDigits: 2 })}%`) } /** - * 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它)。 + * 审批卡里双盲复算那一块(RunPanel 的 ApprovalCard 在 payload.kind === 'dual_check' 时渲染它,通用的输入框、备注和 + * 「提交 / 驳回并终止」不画——mode 是 input,通用那套会把一个字符串当 value 发出去)。 * - * 现在是占位,什么都不画;双盲复算的工作包在这里补上两路结果的对照,payload 的形状由那边定。 + * 先按 payload.summary 画:两个值、相差多少、摘要里的差异,选择也能先做;完整对照(card_artifact)到了再补上盲算模型、 + * 全部具体差异和「对照两条 SQL」。取不到完整对照时照实说,按摘要照样能裁决。每个指标三选一、缺一个都不能提交, + * 回复走审批接口:value 为 {choices, note} */ -export function DualCheckApproval(_props: DualCheckApprovalProps) { - return null +export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalProps) { + const toast = useToast() + const refreshApprovals = useCatalog((s) => s.refreshApprovals) + const attachRun = useStudio((s) => s.attachRun) + const uid = useId() + const payload = approval.payload ?? {} + const summary = useMemo(() => summaryOf(payload.summary), [payload.summary]) + const cardId = str(payload.card_artifact) ?? '' + + const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null) + useEffect(() => { + if (!cardId) return + let alive = true + setCard({ id: cardId }) + api.artifact(cardId).then( + (res) => { if (alive) setCard({ id: cardId, data: cardOf(res?.content) }) }, + // 取不到(工件被清理、网络断了)和取回来认不出一样处理:按摘要裁决 + () => { if (alive) setCard({ id: cardId, data: null }) }, + ) + return () => { alive = false } + }, [cardId]) + const slot = card?.id === cardId ? card : null + const data = slot?.data ?? null + const loading = !!cardId && slot?.data === undefined + const lost = !cardId || slot?.data === null + + const views = useMemo(() => viewsOf(summary, data), [summary, data]) + const [choices, setChoices] = useState>({}) + const [note, setNote] = useState('') + const [busy, setBusy] = useState(false) + const pending = views.filter((v) => !choices[v.id]).length + + const submit = async () => { + if (busy || pending || !views.length) return + setBusy(true) + try { + const value: DualCheckReply = { + choices: Object.fromEntries(views.map((v) => [v.id, choices[v.id]])), + note: note.trim(), + } + // 照 ApprovalCard 里 mode 为 input 时的写法:备注外层也放一份,节点拿到的是 {approved, note, value} + await api.approvals.decide(approval.id, { approved: true, note: value.note, value }) + await refreshApprovals() + if (onResolved) await onResolved(approval.run_id) + else await attachRun(approval.run_id) + toast(T.done, 'ok') + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + + return ( +
+ {views.length > 0 &&

{T.intro(views.length)}

} + {loading && ( +
+ {T.loading} +
+ )} + {lost && views.length > 0 && ( +
{T.loadFailed}
+ )} + {data && (str(data.model) || data.same_model) && ( +
+ {str(data.model) && {T.model(data.model!)}} + {data.same_model && ( + + {T.sameModel} + + )} +
+ )} + + {!views.length ? ( +

{T.empty}

+ ) : ( + <> + {views.map((v) => ( + setChoices((cur) => ({ ...cur, [v.id]: c }))} /> + ))} + + {/* 选了会怎样,常显在提交按钮上面:审核人得在提交之前知道「采用盲算」并不替换报告里的数 */} +
+ {T.consequence(approval.run_class)} + {T.consequenceFail} +
+
+ +