diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index a065555a..624024f9 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -898,12 +898,14 @@ 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 { verify: verifyGaps, other: gaps } = splitIssuanceGaps(Array.isArray(detail?.gaps) ? detail.gaps.map(String) : []) + // 双盲复算、反方审稿报上来的缺口和「校验没跑全」分开说,再分查出了问题(两路不一致、结论被推翻)和没核成(无法复算) + const { failed: verifyFailed, unfinished: verifyUnfinished, 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(';')}` : '', + verifyFailed.length ? `${DUAL_CHECK_TEXT.verifyGaps.failed}:${verifyFailed.join(';')}` : '', + verifyUnfinished.length ? `${DUAL_CHECK_TEXT.verifyGaps.unfinished}:${verifyUnfinished.join(';')}` : '', // 降档常常不是数字对不上,而是校验根本没跑全(叙述模板渲染为空、指标集为空): // 不写出来,悬停在一个「降档出具」上看不到任何理由 gaps.length ? `校验未全部完成:${gaps.join(';')}` : '', diff --git a/frontend/src/dev/fixtures/challenge.json b/frontend/src/dev/fixtures/challenge.json index 042e3469..6dbb3bf6 100644 --- a/frontend/src/dev/fixtures/challenge.json +++ b/frontend/src/dev/fixtures/challenge.json @@ -14,7 +14,9 @@ "dup": "run-chal-dup", "norecord": "run-chal-norecord", "unreviewed": "run-chal-unreviewed", - "offpath": "run-chal-offpath" + "offpath": "run-chal-offpath", + "over": "run-chal-over", + "given": "run-chal-given" }, "report": { "node_id": "write", diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index e1eb4998..5e8a4ffe 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -3,6 +3,7 @@ import { ReactFlow, ReactFlowProvider } from '@xyflow/react' import { Inspector } from '../../canvas/Inspector' import { NodeCard } from '../../canvas/NodeCard' import { ApprovalCard } from '../../run/RunPanel' +import { ChallengeSummary } from '../../run/ChallengeSummary' import { useCatalog } from '../../store/catalog' import { useStudio } from '../../store/studio' import type { Approval, ApprovalChallenge, ApprovalReport, Provider, Workflow } from '../../types' @@ -22,9 +23,16 @@ import fx from './challenge.json' * - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里 * - ?syn=challenge-approval-clear / -clear-flag:正式运行,没有被推翻的结论(降档 / 只标注),两种都不写降档、不写只标注 * - ?syn=challenge-approval-explore-flag:探索运行配只标注,同样不说任何和出具有关的话 - * - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),不说任何和出具有关的话 - * - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句没能反查(unreviewed),被推翻的会降档(degrades) + * - ?syn=challenge-approval-runfail:老服务端的审批(不带 run_class),运行类别也取不到(检查脚本先答 500):不说任何 + * 和出具有关的话。审批带着运行类别时照它说、不另取,见 challenge-summary-given + * - ?syn=challenge-approval-unreviewed:正式运行,四句里有两句未能反查(unreviewed:被削弱、经受住的那两句换成数据源 + * 已停用、记为无法判断,计数跟着改成无法判断 3 句;工件由检查脚本按同样的改法合成),被推翻的会降档(degrades) + * - ?syn=challenge-approval-unreviewed-over:服务端给的 unreviewed(5)比「无法判断」的句数(1)还多(不该出现): + * 卡上按「无法判断」的句数说,两个数不打架 * - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假) + * - ?syn=challenge-summary-given:反方审稿那一块单独画,审批自带的运行类别(正式运行)由调用方传进来;检查脚本让这次运行的 + * GET 失败,卡上照样写「出具会降档」,也不另发这次请求。审批卡(RunPanel 的 ApprovalCard)把 approval.run_class + * 传给它之后,卡上走的就是这条路 * - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成) * - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null) * @@ -39,12 +47,13 @@ import fx from './challenge.json' const report = fx.report as unknown as ApprovalReport const challenge = fx.challenge as unknown as ApprovalChallenge -function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', reviewed: ApprovalReport): Approval { +/** runClass 为 null:老服务端的审批,不带运行类别 */ +function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory' | null, reviewed: ApprovalReport): Approval { return { id, run_id: runId, node_id: 'review', mode: 'approve', title: '本月月报可以发出吗?', status: 'pending', response: {}, payload: { kind: 'human_node', message: '请审核本月月报。', reports: [reviewed] }, created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''), - workflow_name: '月报', node_label: '主管审批', run_class: runClass, + workflow_name: '月报', node_label: '主管审批', ...(runClass ? { run_class: runClass } : {}), } } @@ -68,6 +77,18 @@ const clear: Partial = { counts: { stands: 1, weakened: 1, refuted: 0, inconclusive: 1 }, items: challenge.items.filter((it) => it.verdict !== 'refuted'), } +/** + * 有两句未能反查:被削弱(u5)、经受住(u8)的那两句换成数据源已停用、没查成,记为无法判断(服务端未能反查的每一句都 + * 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查 + */ +const UNREVIEWED_UNITS = ['u5', 'u8'] +const unreviewed: Partial = { + counts: { stands: 0, weakened: 0, refuted: 1, inconclusive: 3 }, + items: challenge.items.map((it) => (UNREVIEWED_UNITS.includes(it.unit) ? { ...it, verdict: 'inconclusive' } : it)), + artifact: 'f'.repeat(64), + unreviewed: 2, + degrades: true, +} // ---------------------------------------------------------------- 检查器 @@ -201,7 +222,7 @@ export const cases: Record JSX.Element> = { approval={approvalOf('ap-chal-10', RUNS.exploreFlag, 'exploratory', withChallenge({ on_refuted: 'flag' }))} /> ), 'challenge-approval-runfail': () => ( - + ), 'challenge-approval-dup': () => ( JSX.Element> = { ), 'challenge-approval-unreviewed': () => ( + approval={approvalOf('ap-chal-14', RUNS.unreviewed, 'formal', withChallenge(unreviewed))} /> + ), + 'challenge-approval-unreviewed-over': () => ( + + ), + 'challenge-summary-given': () => ( +
+ +
), 'challenge-approval-offpath': () => ( :两个指标的预告换成 merges 里那一种组合按它的目录算的(catalog;和 variant、peer 一起用,换上的 SQL * 照它的 gmv、refund_rate 写)。合起来的预告(joint)在完整对照里,由检查脚本按同一种组合伪造工件 * - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」 + * + * ?syn=verify-gaps:出具缺口里有双盲复算、反方审稿报上来的几条(两路结果不一致、结论被推翻、指标无法复算)和一条 + * 校验没做完的。上面是运行详情的证据页(真的 EvidencePane:证据图、审计接口由检查脚本伪造),下面是同一次运行的成果 + * 节点卡片(出具印章的悬停说明):两处都把核查的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下 */ const approval = (patch: Partial = {}): Approval => ({ ...(data.approval as unknown as Approval), @@ -103,6 +108,73 @@ function InspectorCase() { ) } +/** verify-gaps 用例的运行和出具明细(库、指标、地名都是虚构的) */ +const VERIFY_GAPS = { + run: 'run-verify-gaps', + gaps: { + failed: ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'], + unfinished: ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'], + other: ['叙述模板渲染为空(路径可能有误)'], + }, +} +const GAPS = VERIFY_GAPS.gaps +const GAPS_ISSUANCE = { + tier: 'degraded', matched_numbers: 3, metrics_checked: 2, missing_required: [], missing_expected: [], unmatched_numbers: [], + // 缺口的先后照服务端(出具契约按上游节点的先后收):核查的和别的混在一起,分组靠前缀认 + gaps: [GAPS.failed[0], GAPS.other[0], GAPS.unfinished[0], GAPS.failed[1]], +} +const GAPS_RUN: Run = { + id: VERIFY_GAPS.run, workflow_id: null, workflow_name: '周报(预览)', status: 'succeeded', input: {}, + output: { _issuance: GAPS_ISSUANCE }, error: null, usage: {} as Run['usage'], run_class: 'formal', +} +const GAPS_GRAPH = { + nodes: [ + { id: 'dc', type: 'dual_check', position: { x: 0, y: 40 }, data: { label: '复核周报口径', config: {} } }, + { id: 'done', type: 'output', position: { x: 300, y: 40 }, data: { label: '成果', config: {} } }, + ], + edges: [{ id: 'g1', source: 'dc', target: 'done' }], +} as unknown as GraphSpec + +/** + * 证据页和成果节点卡片:图放进 studio store,再按一次运行的事件灌进去(出具事件带着完整的明细,卡片的印章按它画、 + * 悬停说明按它写)。只在这个用例渲染时改 store + */ +function VerifyGapsCase() { + useState(() => { + const { nodes, edges } = toFlow(GAPS_GRAPH) + const st = useStudio.getState() + st.clearRun() + useStudio.setState({ nodes, edges, selectedId: null, run: { ...GAPS_RUN, status: 'running', output: {} }, runSnapshot: null }) + const at = Date.now() / 1000 - 5 + const events: RunEvent[] = [ + { seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 2 } }, + { seq: 2, type: 'node.started', node_id: 'dc', ts: at + 0.1, data: {} }, + { seq: 3, type: 'node.finished', node_id: 'dc', ts: at + 1, data: { duration_ms: 900 } }, + { seq: 4, type: 'node.started', node_id: 'done', ts: at + 1.1, data: {} }, + { seq: 5, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { ...GAPS_ISSUANCE, unmatched: 0 } }, + { seq: 6, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 200 } }, + { seq: 7, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output: { _issuance: GAPS_ISSUANCE }, duration_ms: 1400 } }, + ] + for (const e of events) useStudio.getState().applyEvent(e) + return true + }) + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + return ( +
+
+ undefined} refreshKey="" /> +
+
+ + + +
+
+ ) +} + export const cases: Record JSX.Element> = { 'dual-check-approval': () => (
@@ -123,4 +195,5 @@ export const cases: Record JSX.Element> = { ), 'dual-check-inspector': () => , + 'verify-gaps': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index b83b3c54..dedf7245 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3336,8 +3336,8 @@ export const WHATIF_TEXT = { /** * 双盲复算:节点库说明、检查器、节点卡片摘要、两边对不上时的审批卡,以及时间线上双盲复算的日志。核查的上限叫法 - * (limitLabel)和出具缺口里「核查未通过」那一组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的时间线说法在 - * CHALLENGE_TEXT.log。 + * (limitLabel)和出具缺口里「核查未通过」「核查未完成」那两组(verifyGaps)反方审稿也用,放在这里一处;反方审稿自己的 + * 时间线说法在 CHALLENGE_TEXT.log。 * * 两边的叫法固定为「原流程」「盲算」(服务端 explain_sql_diff 的 labels 也是这两个词),审核人的三个选择固定为 * 「采用原流程的结果」「采用盲算的结果」「两边都不对」。选「采用盲算的结果」不会替换报告里的数,只记缺口, @@ -3492,8 +3492,9 @@ export const DUAL_CHECK_TEXT = { * 同一次裁决里几个指标按当前的选择落到同一张表同一项上时,服务端写目录前把它们归拢(dual_check_facts._merged): * 说法相同的写一次,有效记录条件用 AND 合成一句,业务日期、去重规则说法不同的都不写,驳回过的拿出来不写。对照工件 * 里有服务端把每种组合算好的结果(catalog_joint)时照它说:会写的是合起来的那个值,并写明和哪几个指标一起记 - * (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择有条件地说(conflict、joined), - * tail 里说明预告是按当前的选择算的 + * (together);没有时(老服务端、组合太多、工件还没取到)按其余指标当前的选择推(conflict、joined、pending), + * 被目录挡下的同项指标也算进来,推不准的(pending)只说可能,结尾换成 tailInferred:推出来的不是服务端算好的结果, + * 不能和 tail 一样只在「审批期间目录有改动」时才不准 */ catalogPreview: { head: '记入数据目录:', @@ -3512,10 +3513,22 @@ export const DUAL_CHECK_TEXT = { conflictSame: (choice: string, metrics: string) => `${choice}时与${metrics}的选择说法不同,都不记`, /** 有效记录条件接在 write / writeSame 后面:和这些指标当前采用的条件合成一句写 */ joined: (metrics: string) => `,与${metrics}采用的条件用 AND 合成一句`, + /** + * 没有算好的合并结果、同一项上又有被目录挡下的指标(有效记录条件):挡下的原因卡上认不出(驳回过的会被拿掉, + * 别的原因会连这一边一起挡下),写不写说不准,只说可能。value 同 write + */ + pending: (choice: string, where: string, value: string, metrics: string) => + `${choice}时,${where}可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`, + /** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */ + pendingSame: (choice: string, value: string, metrics: string) => + `${choice}时可能记为「${value}」,也可能不记:要与${metrics}的选择合起来才能确定`, /** 「退款率」、「订单数」;多于 3 个只列前 3 个 */ metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、') + (names.length > 3 ? `等 ${names.length} 个指标` : ''), tail: '写入的项记为人工确认的事实。以上按发起审批时的数据目录和各指标当前的选择预告,审批期间数据目录有改动时,以时间线上的记录为准', + /** 有一条是按其余指标当前的选择推出来的(没有算好的合并结果):推断不一定准,实际写入一律以时间线为准 */ + tailInferred: '写入的项记为人工确认的事实。几个指标落到同一项上时,以上是按各指标当前的选择推断的结果,' + + '实际写入以时间线上的记录为准', }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, @@ -3589,17 +3602,23 @@ export const DUAL_CHECK_TEXT = { /** metric 是指标 ID(服务端日志只带 ID,中文名在原话里),不当名字加引号 */ limit: (label: string, metric?: string) => (metric ? `指标 ${metric} 的盲算已达${label},按无法复算处理` : `双盲复算已达${label}`), limitNext: '请在「设置 → 偏好设置 → 核查」中调高双盲复算的上限', - /** dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列) */ - unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,按缺口计入出具` - : '双盲复算有指标无法复算,按缺口计入出具'), + /** + * dual_check_unchecked:盲算触顶、出错、交不出数,或者输入本身没法单独复算(合并查询、遮罩列)。只说记为缺口, + * 不说出具:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说(同 gaps) + */ + unchecked: (n?: number) => (n ? `双盲复算有 ${formatNumber(n)} 个指标无法复算,已记为缺口` + : '双盲复算有指标无法复算,已记为缺口'), uncheckedNext: '无法复算的原因见展开内容。来自合并查询、遮罩列的输入无法单独复算;盲算触顶、出错的,可调高「设置 → 偏好设置 → 核查」中的上限后重新运行', }, /** * 出具缺口里双盲复算、反方审稿报上来的那几条(缺口以「双盲复算「节点名」:」「反方审稿:」开头):时间线、成果卡片、 - * 证据页把它们另起一组,不和「校验没跑全」混在一起——它们是核查查出来的问题,不是校验没做完 + * 证据页把它们另起,不和「校验没跑全」混在一起,再按是查出了问题还是没核成分两组(run/decode.ts 的 splitIssuanceGaps): + * failed 是核查查出来的问题(两路结果不一致、结论被推翻),unfinished 是没核成的(指标无法复算:合并查询、遮罩列的 + * 输入做不了复算,盲算触顶、出错)——没核过的数不能说成「未通过」,读的人会以为这个数被查出了错 */ verifyGaps: { - head: '核查未通过', + failed: '核查未通过', + unfinished: '核查未完成', }, } @@ -3689,11 +3708,17 @@ export const CHALLENGE_TEXT = { degrade: '出具会降档', degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具', flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位', - /** 按降档处置,但节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具 */ - degradeOff: '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位', - /** 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了等,这几句根本没查成,只是记为无法判断 */ - unreviewed: (n: number) => `${formatNumber(n)} 句没能反查,已记为无法判断`, - unreviewedHint: '核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线', + /** + * 按降档处置,但反方审稿节点不在带出具契约的成果节点上游(服务端的 degrades 为假):缺口进不了出具。这句画在人工审批 + * 的卡上(画布上也会在审批节点旁弹出),主语写明是反方审稿节点,不说「本节点」——那里的本节点是审批节点 + */ + degradeOff: '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位', + /** + * 服务端的 unreviewed:反查没有完成的句数(核查模型不可用、触顶没给出结论、数据源已不存在或已停用、报告没有引用 + * 数据库查询……),都记为无法判断。触顶的可能已经查过几次,不能说成「没有查询数据」;不会多于「无法判断」的句数 + */ + unreviewed: (n: number) => `${formatNumber(n)} 句未能反查,已记为无法判断`, + unreviewedHint: '反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,或报告未引用数据库查询,原因见运行时间线', /** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */ noRecord: '反方审稿记录未保存,无法查看理由和查询', more: (n: number) => `另有 ${formatNumber(n)} 句未列出`, @@ -3734,10 +3759,13 @@ export const CHALLENGE_TEXT = { refutedNext: '请在证据中对照反方审稿的查询和理由,核实这些结论', limit: (label: string, skipped?: number) => (skipped ? `反方审稿已达${label},${formatNumber(skipped)} 句结论未审` : `反方审稿已达${label}`), limitNext: '请在节点或「设置 → 偏好设置 → 核查」中调高反方审稿的上限', - /** challenge_unreviewed:有句子没能反查(核查模型不可用、数据源不在了……),记为无法判断 */ - unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论没能反查,已记为无法判断` - : '反方审稿有结论没能反查,已记为无法判断'), - unreviewedNext: '这几句没有真正查询数据。请检查核查模型和数据源是否可用,或在「设置 → 偏好设置 → 核查」中调高上限后重新运行', + /** + * challenge_unreviewed:有句子未能反查(核查模型不可用、整理证据出错、报告没有引用数据库查询、数据源不在了、一条 + * 查询都没执行成功),记为无法判断。触顶的不在这里(由 challenge_limit 说),所以建议里不提调高上限 + */ + unreviewed: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论未能反查,已记为无法判断` + : '反方审稿有结论未能反查,已记为无法判断'), + unreviewedNext: '原因见展开内容。请检查核查模型和数据源是否可用,修正后重新运行;报告未引用数据库查询的结论无法反查', /** challenge_sources_lost:反查了,但该查的数据源有停用了的,判断不含它们的数据 */ sourcesLost: (n?: number) => (n ? `反方审稿有 ${formatNumber(n)} 句结论只查了部分数据源` : '反方审稿有结论只查了部分数据源'), sourcesLostSub: (names: string[]) => `未能查询:${names.map((x) => `「${x}」`).join('、')}(已不存在或已停用),判断不含其中的数据`, diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 44c10062..a0b7b887 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -312,24 +312,31 @@ function useAudit(runId: string, key: string): AuditState { } /** - * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的一组「核查未通过」。那几条是核查查出来的问题(两路结果不一致、 - * 结论被推翻、指标无法复算),横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来, - * 紧跟在它下面单列,颜色和横幅里的缺口一样 + * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」 + * (指标无法复算)。横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,紧跟在它下面单列, + * 颜色和横幅里的缺口一样(分组见 run/decode.ts 的 splitIssuanceGaps)。导出给预览用例(dev/fixtures/dual_check.tsx) */ -function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { - const { verify, other } = useMemo( +export function IssuanceWithChecks({ issuance, runClass }: { issuance: Record; runClass?: string }) { + const { failed, unfinished, other } = useMemo( () => splitIssuanceGaps(Array.isArray(issuance.gaps) ? issuance.gaps.map(String) : []), [issuance]) - const shown = useMemo(() => (verify.length ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + const verify = failed.length + unfinished.length > 0 + const shown = useMemo(() => (verify ? { ...issuance, gaps: other } : issuance), [issuance, verify, other]) + const group = (kind: 'failed' | 'unfinished', gaps: string[]) => gaps.length > 0 && ( +
+
{DUAL_CHECK_TEXT.verifyGaps[kind]}
+
    + {gaps.map((g) =>
  • · {g}
  • )} +
+
+ ) return (
- {verify.length > 0 && ( -
-
{DUAL_CHECK_TEXT.verifyGaps.head}
-
    - {verify.map((g) =>
  • · {g}
  • )} -
+ {group('failed', failed)} + {group('unfinished', unfinished)}
)}
diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index f8f7892b..1e850522 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -14,40 +14,50 @@ export interface ChallengeSummaryProps { /** 这张审批卡审的报告(payload.reports,和 ApprovalReports 拿到的是同一份) */ reports: ApprovalReport[] runId: string + /** + * 这次运行的类别(审批自带的 approval.run_class,同 ApprovalReports 的 runClass)。给了就照它说、不再另取;没给 + * (老服务端的审批没有这一项)才按运行 id 取一次 + */ + runClass?: string | null } /** - * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),有句子根本没反查成时 - * 另起一行说「N 句没能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行 - * 过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 + * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),出具相关的那一句, + * 有句子反查没完成时再说一行「N 句未能反查」,逐句列出结论原文和判断,点开一句再按 challenge_artifact 取完整内容,看反方 + * 的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、不能点开,整块说一次。 * * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 * 「出具会降档」只在正式运行、有被推翻的结论、被推翻的真会让出具降档时才写:服务端给了 degrades 就照它(按降档处置、又接 * 在带出具契约的成果节点上游才为真),老服务端没给时按「没写只标注」算。只标注的写一句不影响出具,按降档处置却不在出具 - * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别不由审批卡传进来,按运行 id 从证据 store - * 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新拿到焦点时再取)。 + * 路径上的写一句为什么不影响;探索运行不出具,和出具有关的话一句不说。运行类别照审批卡给的(runClass,审批自带的那一项, + * 卡顶写的也是它);没给时按运行 id 从证据 store 取(loadRunClass),取到之前、取不到时都不说和出具有关的话(窗口重新 + * 拿到焦点时再取)。 * * 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。 */ /** - * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 没能反查的句数,degrades 被推翻的会不会让出具降档。 + * 审批卡上的反方审稿概况,约定之外服务端另带两项(期 4):unreviewed 未能反查的句数,degrades 被推翻的会不会让出具降档。 * 老服务端都不给 */ type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } +type RunClass = 'formal' | 'exploratory' +const runClassOf = (v: unknown): RunClass | null => (v === 'formal' || v === 'exploratory' ? v : null) -export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) { +export function ChallengeSummary({ reports, runId, runClass }: ChallengeSummaryProps) { const reviewed = useMemo(() => reports.flatMap((r) => { const challenge = challengeOf(r) return challenge ? [{ report: r, challenge }] : [] }), [reports]) if (!reviewed.length) return null - return + return } -function ChallengeList({ reviewed, runId }: { - reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string +function ChallengeList({ reviewed, runId, given }: { + reviewed: { report: ApprovalReport; challenge: Challenge }[]; runId: string; given: RunClass | null }) { - const run = useRunClass(runId) + // 审批卡给了运行类别就照它,不另取:另取的那一次失败了,正式运行里的「出具会降档」会无端消失,而卡顶照样写着正式运行 + const fetched = useRunClass(given ? '' : runId) + const run = given ?? fetched return ( <> {reviewed.map(({ report, challenge }) => ( @@ -104,11 +114,11 @@ function challengeOf(report: ApprovalReport): Challenge | null { } /** - * 这次运行的类别:取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具有关的话都不说(卡顶照审批自带的运行 - * 类别写着,这里再猜一个只会和它对不上)。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发), - * 取到了正式运行,降档的提示随之出现 + * 这次运行的类别(审批卡没给时才取,runId 为空就不取):取到之前、取不到时为 null——不知道是不是正式运行,就一句和出具 + * 有关的话都不说。取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发),取到了正式运行,降档的提示 + * 随之出现 */ -function useRunClass(runId: string): 'formal' | 'exploratory' | null { +function useRunClass(runId: string): RunClass | null { const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined)) const load = useEvidence((s) => s.loadRunClass) useEffect(() => { if (runId) void load(runId) }, [runId, load]) @@ -119,7 +129,7 @@ function useRunClass(runId: string): 'formal' | 'exploratory' | null { window.addEventListener('focus', again) return () => window.removeEventListener('focus', again) }, [status, runId, load]) - return status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null + return status === 'ok' ? runClassOf(slot?.data) : null } // ---------------------------------------------------------------- 取工件 @@ -212,7 +222,7 @@ const focusLost = () => !document.activeElement || document.activeElement === do // ---------------------------------------------------------------- 卡片 function ChallengeBlock({ report, challenge, run }: { - report: ApprovalReport; challenge: Challenge; run: 'formal' | 'exploratory' | null + report: ApprovalReport; challenge: Challenge; run: RunClass | null }) { const [open, setOpen] = useState>(() => new Set()) const [all, setAll] = useState(false) @@ -226,7 +236,8 @@ function ChallengeBlock({ report, challenge, run }: { const flagged = challenge.on_refuted === 'flag' // 被推翻的真会让出具降档:服务端给了就照它,老服务端没给时按「不是只标注」算 const degrades = challenge.degrades ?? !flagged - const unreviewed = challenge.unreviewed ?? 0 + // 未能反查的每一句都记为无法判断(服务端的约定),不会多于概况里的「无法判断」:对不上时按概况的数说,两个数不打架 + const unreviewed = Math.min(challenge.unreviewed ?? 0, counts.inconclusive) // 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件 const extra = all && detail.data ? detail.data.claims.filter((c) => !items.some((it) => sameClaim(c, it))) : [] const rows: ChallengeItem[] = [...items, ...extra] @@ -283,14 +294,8 @@ function ChallengeBlock({ report, challenge, run }: { ) : (
{CHALLENGE_TEXT.none}
)} - {/* 根本没反查成的几句:「无法判断」里分不出查了数据不够和一条都没查,单独说一次 */} - {unreviewed > 0 && ( -

- {CHALLENGE_TEXT.unreviewed(unreviewed)} - 。{CHALLENGE_TEXT.unreviewedHint} -

- )} - {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */} + {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行)。 + 紧跟在概况那一行(「出具会降档」的标记)下面,中间不插别的话 */} {refuted && !flagged && degrades && (
{CHALLENGE_TEXT.degradeHint}
)} @@ -300,6 +305,13 @@ function ChallengeBlock({ report, challenge, run }: { {refuted && flagged && (
{CHALLENGE_TEXT.flagNote}
)} + {/* 反查没完成的几句:「无法判断」里分不出查了数据不够和反查没做完,单独说一次 */} + {unreviewed > 0 && ( +

+ {CHALLENGE_TEXT.unreviewed(unreviewed)} + 。{CHALLENGE_TEXT.unreviewedHint} +

+ )} {rows.length > 0 && (
    {rows.map((item, i) => { diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 048e0ad2..94a9fd44 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -213,9 +213,15 @@ interface Clause { joint: boolean /** 合并结果里和别的指标一起记入 */ together: boolean - /** 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined) */ + /** + * 没有算好的合并结果、按其余指标当前的选择推的:说法不同都不记(conflict)、有效记录条件合写(joined)、同一项上有被 + * 目录挡下的指标,写不写说不准、只说可能(pending)。inferred:这一条是推出来的(以上几种,以及推的路上照单个指标的 + * 预告说的),结尾换成「以时间线上的记录为准」的一般说法 + */ conflict: boolean joined: boolean + pending: boolean + inferred: boolean } /** @@ -224,12 +230,20 @@ interface Clause { * 那一边认出了写到哪的;被目录挡下的也算——服务端照样并进来): * - 只有它自己:照它自己的预告说,会写的说会记为什么,不写的说原因; * - 还有别的:照对照工件里服务端为这几个成员算好的那一条(catalog_joint)说,会写的是合起来的值、写明和谁一起记, - * 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时按旧办法推:会写的一边,业务日期、去重规则说法 - * 不同说都不记,有效记录条件说合成一句,结尾说以时间线上的记录为准 + * 不写的照原因说;工件里没有(老服务端、组合太多、工件还没取到)时照服务端归拢的先后推(inferred),被目录挡下的 + * 同项指标也算进来——服务端先归拢、后对目录: + * - 业务日期、去重规则:有一个说法不同(不论它会不会被目录挡下),归拢就失败,都不记;说法都一样时结果和单个指标 + * 一样(同一个说法对同一份目录),照自己的预告说; + * - 有效记录条件:自己被挡下的,合起来也写不进去(驳回过的会被拿掉,别的原因连合起来的整句一起挡下),照自己的原因说; + * 同项上有别的指标被挡下时说不准(挡下它的若是驳回,拿掉它照写;若是别的原因,这一边也一起挡下),只说可能; + * 都会写、条件不同的说合成一句 + * 推出来的不是服务端算好的结果,结尾换成以时间线上的记录为准的一般说法 */ function clausesOf(view: MetricView, views: MetricView[], choices: Record, joint: JointEntry[]): Clause[] { const P = T.catalogPreview - const blank = { write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false } + const blank = { + write: false, where: false, reason: false, joint: false, together: false, conflict: false, joined: false, pending: false, inferred: false, + } const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), ...blank }) const out: Clause[] = [] for (const side of ['a', 'b'] as const) { @@ -266,19 +280,28 @@ function clausesOf(view: MetricView, views: MetricView[], choices: Record { - const theirs = o.preview?.[choices[o.id] as 'a' | 'b'] - return theirs?.write && !sameFact(mine.target!.item, theirs.raw, mine.raw) - }).map((o) => o.name))] - if (differ.length && mine.target!.item !== 'valid_filter') { + // 没有算好的合并结果:照服务端归拢的先后推。同项上的指标不论会写还是被挡下都带着本来要写的说法(raw),一起算 + const item = mine.target!.item + const theirs = peers.map((o) => ({ name: o.name, side: o.preview![choices[o.id] as 'a' | 'b']! })) + // 认不出本来要写什么(没带 raw)的不算说法不同:说不准,落到下面的「可能」里 + const differ = [...new Set(theirs.filter((t) => t.side.raw != null && !sameFact(item, t.side.raw, mine.raw)).map((t) => t.name))] + const held = [...new Set(theirs.filter((t) => !t.side.write).map((t) => t.name))] + const inferred = { inferred: true } + if (differ.length && item !== 'valid_filter') { + // 业务日期、去重规则说法不同:归拢这一步就不写了,轮不到对目录,挡下的那个也一样 const names = P.metricNames(differ) - out.push({ side, ...blank, conflict: true, where: !short, + out.push({ side, ...blank, ...inferred, conflict: true, where: !short, text: short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], mine.target!.where, names) }) continue } - out.push(differ.length ? writing(mine.value, { joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value)) + if (!mine.write) { out.push({ ...blocked(mine.reason), ...inferred }); continue } + if (held.length) { + const names = P.metricNames(held) + out.push({ side, ...blank, ...inferred, pending: true, where: !short, + text: short ? P.pendingSame(T.choices[side], mine.value, names) : P.pending(T.choices[side], mine.target!.where, mine.value, names) }) + continue + } + out.push(differ.length ? writing(mine.value, { ...inferred, joined: true }, P.joined(P.metricNames(differ))) : writing(mine.value, inferred)) } out.push(skip('neither')) return out @@ -607,10 +630,12 @@ function MetricBlock({ view, name, choice, clauses, onChoose }: { /** * 服务端预告的「记入数据目录」:三个选择逐条一句(clausesOf),审核人当前选的那一条加重——选之前三条都看得到,选了之后 - * 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里 + * 一眼看出这一选会往目录里写什么、不写的为什么。不写的原因是服务端的原话,往往不短,逐条分行写,不挤在一句里。有一条 + * 是推出来的(没有算好的合并结果)时,结尾换成「实际写入以时间线上的记录为准」的一般说法 */ function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: DualCheckChoice }) { const P = T.catalogPreview + const inferred = clauses.some((c) => c.inferred) return (
    {P.head}
    @@ -619,12 +644,13 @@ function CatalogPreviewNote({ clauses, choice }: { clauses: Clause[]; choice?: D
  • + data-conflict={c.conflict ? '' : undefined} data-joined={c.joined ? '' : undefined} + data-pending={c.pending ? '' : undefined} data-inferred={c.inferred ? '' : undefined}> {c.text}
  • ))}
-

{P.tail}

+

{inferred ? P.tailInferred : P.tail}

) } diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index cceb4d8c..b3286247 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -718,7 +718,7 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: /** * 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的 * (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。 - * 无法复算(dual_check_unchecked)、没能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 + * 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是 * 期 4 补上的:以前认不得,只能照原话显示 */ const VERIFY_LOG_CODES = new Set([ @@ -734,13 +734,23 @@ const CL = CHALLENGE_TEXT.log /** * 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀 * (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。 - * 它们是核查查出来的问题(两路结果不一致、结论被推翻、指标无法复算),时间线、成果卡片、证据页另起一组「核查未通过」, - * 不和「校验没跑全」的缺口混在一起。其余的照旧 + * 时间线、成果卡片、证据页把它们另起,不和「校验没跑全」的缺口混在一起,再分两组(叫法在 DUAL_CHECK_TEXT.verifyGaps): + * - failed「核查未通过」:核查查出来的问题——两路结果不一致(审核人选了盲算、两边都不对、没作出选择),结论被推翻; + * - unfinished「核查未完成」:没核成的——指标无法复算(nodes/dual_check.py 的 GAP_UNCHECKED「指标「X」无法复算:原因」)。 + * 合并查询、遮罩列的输入做不了复算,盲算触顶、出错的也没核过,说成「未通过」读的人会以为这个数被查出了错。 + * 其余的照旧 */ 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 escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +/** 双盲复算报的「无法复算」:前缀(节点名可有可无)之后紧跟 GAP_UNCHECKED 的开头 */ +const UNCHECKED_GAP = new RegExp(`^${escapeRe(NODE_TYPE_LABEL.dual_check)}(?:「.*?」)?:指标「.+?」无法复算:`) +export function splitIssuanceGaps(gaps: readonly string[]): { failed: string[]; unfinished: string[]; other: string[] } { + const out = { failed: [] as string[], unfinished: [] as string[], other: [] as string[] } + for (const g of gaps) { + const verify = VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`)) + ;(!verify ? out.other : UNCHECKED_GAP.test(g) ? out.unfinished : out.failed).push(g) + } + return out } const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) /** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */ @@ -1140,7 +1150,7 @@ function explainLog(code: string | undefined, message: string, d: Record (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)), } const RUNS = fx.runs +// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用 const RUN_CLASS = { [RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal', [RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal', [RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal', - [RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', + [RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', [RUNS.over]: 'formal', +} +// challenge-approval-unreviewed 的工件:同第一个用例,被削弱(u5)、经受住(u8)的两句换成数据源已停用、没查成 +// (服务端 unreached 的写法:记为无法判断,理由是系统说的,没有理由文档和查询) +const UNREVIEWED_ART = 'f'.repeat(64) +const UNREVIEWED_UNITS = ['u5', 'u8'] +const UNREVIEWED_CONTENT = { + ...fx.artifacts[ART], + claims: fx.artifacts[ART].claims.map((c) => (UNREVIEWED_UNITS.includes(c.unit) ? { + ...c, verdict: 'inconclusive', rationale_doc: null, queries: [], + rationale: '这句引用的数据源「北仓库」已不存在或已停用,没有可以反查的数据源,这句未反查', + } : c)), } const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' } const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位' const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具' -const DEGRADE_OFF = '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位' -const UNREVIEWED = (n) => `${n} 句没能反查,已记为无法判断。核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线` +// 主语写明是反方审稿节点:这句画在人工审批的卡上,「本节点」读着像审批节点 +const DEGRADE_OFF = '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位' +const UNREVIEWED = (n) => `${n} 句未能反查,已记为无法判断。反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,` + + '或报告未引用数据库查询,原因见运行时间线' const FAILED = '无法获取反方审稿的详细内容' // F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面 const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份' @@ -152,6 +168,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { hits.artifacts.push(id) if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } }) if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } }) + if (id === UNREVIEWED_ART) return route.fulfill({ json: { id, content: UNREVIEWED_CONTENT } }) const content = fx.artifacts[id] return content ? route.fulfill({ json: { id, content } }) : route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } }) @@ -354,9 +371,9 @@ await section('正式运行、没有被推翻的结论:不写降档', async () }) await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => { - // 取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的 - // 运行类别写着正式运行,两处说法打架。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行 - // 降档提示才出现 + // 老服务端的审批不带运行类别,只能按运行 id 取。取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为 + // 正式运行……会降档」外加重试。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行降档 + // 提示才出现。审批带着运行类别时照它说、不另取,见下一节 const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail') const block = page.locator('[data-challenge="write"]') await block.waitFor() @@ -386,31 +403,67 @@ await section('运行类别取不到:不说任何和出具有关的话,重 await ctx.close() }) -await section('有句子根本没反查成:单独说一行,不混在「无法判断」里', async () => { - // 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了……这几句记为无法判断,可一条查询都没执行。「无法判断 2 句」 - // 分不出是查了、数据不够,还是根本没查,审批卡单独说 +await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => { + // 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里 + // 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的 + // GET 一律答 404 + const { ctx, page, hits, errors } = await open('challenge-summary-given') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await page.waitForTimeout(500) + check('给了运行类别(正式运行):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block)) + check('……不另取运行类别', !hits.runs.includes(RUNS.given), hits.runs.join('、')) + check('……页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => { + // 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断 + // 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它 const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed') const block = page.locator('[data-challenge="write"]') await block.waitFor() await runClassSettled(page, hits, RUNS.unreviewed) const line = block.locator('[data-challenge-unreviewed]') - check('写明几句没能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2) + check('写明几句未能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2) && (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有') - check('……概况照服务端的计数写,不另算', (await text(block.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句') + const inconclusive = await text(block.locator('[data-challenge-count="inconclusive"]')) + check('……概况照服务端的计数写(无法判断 3 句:两句未能反查、一句查过了数据不够),未能反查的不多于无法判断', + inconclusive === '无法判断 3 句' && 2 <= Number(inconclusive.match(/\d+/)?.[0]), inconclusive) + check('……用「未能」,不说「没能」「真正」', !/没能|真正/.test(await text(block))) // degrades 为真、正式运行、有被推翻的:照常写降档 check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档' && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0) - check('没能反查:页面没有报错', errors.length === 0, errors.join(';')) + // 降档的说明紧跟在概况那一行(「出具会降档」的标记)下面,未能反查的那一行在它后面 + const order = await block.evaluate((el) => [...el.children].map((c) => (c.hasAttribute('data-challenge-counts') ? 'counts' + : c.hasAttribute('data-challenge-degrade-note') ? 'note' : c.hasAttribute('data-challenge-unreviewed') ? 'unreviewed' : '')).filter(Boolean)) + check('……降档的说明紧跟在降档标记那一行下面,未能反查的那一行排在后面', order.join(',') === 'counts,note,unreviewed', order.join(',')) + // 点开一句未能反查的:理由是系统说的原因,没有查询 + await toggle(page, 'u5').click() + await detail(page, 'u5').locator('[data-challenge-rationale-text]').waitFor() + check('点开未能反查的一句:理由写明数据源已停用、这句未反查', (await text(detail(page, 'u5').locator('[data-challenge-rationale-text]'))) + .includes('这句未反查'), await text(detail(page, 'u5'))) + check('未能反查:页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() // 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行 for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) { const other = await open(syn) await other.page.locator('[data-challenge="write"]').waitFor() await runClassSettled(other.page, other.hits, run) - check(`${syn}:没有没能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0 - && !(await text(other.page.locator('[data-challenge="write"]'))).includes('没能反查')) + check(`${syn}:没有未能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0 + && !(await text(other.page.locator('[data-challenge="write"]'))).includes('未能反查')) await other.ctx.close() } + // 服务端给的 unreviewed 比「无法判断」还多(不该出现):按「无法判断」的句数说,两个数不打架 + const over = await open('challenge-approval-unreviewed-over') + await over.page.locator('[data-challenge="write"]').waitFor() + await runClassSettled(over.page, over.hits, RUNS.over) + const capped = over.page.locator('[data-challenge-unreviewed]') + check('unreviewed 比「无法判断」多:按「无法判断」的句数说', (await capped.getAttribute('data-challenge-unreviewed')) === '1' + && (await text(capped)).startsWith('1 句未能反查') && (await text(over.page.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句', + await text(capped)) + await over.ctx.close() }) await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => { @@ -423,8 +476,8 @@ await section('按降档处置、却不在出具路径上(degrades 为假) check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0 && !(await text(block)).includes('出具会降档'), await text(block)) const off = page.locator('[data-challenge-degrade-off]') - check('写明本节点不在带出具契约的成果节点上游、不影响出具档位', await off.count() === 1 && (await text(off)) === DEGRADE_OFF, - await off.count() ? await text(off) : '没有') + check('写明反方审稿节点不在带出具契约的成果节点上游、不影响出具档位(不说「本节点」)', await off.count() === 1 && (await text(off)) === DEGRADE_OFF + && !(await text(off)).includes('本节点'), await off.count() ? await text(off) : '没有') check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0) check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() @@ -775,7 +828,7 @@ await section('窄屏与深浅色', async () => { check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) await ctx.close() } - // 几行说明(降档原因、没能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清 + // 几行说明(降档原因、未能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清 for (const theme of ['dark', 'light']) { const notes = [ ['challenge-approval', '[data-challenge-degrade-note]'], diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 570ba6cf..1e803546 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2181,10 +2181,11 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结 check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null)) }) -await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => { +await section('核查类日志与出具缺口(期 4):无法复算、未能反查、只查了部分数据源说人话,核查报上来的缺口另起两组', async () => { // 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成 - // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准—— - // 它们是核查查出来的问题,不是校验没做完 + // 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 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 }), @@ -2202,7 +2203,7 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 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' }), ] @@ -2215,12 +2216,16 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 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}`) + // 只说记为缺口,不说「计入出具」:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说 + check('dual_check_unchecked:几个指标无法复算、记为缺口(不提出具),副标题点名带原因(没名字的写 id),提醒色', + un?.title === '双盲复算有 2 个指标无法复算,已记为缺口' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限' + && un.level === 'warn' && !!un.next?.includes('核查') && !`${un.title}${un.sub}`.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}`) + // 用「未能」(同服务端原话「未能反查」),不说「没有真正查询数据」(触顶的句子查过);这一条不含触顶的句子 + // (由 challenge_limit 说),下一步不提调高上限 + check('challenge_unreviewed:几句未能反查、记为无法判断,原因放副标题,下一步不提上限', ur?.title === '反方审稿有 2 句结论未能反查,已记为无法判断' + && ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('核查模型') && !!ur.next?.includes('数据源') + && !ur.next.includes('上限') && !/没能|真正/.test(`${ur.title}${ur.next}`), `${ur?.title} / ${ur?.sub} / ${ur?.next}`) const lost = by('challenge_sources_lost') check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源' && lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'), @@ -2229,24 +2234,74 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能 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('出具那一行:核查查出了问题的两条归在「核查未通过」下', lines.includes( + '核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail) + check('……无法复算的那条归在「核查未完成」下,不在「核查未通过」下', lines.includes( + '核查未完成:双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询') + && !lines.some((l) => l.startsWith('核查未通过:') && l.includes('无法复算')), detail) + check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', lines.some((l) => l.startsWith('校验不完整:') + && l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了') && !l.includes('无法复算')), detail) + check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', lines.some((l) => l.startsWith('校验不完整:') + && l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail) + const at = (head) => lines.findIndex((l) => l.startsWith(head)) + check('……先「核查未通过」、再「核查未完成」、最后「校验不完整」,标题以档位开头', title.startsWith('降档出具:核查未通过:') + && at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验不完整:'), title) check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail) // splitIssuanceGaps:成果卡片、证据页按同一个函数分组 const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', - '缺少必需指标:gmv', '反方审稿报告没写完']) - check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({ - verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'], + '缺少必需指标:gmv', '反方审稿报告没写完', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择', + '双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对']) + check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算;无法复算的单列一组', JSON.stringify(split) === JSON.stringify({ + failed: ['反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择', + '双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'], + unfinished: ['双盲复算:指标「订单数」无法复算:输入来自合并查询'], other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split)) + // 和服务端的说法对得上:双盲复算、反方审稿报缺口的原话(nodes/dual_check.py 的 GAP_*、nodes/challenge.py 的 + // _refuted_gap)加上出具契约的前缀(nodes/io.py 的 _upstream_issuance_gaps:节点名就是类型叫法时只写「类型:」), + // 按服务端的写法拼出来再分组。服务端改了说法(比如「无法复算」换了个词),这里先变红 + const dcPy = readFileSync(`${root}backend/app/engine/nodes/dual_check.py`, 'utf8') + const chPy = readFileSync(`${root}backend/app/engine/nodes/challenge.py`, 'utf8') + const ioPy = readFileSync(`${root}backend/app/engine/nodes/io.py`, 'utf8') + const gapOf = (name) => dcPy.match(new RegExp(`^${name} = "([^"]+)"$`, 'm'))?.[1] + const fill = (tpl) => tpl?.replace('{name}', '订单数').replace('{reason}', '输入来自合并查询') + const refuted = chPy.match(/return f"(推翻了 \{len\(claims\)\} 句结论:\{_named\(claims\)\})"/)?.[1] + check('读出了服务端报缺口的写法和前缀的写法', ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].every((n) => !!gapOf(n)) + && !!refuted && ioPy.includes('prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:"'), + ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].map((n) => `${n}=${gapOf(n)}`).join(' / ')) + const prefixed = (kind, title, body) => (title === kind ? `${kind}:` : `${kind}「${title}」:`) + body + const served = mod.splitIssuanceGaps([ + ...['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED'].map((n) => prefixed('双盲复算', '复核', fill(gapOf(n)))), + prefixed('双盲复算', '双盲复算', fill(gapOf('GAP_UNCHECKED'))), + prefixed('反方审稿', '反方审稿', refuted?.replace('{len(claims)}', '1').replace('{_named(claims)}', '「东区增长主要来自新客」')), + ]) + check('……两路不一致(选了盲算、两边都不对、没作出选择)和被推翻的归「核查未通过」,无法复算的归「核查未完成」', + served.failed.length === 4 && served.unfinished.length === 1 && served.unfinished[0].startsWith('双盲复算:指标「订单数」无法复算:') + && served.other.length === 0, JSON.stringify(served)) + + // dual_check_catalog 的 written 带了写进去的值(期 4):时间线写「…记为「值」」,值的写法和审批卡上的「会记为「值」」 + // 同一个函数(catalogPatch 的 patchValueText),两处一字不差 + const cp = await import(await load('/src/run/catalogPatch.ts')) + const { DUAL_CHECK_TEXT: DCT } = await import(await load('/src/lib/terms.ts')) + const valued = flatten(mod.decodeRun([ + E(1, 'node.started', 'dc', { node_type: 'dual_check' }), + E(2, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入', + written: [{ source: 'shop', table: 'orders', item: 'business_date', value: { column: 'paid_at' } }, + { source: 'shop', table: 'refunds', item: 'valid_filter', value: "status <> 'test'" }], skipped: [] }), + E(3, 'node.finished', 'dc', {}), + ])).find((s) => s.code === 'dual_check_catalog') + const date = cp.patchValueText('business_date', { column: 'paid_at' }) + const cond = cp.patchValueText('valid_filter', "status <> 'test'") + check('dual_check_catalog 带值:业务日期写列名、有效记录条件写原句,「哪张表的哪一项记为「值」」', + valued?.title === '已将 2 项人工确认的事实记入数据目录' + && valued.sub === `「orders」(shop)的业务日期记为「${date}」;「refunds」(shop)的有效记录条件记为「${cond}」` + && date === 'paid_at' && cond === "status <> 'test'", `${valued?.title} / ${valued?.sub}`) + const card = DCT.approval.catalogPreview.write('采用盲算的结果', DCT.log.catalogItem('orders', 'shop', '业务日期'), date) + check('……值和审批卡上「会记为「值」」的写法一字不差', card.endsWith(`会记为「${date}」`) + && (valued?.sub ?? '').startsWith(`${DCT.log.catalogItem('orders', 'shop', '业务日期')}记为「${date}」`), `${card} / ${valued?.sub}`) + // 老日志缺字段、字段类型不对:不崩,不出 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' }]] diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7bfbfe10..7cb95b43 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -17,7 +17,11 @@ // 节点设置时有条件地说; // - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪; // 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端 -// 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本; +// 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的 +// 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目 +// 标出,老工件照旧纯文本; +// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」 +// 「核查未完成」两组,不放在「校验未全部完成」下; // - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 @@ -456,16 +460,23 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 }) /** - * 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined}。reason:不写、照服务端 - * 的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;conflict、joined:没有合并预告时 - * 按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句) + * 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined, pending, inferred}。 + * reason:不写、照服务端的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入; + * conflict、joined、pending:没有合并预告时按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句、同项上有 + * 被挡下的指标说不准只说可能);inferred:这一条是推出来的 */ const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')] .map((s) => [s.dataset.dualCatalogSide, { text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight), reason: s.dataset.reason != null, joint: s.dataset.joint != null, together: s.dataset.together != null, - conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, + conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, pending: s.dataset.pending != null, + inferred: s.dataset.inferred != null, }]))) +/** 一块预告的结尾:{kind: exact(照服务端算好的说)| inferred(有推出来的), text} */ +const previewTail = async (loc) => { + const tail = loc.locator('[data-dual-catalog-tail]') + return { kind: await tail.getAttribute('data-dual-catalog-tail'), text: await text(tail) } +} /** 不写的一条:选择的叫法加服务端的原话 */ const blockedText = (label, reason) => `${label}时不会记入:${reason}` const CHOICE_LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } @@ -662,6 +673,10 @@ await section('审批卡:几个指标落到同一项上、工件里没有合 const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]')) check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择') && note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note) + const tail = await previewTail(metric(page, 'gmv')) + check('……推出来的:结尾不说只有目录被改了才会不一致,说实际写入以时间线上的记录为准', tail.kind === 'inferred' + && tail.text.includes('推断') && tail.text.endsWith('实际写入以时间线上的记录为准') && !tail.text.includes('审批期间数据目录有改动时'), + `${tail.kind} / ${tail.text}`) // 退款率改选两边都不对:不再落到同一项上,销售额照常记 await choose(page, 'refund_rate', 'neither') g = await previewClauses(metric(page, 'gmv')) @@ -698,6 +713,42 @@ await section('审批卡:几个指标落到同一项上、工件里没有合 && cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句` && cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + // 被目录挡下的同项指标也算进来(2026-10 复核):目录里人工驳回过业务日期 created_at(夹具 time_rejected)。销售额采用 + // 原流程(paid_at,单独看会写),退款率采用盲算(created_at,单独看被驳回挡下,但带着写到哪)。服务端先归拢:说法不同, + // 一项都不写,轮不到对目录。以前只看会写的同伴,销售额照样加粗说「会记为 paid_at」 + const tr = fx.merges.time_rejected + check('夹具:退款率那一边被驳回挡下、带着写到哪,销售额那一边单独看会写,服务端一项都不写', + tr.catalog.refund_rate.b.write === false && tr.catalog.refund_rate.b.table === 'orders' && tr.catalog.gmv.a.write === true + && tr.written.length === 0, JSON.stringify(tr.catalog)) + const rj = await preview('dual-check-approval', { + query: `merge=time_rejected&peer=${tr.refund_rate}`, artifact: cardWith(tr.gmv, { peer: tr.refund_rate }), + }) + await rj.page.waitForSelector('[data-dual-check][data-state="ready"]') + await chooseAll(rj.page, 'time_rejected') + cur = await current(rj.page) + check('……两边加粗的那一句都说说法不同、都不记,销售额不再说「会记为」', ['gmv', 'refund_rate'].every((id) => cur[id]?.conflict + && !cur[id].write && cur[id].inferred && !cur[id].text.includes('会记为')), JSON.stringify(cur)) + check('……退款率被挡下的那一句照归拢的结果说(和销售额说法不同),不照它单独的驳回原因说', + cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', cur.refund_rate?.text) + const rjTail = await previewTail(metric(rj.page, 'gmv')) + check('……结尾换成实际写入以时间线上的记录为准', rjTail.kind === 'inferred' && rjTail.text.endsWith('实际写入以时间线上的记录为准'), rjTail.text) + + // 有效记录条件、同项上有被挡下的指标(夹具 filters_rejected:销售额采纳的条件曾被驳回):挡下它的若是驳回,服务端 + // 拿掉它、退款率照写;若是别的原因(和已有条件合不起来),退款率也一起挡下。卡上认不出是哪一种,退款率那一句只说可能 + const fr = fx.merges.filters_rejected + const fp = await preview('dual-check-approval', { + query: `merge=filters_rejected&peer=${fr.refund_rate}&variant=${fr.gmv}`, artifact: cardWith(fr.gmv, { peer: fr.refund_rate }), + }) + await fp.page.waitForSelector('[data-dual-check][data-state="ready"]') + await chooseAll(fp.page, 'filters_rejected') + cur = await current(fp.page) + check('有效记录条件、同项上有被挡下的:退款率说可能记为自己的条件、也可能不记,不说定了「会记为」', !!cur.refund_rate?.pending + && !cur.refund_rate.write && cur.refund_rate.inferred && cur.refund_rate.text === '采用原流程的结果时,「orders」(shop)的有效记录条件' + + `可能记为「${fr.catalog.refund_rate.a.value}」,也可能不记:要与「销售额」的选择合起来才能确定`, cur.refund_rate?.text) + check('……销售额(被驳回挡下)照它的原因说不会记入,和服务端一致(确实没写)', !!cur.gmv?.reason && !cur.gmv.write + && cur.gmv.text === blockedText(CHOICE_LABEL.a, fr.catalog.gmv.a.reason) + && !fr.written.some((w) => w.value === fr.catalog.gmv.a.value), cur.gmv?.text) + // 窄屏:说法不同的那一句更长,也不撑宽 for (const theme of ['dark', 'light']) { const narrow = await open('time_split', { width: 390, height: 844, theme }) @@ -765,6 +816,10 @@ await section('审批卡:几个指标落到同一项上时,照完整对照 const { page, errors } = await open('time_split') for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(page, id, c) let cur = await current(page) + const exact = await previewTail(metric(page, 'gmv')) + check('照服务端算好的合并结果说:结尾只说审批期间目录有改动时以时间线为准,不说是推断的', exact.kind === 'exact' + && exact.text.includes('审批期间数据目录有改动时') && !exact.text.includes('推断') + && !Object.values(cur).some((c) => c?.inferred), `${exact.kind} / ${exact.text}`) check('分开选:两边都说不会记入、写明给出了不同的说法', ['gmv', 'refund_rate'].every((id) => cur[id]?.reason && cur[id].text.includes('给出了不同的说法') && cur[id].text.includes('都未写入数据目录')), JSON.stringify(cur)) await choose(page, 'refund_rate', 'a') @@ -1043,6 +1098,66 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅 await shot(rail.page, 'approval-rail-dark') }) +await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { + // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」 + // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻) + // 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例 + // verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造 + const RUN = 'run-verify-gaps' + const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'] + const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'] + const OTHER = ['叙述模板渲染为空(路径可能有误)'] + const extra = async (req, url) => { + if (url.pathname === `/api/runs/${RUN}/evidence`) { + return { json: { run_id: RUN, mode: 'none', note: '这次运行没有报告文档', seal: { sealed: true, ok: true, manifest_seq: 7 } } } + } + if (url.pathname === `/api/runs/${RUN}/evidence/audit`) return { status: 404, json: { detail: 'Not Found' } } + return null + } + const items = (els) => els.map((el) => el.textContent.replace(/^·\s*/, '').trim()) + for (const [width, theme] of [[1280, 'dark'], [390, 'dark'], [390, 'light']]) { + const what = `${width}px(${theme})` + const { page, errors } = await preview('verify-gaps', { extra, width, height: 900, theme }) + await page.waitForSelector('[data-evidence-verify-gaps]') + const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-verify-group]')].map((g) => ({ + kind: g.dataset.evidenceVerifyGroup, head: g.firstElementChild?.textContent.trim(), + items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()), + }))) + check(`${what} 证据页:横幅下面另起两组,先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算)`, + JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过', items: FAILED }, + { kind: 'unfinished', head: '核查未完成', items: UNFINISHED }]), JSON.stringify(groups)) + const banner = await page.locator('[data-evidence-issuance] [data-issuance-gaps] li').evaluateAll(items) + check(`${what} ……横幅的「校验未全部完成」里只剩校验没做完的那条`, JSON.stringify(banner) === JSON.stringify(OTHER) + && (await text(page.locator('[data-evidence-issuance] [data-issuance-gaps]'))).startsWith('校验未全部完成'), JSON.stringify(banner)) + // 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的 + const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp') + await stamp.waitFor() + const lines = ((await stamp.getAttribute('title')) ?? '').split('\n') + const at = (head) => lines.findIndex((l) => l.startsWith(head)) + check(`${what} 成果节点印章:悬停说明先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`, + lines[at('核查未通过:')] === `核查未通过:${FAILED.join(';')}` && lines[at('核查未完成:')] === `核查未完成:${UNFINISHED.join(';')}` + && lines[at('校验未全部完成:')] === `校验未全部完成:${OTHER.join(';')}` + && at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验未全部完成:'), lines.join(' / ')) + const w = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`${what}:证据页不横向溢出`, w <= 0, `${w}px`) + const c = await page.evaluate(() => { + const box = document.querySelector('[data-evidence-verify-gaps]') + const bgOf = (el) => { + for (let n = el; n && n !== document.documentElement; n = n.parentElement) { + const v = getComputedStyle(n).backgroundColor + if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v + } + return 'rgba(0, 0, 0, 0)' + } + return { fg: getComputedStyle(box).color, bg: bgOf(box), base: getComputedStyle(document.body).backgroundColor } + }) + const ratio = contrast(c.fg, c.bg, c.base) + check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`) + await shot(page, `verify-gaps-${width}-${theme}`) + check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) + } +}) + await section('记录页的工件列表:双盲复算对照、反方审稿记录有名字', async () => { // 工件列表按 pages/runs/model.ts 的 ARTIFACT_KIND_LABEL 写种类:期 3 新加的两种工件没登记时,列表里露出的是键名 // dual_check、challenge,说明一栏只有一句「工件」。按页面自己加载的那一份取(同 check-evidence 的工件一节)