双盲复算前端(一):时间线解读双盲复算和反方审稿的全部日志代码,补类型和文案

- 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
- 去掉双盲复算节点说明里的「功能开发中」和节点卡片上的占位摘要
This commit is contained in:
ThearchyHelios
2026-10-05 13:54:45 +08:00
parent e626b205b6
commit 4d29b67cef
6 changed files with 668 additions and 9 deletions
-2
View File
@@ -95,8 +95,6 @@ function summarize(type: NodeType, config: Record<string, any>): string {
case 'subgraph':
return config.workflow_id ? '嵌套工作流' : '未选择工作流'
// 还是占位的两种:卡片上直接说尚未生效,免得以为放上去就会执行
case 'dual_check':
return DUAL_CHECK_TEXT.pending
case 'challenge':
return CHALLENGE_TEXT.pending
default:
+1 -1
View File
@@ -662,7 +662,7 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
// 说明里写明尚未生效,检查器顶上就是这一句;各自的工作包补上字段时把这句去掉
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: {},
+249 -4
View File
@@ -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<string, string>,
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<string, string>,
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<string, string>,
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<string, string>,
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: '核查设置中有无效值,请修正后再保存',
},
}
+148 -2
View File
@@ -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<string, any> => !!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<string, any> = {},
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks'> | null {
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks' | 'level' | 'artifact'> | 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<string,
fix: 'canvas',
}
}
// ---- 双盲复算:只读结构化字段。老日志缺了字段就不点名、不报数,标题照样说得通
case 'dual_check_skipped': {
const reason = str(d.reason)
if (reason === 'disabled') {
return { title: DC.log.skippedDisabled, next: DC.log.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' }
}
// 放了这个节点却一个指标都复算不了:多半是配错了,用提醒色
if (reason === 'no_inputs') {
return { title: DC.log.skippedNoInputs, next: DC.log.skippedNoInputsNext, fix: 'canvas', fixField: 'metrics', level: 'warn' }
}
return { title: DC.log.skipped, sub: cjkText(d.reason), level: 'info' }
}
case 'dual_check_same_model':
return { title: DC.log.sameModel(str(d.model)), next: DC.log.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' }
case 'dual_check_agreed':
return { title: DC.log.agreed(countOf(d.metrics) || undefined), level: 'info' }
case 'dual_check_disagreed': {
// metrics 是 [{id, name}]:副标题点名,没起名字的指标写 id
const list = Array.isArray(d.metrics) ? d.metrics.filter(isRecord) : []
const names = list.map((m) => 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
+121
View File
@@ -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<string, DualCheckChoice>
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<I extends DualCheckInput = DualCheckInput> {
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<DualCheckBlindInput>
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<string, string>
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
}
+149
View File
@@ -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)