diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 0d7083af..a065555a 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -10,7 +10,7 @@ import { NODE_DEFS, sourceHandles } from './nodeDefs' import { DENSE_EXITS, NODE_WIDTH } from './routing' import { LiveClock, stillClock, TeamMatrix, teamBrief, type TeamEnding } from './TeamMatrix' import { edgeKey, topology } from '../run/derive' -import { exhaustedOf } from '../run/decode' +import { exhaustedOf, splitIssuanceGaps } from '../run/decode' import { api } from '../api/client' import { isComposing, StatusBadge, toast } from '../components/ui' import { claimCountsOf, matchedSource } from '../lib/evidence' @@ -898,10 +898,12 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps const tier = view.at != null && issuedAt != null && view.at < issuedAt ? undefined : issuance?.tier const tierClass = tier === 'formal' ? 'is-formal' : tier === 'degraded' ? 'is-degraded' : tier === 'withheld' ? 'is-withheld' : '' const detail = issuanceDetail?.tier ? issuanceDetail : undefined - const gaps: string[] = Array.isArray(detail?.gaps) ? detail.gaps.map(String) : [] + // 双盲复算、反方审稿报上来的缺口是核查查出来的问题(两路不一致、结论被推翻),和「校验没跑全」分开说 + const { verify: verifyGaps, other: gaps } = splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : []) const stampTitle = tier ? [ `出具判定:${issuanceLabel(tier)}`, detail ? `可追溯 ${detail.matched_numbers ?? 0} 个数字 / 核对 ${detail.metrics_checked ?? 0} 个指标` : '', + verifyGaps.length ? `${DUAL_CHECK_TEXT.verifyGaps.head}:${verifyGaps.join(';')}` : '', // 降档常常不是数字对不上,而是校验根本没跑全(叙述模板渲染为空、指标集为空): // 不写出来,悬停在一个「降档出具」上看不到任何理由 gaps.length ? `校验未全部完成:${gaps.join(';')}` : '', diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index af39ded1..6938c832 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3335,7 +3335,9 @@ export const WHATIF_TEXT = { } /** - * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算和反方审稿的日志。 + * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算的日志。核查的上限叫法 + * (limitLabel)和出具缺口里「核查未通过」那一组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的时间线说法在 + * CHALLENGE_TEXT.log。 * * 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为 * 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口, @@ -3570,31 +3572,24 @@ export const DUAL_CHECK_TEXT = { const where = table ? `「${table}」${source ? `(${source})` : ''}` : '' return where && item ? `${where}的${item}` : where || item || '' }, + /** 写进数据目录的一项连同写进去的值:「orders」(shop)的业务日期记为「paid_date」 */ + catalogValue: (where: string, value: string) => `${where}记为「${value}」`, catalogSkipped: (n: number) => `${formatNumber(n)} 项未写入`, catalogNext: '可在「数据 → 数据目录」中查看或修改', /** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */ limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`), limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', + /** dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列) */ + unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,按缺口计入出具` + : '双盲复算有指标无法复算,按缺口计入出具'), + uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行', }, - /** 反方审稿日志的时间线说法:时间线解读统一在这里做,反方审稿的工作包不另写一份 */ - challengeLog: { - skippedDisabled: '反方审稿未启用,已跳过', - skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', - // no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、 - // 保存失败,或者它在这次没有执行到的分支上 - skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿', - skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它', - skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', - skipped: '反方审稿已跳过', - sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` - : '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'), - sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', - done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'), - verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record, - refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'), - refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', - limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), - limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', + /** + * 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、 + * 证据页把它们另起一组,不和「校验没跑全」混在一起——它们是核查查出来的问题,不是校验没做完 + */ + verifyGaps: { + head: '核查未通过', }, } @@ -3703,6 +3698,35 @@ export const CHALLENGE_TEXT = { truncated: '结果已截断,数据库中还有更多数据', /** 理由里有出处的数字:读屏听到的是「数字,出处:查询 Q2 · 第 1 行 · amount」,点一下跳到那次查询 */ cited: (text: string, source: string) => `${text},出处:${source},跳到这次查询`, + + // ---- 时间线(run/decode.ts explainLog):按日志的 code 和结构化字段说,不从原话里认 + log: { + skippedDisabled: '反方审稿未启用,已跳过', + skippedDisabledNext: '如需审稿,请在节点中开启,或在「设置 → 偏好设置 → 核查」中启用反方审稿', + // no_report:要审的报告节点找得到(找不到时节点直接报错,到不了这里),但它的报告文档读不出来——没有写出、 + // 保存失败,或者它在这次没有执行到的分支上 + skippedNoReport: '要审的报告没有可读取的报告文档,已跳过反方审稿', + skippedNoReportNext: '请确认报告撰写节点是否执行成功、是否写出了报告;它位于条件分支上时,确认本次运行执行到了它', + skippedNoClaims: '报告中没有需要审的关键结论,已跳过反方审稿', + skipped: '反方审稿已跳过', + sameModel: (model?: string) => (model ? `反方审稿模型与报告撰写模型同为「${model}」,审稿缺乏独立性` + : '反方审稿模型与报告撰写模型相同,审稿缺乏独立性'), + sameModelNext: '请在节点中指定其他审稿模型,或在「设置 → 偏好设置 → 核查」中修改核查模型', + done: (n?: number) => (n ? `反方审稿完成:审阅了 ${formatNumber(n)} 句结论` : '反方审稿完成'), + verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } as Record, + refuted: (n?: number) => (n ? `反方审稿推翻了 ${formatNumber(n)} 句结论` : '反方审稿推翻了报告中的结论'), + refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', + limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), + limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', + /** challenge_unreviewed:有句子没能反查(核查模型不可用、数据源不在了……),记为无法判断 */ + unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论没能反查,已记为无法判断` + : '反方审稿有结论没能反查,已记为无法判断'), + unreviewedNext: '这几句没有真正查询数据。请检查核查模型和数据源是否可用,或在「设置 → 偏好设置 → 核查」中调高上限后重新运行', + /** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */ + sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'), + sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`, + sourcesLostNext: '请在「数据」页确认这些数据源是否应启用,启用后重新运行', + }, } /** diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index f04e3298..44c10062 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -12,8 +12,9 @@ import { issuanceMarks, segmentState, unitVerdict, verdictState, type AuditFilter, type AuditGroup, type AuditRow, } from '../../lib/evidence' import { formatNumber } from '../../lib/format' -import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' +import { DUAL_CHECK_TEXT, EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { IssuanceBanner } from '../../run/AssistantStream' +import { splitIssuanceGaps } from '../../run/decode' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' import { Markdown, inlineStyles } from '../../run/Markdown' @@ -145,11 +146,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { {/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */} - {!!output?._issuance?.tier && ( -
- -
- )} + {!!output?._issuance?.tier && } {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -314,6 +311,31 @@ function useAudit(runId: string, key: string): AuditState { return state } +/** + * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的一组「核查未通过」。那几条是核查查出来的问题(两路结果不一致、 + * 结论被推翻、指标无法复算),横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来, + * 紧跟在它下面单列,颜色和横幅里的缺口一样 + */ +function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { + const { verify, other } = useMemo( + () => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance]) + const shown = useMemo(() => (verify.length ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + return ( +
+ + {verify.length > 0 && ( +
+
{DUAL_CHECK_TEXT.verifyGaps.head}
+
    + {verify.map((g) =>
  • · {g}
  • )} +
+
+ )} +
+ ) +} + /** 顶上一行:这次运行按哪种方式展示证据、封存核对的结果、每份报告文档的哈希 */ function SummaryBar({ mode, seal, message, reports, labelOf }: { mode?: string diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 96c7174d..0c8ed4c7 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -332,6 +332,8 @@ export const ARTIFACT_KIND_LABEL: Record = { metric_set: '口径卡指标集', report_doc: '报告文档', schema_snapshot: '表结构快照', + dual_check: '双盲复算对照', + challenge: '反方审稿记录', } export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind @@ -349,6 +351,11 @@ export function artifactDescription(kind: string, meta?: Record | n // 查库时冻结的表结构(工件行的 meta 里有 {source, tables: 表数量}):报告里的表名、字段名按它核对 case 'schema_snapshot': return meta?.source ? `表结构:数据源「${meta.source}」${typeof meta.tables === 'number' ? ` · ${meta.tables} 张表` : ''}` : '查询时保存的表结构' + // 两路结果对不上时交给审核人的完整对照(工件行的 meta 里有 {metrics: 指标个数}) + case 'dual_check': return typeof meta?.metrics === 'number' + ? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明' + // 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点}) + case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询' default: return '工件' } } diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 33dd7161..cceb4d8c 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, - CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT, + CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, NODE_TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -717,16 +717,31 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: /** * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 - * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒 + * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。 + * 无法复算(dual_check_unchecked)、没能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 + * 期 4 补上的:以前认不得,只能照原话显示 */ const VERIFY_LOG_CODES = new Set([ 'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved', - 'dual_check_catalog', 'dual_check_limit', + 'dual_check_catalog', 'dual_check_limit', 'dual_check_unchecked', 'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit', + 'challenge_unreviewed', 'challenge_sources_lost', ]) const DC = DUAL_CHECK_TEXT -const CL = DUAL_CHECK_TEXT.challengeLog +const CL = CHALLENGE_TEXT.log + +/** + * 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀 + * (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。 + * 它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),时间线、成果卡片、证据页另起一组「核查未通过」, + * 不和「校验没跑全」的缺口混在一起。其余的照旧 + */ +const VERIFY_GAP_KINDS = [NODE_TYPE_LABEL.dual_check, NODE_TYPE_LABEL.challenge] +export function splitIssuanceGaps(gaps: readonly string[]): { verify: string[]; other: string[] } { + const isVerify = (g: string) => VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`)) + return { verify: gaps.filter(isVerify), other: gaps.filter((g) => !isVerify(g)) } +} const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) /** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */ const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[㐀-鿿]/.test(v) ? v : undefined) @@ -1034,16 +1049,18 @@ function explainLog(code: string | undefined, message: string, d: Record { const key = str(w.item) - const item = key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key) - ? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key) - return DC.log.catalogItem(str(w.table), str(w.source), item) + const known = !!key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key) + const item = known ? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key) + const where = DC.log.catalogItem(str(w.table), str(w.source), item) + const value = known && w.value != null && w.value !== '' ? patchValueText(key, w.value) : '' + return where && value ? DC.log.catalogValue(where, value) : where }).filter(Boolean) const why = skipped.map((s) => str(s.reason)).filter((r): r is string => !!r) const rest = skipped.length ? `${DC.log.catalogSkipped(skipped.length)}${why.length ? `:${why.join(';')}` : ''}` : '' @@ -1062,7 +1079,22 @@ function explainLog(code: string | undefined, message: string, d: Record { + const name = str(m.name) ?? str(m.id) + const why = cjkText(m.reason) + return name ? `「${name}」${why ? `:${why}` : ''}` : '' + }).filter(Boolean) + return { + title: DC.log.unchecked(list.length || undefined), + sub: parts.length ? parts.join(';') : undefined, + next: DC.log.uncheckedNext, + level: 'warn', + } + } + // ---- 反方审稿:同上,文案在 CHALLENGE_TEXT.log(时间线的解读统一在这里做) case 'challenge_skipped': { const reason = str(d.reason) if (reason === 'disabled') { @@ -1107,6 +1139,25 @@ function explainLog(code: string | undefined, message: string, d: Record s?.artifact != null)) }) +await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => { + // 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成 + // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准—— + // 它们是核查查出来的问题,不是校验没做完 + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const events = [ + E(1, 'run.started', null, { total: 3 }), + E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }), + // metrics 是 [{id, name, reason}]:没起名字的写 id,原因是一句中文 + E(3, 'log', 'dc', { level: 'warn', code: 'dual_check_unchecked', message: '双盲复算未能核对 2 个指标,按无法复算计入缺口', + metrics: [{ id: 'gmv', name: '销售额', reason: '输入来自合并查询,无法单独复算' }, { id: 'aov', name: '', reason: '盲算已达查询次数上限' }] }), + E(4, 'node.finished', 'dc', { duration_ms: 20 }), + E(5, 'node.started', 'ch', { node_type: 'challenge', label: '审月报' }), + E(6, 'log', 'ch', { level: 'warn', code: 'challenge_unreviewed', claims_skipped: 2, reason: '数据源「北仓库」已不存在或已停用', + message: '反方审稿有 2 句结论未能反查,已记为无法判断:数据源「北仓库」已不存在或已停用' }), + E(7, 'log', 'ch', { level: 'warn', code: 'challenge_sources_lost', claims: 1, sources: ['北仓库', '南仓库'], + message: '反方审稿有 1 句结论只查了部分数据源' }), + E(8, 'node.finished', 'ch', { duration_ms: 30 }), + E(9, 'node.started', 'done', { node_type: 'output', label: '成果' }), + E(10, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 2, matched_numbers: 3, + gaps: ['双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」', + '叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致'] }), + E(11, 'node.finished', 'done', { duration_ms: 5 }), + E(12, 'run.finished', null, { status: 'succeeded' }), + ] + const steps = mod.decodeRun(events) + const all = flatten(steps) + const by = (code) => all.find((s) => s.code === code) + const NEW = ['dual_check_unchecked', 'challenge_unreviewed', 'challenge_sources_lost'] + check('三个日志代码各有一行、挂在自己的节点下', NEW.every((c) => all.some((s) => s.kind === 'node' && s.children?.some((x) => x.code === c))), + NEW.filter((c) => !by(c)).join('、')) + check('……标题里没有内部代码,原话留在展开区', NEW.every((c) => !/dual_check_|challenge_/.test(`${by(c)?.title}${by(c)?.sub ?? ''}${by(c)?.next ?? ''}`)) + && by('challenge_unreviewed')?.detail === events[5].data.message) + const un = by('dual_check_unchecked') + check('dual_check_unchecked:几个指标无法复算、计入缺口,副标题点名带原因(没名字的写 id),提醒色', + un?.title === '双盲复算有 2 个指标无法复算,按缺口计入出具' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限' + && un.level === 'warn' && !!un.next?.includes('核查'), `${un?.title} / ${un?.sub}`) + const ur = by('challenge_unreviewed') + check('challenge_unreviewed:几句没能反查、记为无法判断,原因放副标题', ur?.title === '反方审稿有 2 句结论没能反查,已记为无法判断' + && ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('没有真正查询数据'), `${ur?.title} / ${ur?.sub}`) + const lost = by('challenge_sources_lost') + check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源' + && lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'), + `${lost?.title} / ${lost?.sub}`) + + const issuance = steps.find((s) => s.kind === 'issuance') + const title = issuance?.title ?? '' + const detail = issuance?.detail ?? '' + check('出具那一行:核查报上来的两条归在「核查未通过」下', detail.split('\n').includes( + '核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail) + check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', detail.split('\n').some((l) => l.startsWith('校验不完整:') + && l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了')), detail) + check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', detail.split('\n').some((l) => l.startsWith('校验不完整:') + && l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail) + const lines = detail.split('\n') + check('……核查那一组排在校验不完整前面,标题以档位开头', title.startsWith('降档出具:核查未通过:') + && lines.findIndex((l) => l.startsWith('核查未通过:')) < lines.findIndex((l) => l.startsWith('校验不完整:')), title) + check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail) + + // splitIssuanceGaps:成果卡片、证据页按同一个函数分组 + const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', + '缺少必需指标:gmv', '反方审稿报告没写完']) + check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({ + verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'], + other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split)) + + // 老日志缺字段、字段类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数 + const odd = [['dual_check_unchecked', { metrics: 'x' }], ['challenge_unreviewed', { claims_skipped: 'x', reason: 3 }], + ['challenge_sources_lost', { claims: null, sources: 'x' }]] + let broken = null + let rows = [] + try { + const got = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'challenge' }), + ...odd.map(([code, data], i) => E(i + 2, 'log', 'x', { level: 'warn', code, ...data })), E(9, 'node.finished', 'x', {})])) + rows = odd.map(([code]) => got.find((s) => s.code === code)) + } catch (e) { + broken = String(e?.message ?? e) + } + check('缺字段的老日志:不抛异常,每个代码照样一行', broken === null && rows.every((r) => r?.title), broken ?? '') + check('……不出 undefined、NaN、[object Object],也不瞎填数', !rows.some((r) => /undefined|NaN|object Object|\d/.test(`${r?.title}${r?.sub ?? ''}`)), + rows.map((r) => `${r?.title}|${r?.sub ?? ''}`).join(' ; ')) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0)