From a25fed002e81778c925de85c34af5284be8d7527 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:02:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=85=B7=E6=A8=AA=E5=B9=85=E6=8A=8A?= =?UTF-8?q?=E6=A0=B8=E6=9F=A5=E7=BC=BA=E5=8F=A3=E5=88=86=E7=BB=84=EF=BC=8C?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=8D=A1=E6=8A=8A=E8=BF=90=E8=A1=8C=E7=B1=BB?= =?UTF-8?q?=E5=88=AB=E4=BA=A4=E7=BB=99=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=EF=BC=9AIssuanceBanner=EF=BC=88=E8=BF=90=E8=A1=8C=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=88=90=E6=9E=9C=E5=8C=BA=E3=80=81=E9=97=AE=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=9B=9E=E7=AD=94=E3=80=81=E8=AF=81=E6=8D=AE=E9=A1=B5?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=B8=AA=EF=BC=89=E6=8A=8A=E5=8F=8C=E7=9B=B2?= =?UTF-8?q?=E5=A4=8D=E7=AE=97=E3=80=81=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=E6=8A=A5=E4=B8=8A=E6=9D=A5=E7=9A=84=E7=BC=BA=E5=8F=A3=E5=8F=A6?= =?UTF-8?q?=E8=B5=B7=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=8D=E3=80=8C=E6=A0=B8=E6=9F=A5=E6=9C=AA=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E3=80=8D=E4=B8=A4=E7=BB=84=EF=BC=8C=E6=8E=92=E5=9C=A8=E3=80=8C?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=9C=AA=E5=85=A8=E9=83=A8=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E3=80=8D=E4=B8=8A=E9=9D=A2=EF=BC=8C=E5=85=88=E5=90=8E=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF=E5=92=8C=E6=88=90=E6=9E=9C=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=8D=B0=E7=AB=A0=EF=BC=9B=E8=AF=81=E6=8D=AE=E9=A1=B5?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=8F=A6=E5=88=97=E4=B8=80=E9=81=8D=E7=9A=84?= =?UTF-8?q?=20IssuanceWithChecks=20=E5=8C=85=E8=A3=85=EF=BC=8C=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E7=94=A8=E6=A8=AA=E5=B9=85=E3=80=82RunPanel=20?= =?UTF-8?q?=E7=9A=84=E5=AE=A1=E6=89=B9=E5=8D=A1=E6=8A=8A=20approval.run=5F?= =?UTF-8?q?class=20=E4=BC=A0=E7=BB=99=20ChallengeSummary=EF=BC=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=9A=84=20GET=20=E5=A4=B1=E8=B4=A5=E4=B9=9F=E7=85=A7?= =?UTF-8?q?=E6=A0=B7=E5=86=99=E3=80=8C=E5=87=BA=E5=85=B7=E4=BC=9A=E9=99=8D?= =?UTF-8?q?=E6=A1=A3=E3=80=8D=E3=80=81=E4=B8=8D=E5=8F=A6=E5=8F=96=E3=80=82?= =?UTF-8?q?check-stream=20=E8=A1=A5=E6=A8=AA=E5=B9=85=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=9A=84=E6=96=AD=E8=A8=80=EF=BC=88=E6=96=B0=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=20issued-verify=EF=BC=89=EF=BC=8Ccheck-verif?= =?UTF-8?q?y=20=E7=9A=84=20verify-gaps=20=E6=94=B9=E8=AF=BB=E6=A8=AA?= =?UTF-8?q?=E5=B9=85=E9=87=8C=E7=9A=84=E6=96=B0=E7=BB=84=EF=BC=8Ccheck-cha?= =?UTF-8?q?llenge=20=E8=A1=A5=E6=95=B4=E5=BC=A0=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E5=B8=A6=E8=BF=90=E8=A1=8C=E7=B1=BB=E5=88=AB=E7=9A=84=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/dev/fixtures/challenge.tsx | 16 +++++--- frontend/src/dev/fixtures/issuance.tsx | 52 ++++++++++++++++++++++++ frontend/src/pages/runs/EvidencePane.tsx | 44 ++++---------------- frontend/src/run/AssistantStream.tsx | 33 +++++++++++++-- frontend/src/run/RunPanel.tsx | 2 +- scripts/check-challenge.mjs | 37 +++++++++++++---- scripts/check-stream.mjs | 41 +++++++++++++++++++ scripts/check-verify.mjs | 42 +++++++++++-------- 8 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 frontend/src/dev/fixtures/issuance.tsx diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index 5e8a4ffe..eb00d9d2 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -31,8 +31,9 @@ import fx from './challenge.json' * 卡上按「无法判断」的句数说,两个数不打架 * - ?syn=challenge-approval-offpath:正式运行,按降档处置,但反方审稿不在带出具契约的成果节点上游(degrades 为假) * - ?syn=challenge-summary-given:反方审稿那一块单独画,审批自带的运行类别(正式运行)由调用方传进来;检查脚本让这次运行的 - * GET 失败,卡上照样写「出具会降档」,也不另发这次请求。审批卡(RunPanel 的 ApprovalCard)把 approval.run_class - * 传给它之后,卡上走的就是这条路 + * GET 失败(500),卡上照样写「出具会降档」,也不另发这次请求 + * - ?syn=challenge-approval-given:同一次运行(GET 同样 500),整张审批卡(RunPanel 的 ApprovalCard):审批带着 + * run_class,卡把它传给反方审稿那一块,照它说出具、不另取 * - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成) * - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null) * @@ -68,9 +69,8 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str } const RUNS = fx.runs -/** 期 4 起服务端另带的两项:unreviewed(没能反查的句数)、degrades(被推翻的会不会让出具降档) */ -type Challenge = ApprovalChallenge & { unreviewed?: number; degrades?: boolean } -const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } as ApprovalChallenge }) +/** 期 4 起服务端另带的两项(unreviewed 没能反查的句数、degrades 被推翻的会不会让出具降档)类型里都有,按需盖上 */ +const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } }) const many = fx.many as unknown as ApprovalChallenge /** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */ const clear: Partial = { @@ -82,7 +82,7 @@ const clear: Partial = { * 记为无法判断,unreviewed 不会多于 inconclusive)。原来无法判断的 u11 是查过了、数据不够,不算未能反查 */ const UNREVIEWED_UNITS = ['u5', 'u8'] -const unreviewed: Partial = { +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), @@ -245,6 +245,10 @@ export const cases: Record JSX.Element> = { ), + 'challenge-approval-given': () => ( + + ), 'challenge-approval-offpath': () => ( diff --git a/frontend/src/dev/fixtures/issuance.tsx b/frontend/src/dev/fixtures/issuance.tsx new file mode 100644 index 00000000..522e1145 --- /dev/null +++ b/frontend/src/dev/fixtures/issuance.tsx @@ -0,0 +1,52 @@ +import type { JSX } from 'react' +import { AssistantStream, type StreamTurn } from '../../run/AssistantStream' +import { decodeRun } from '../../run/decode' +import type { RunEvent } from '../../types' + +/** + * 出具横幅里的核查缺口(运行面板成果区、问数据回答用的 run/AssistantStream 的 IssuanceBanner),scripts/check-stream.mjs + * 按它检查: + * - ?syn=issued-verify:正式运行降档出具。缺口里有一条没通过 SQL 检查的(横幅单列)、双盲复算和反方审稿报上来的几条 + * (两路结果不一致、结论被推翻、指标无法复算),和一条校验没做完的;先后照服务端(出具契约按上游节点的先后收),核查的和 + * 别的混在一起。横幅按「核查未通过」「核查未完成」「校验未全部完成」分组,先后同时间线上出具那一行 + * + * 门店、指标、数字都是虚构的。缺口原话照服务端的写法(出具契约给上游节点的缺口加「节点类型「节点名」:」的前缀) + */ +export const ISSUED_VERIFY = { + run: 'syn-issued-verify', + sql: '查询「取门店流水」(Q1)未通过 SQL 检查(「门店」关联「流水明细」是一对多,对「门店」的「面积」求和会重复计算),结果不可靠;受影响的引用:指标「坪效」', + failed: ['双盲复算「复核月报口径」:指标「客单价」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「西区客流回升主要来自会员日」'], + unfinished: ['双盲复算「复核月报口径」:指标「复购率」无法复算:输入来自合并查询,无法单独复算'], + other: ['叙述模板渲染为空(路径可能有误)'], +} + +const V = ISSUED_VERIFY +const ISSUANCE = { + tier: 'degraded', matched_numbers: 4, metrics_checked: 3, missing_required: [], missing_expected: [], unmatched_numbers: [], + calibers: [], + gaps: [V.failed[0], V.sql, V.other[0], V.unfinished[0], V.failed[1]], + sql_checks: [{ query: '查询「取门店流水」(Q1)', node_id: 'fetch', problems: ['「门店」关联「流水明细」是一对多,对「门店」的「面积」求和会重复计算'], + refs: ['指标「坪效」'], gap: V.sql }], +} +const OUTPUT = { 月报: '上月各门店客单价 86.4 元,坪效 1,240 元。', _issuance: ISSUANCE } + +function verifyTurn(): StreamTurn { + const at = Date.now() / 1000 - 30 + const events: RunEvent[] = [ + { seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 1 } }, + { seq: 2, type: 'node.started', node_id: 'done', ts: at + 1, data: {} }, + { seq: 3, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { ...ISSUANCE, unmatched: 0 } }, + { seq: 4, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 300 } }, + { seq: 5, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output: OUTPUT, duration_ms: 1400 } }, + ] + return { + id: 'issued-verify', question: '上月门店经营月报', phase: 'done', status: '已完成', + steps: decodeRun(events, { status: 'succeeded' }), output: OUTPUT, runId: V.run, runClass: 'formal', + } +} + +export const cases: Record JSX.Element> = { + 'issued-verify': () => ( +
+ ), +} diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 67c2a952..ac3dffee 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -12,9 +12,8 @@ import { issuanceMarks, segmentState, unitVerdict, verdictState, type AuditFilter, type AuditGroup, type AuditRow, } from '../../lib/evidence' import { formatNumber } from '../../lib/format' -import { DUAL_CHECK_TEXT, EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' +import { 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 { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry' @@ -150,8 +149,13 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { reports={graph.reports ?? []} labelOf={labelOf} /> {/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的 - 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */} - {!!output?._issuance?.tier && } + 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块,双盲复算、反方审稿报上来的 + 缺口分「核查未通过」「核查未完成」两组(和问数据、画布右栏同一个横幅) */} + {!!output?._issuance?.tier && ( +
+ +
+ )} {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -322,38 +326,6 @@ function useAudit(runId: string, key: string): AuditState { return state } -/** - * 出具横幅,外加双盲复算、反方审稿报上来的缺口另起的两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」 - * (指标无法复算)。横幅把缺口都列在「校验未全部完成」下,读起来像是校验没做完:这里从横幅里拿出来,紧跟在它下面单列, - * 颜色和横幅里的缺口一样(分组见 run/decode.ts 的 splitIssuanceGaps)。导出给预览用例(dev/fixtures/dual_check.tsx) - */ -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 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 && ( -
- {group('failed', failed)} - {group('unfinished', unfinished)} -
- )} -
- ) -} - /** 顶上一行:这次运行按哪种方式展示证据、封存核对的结果、每份报告文档的哈希 */ function SummaryBar({ mode, seal, message, reports, labelOf }: { mode?: string diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index ecdc7eb0..e229f9a6 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -16,11 +16,12 @@ import { explainRunError } from '../lib/explain' import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format' import { statusLabel } from '../lib/status' import { - COPILOT_CONTEXT_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel, + COPILOT_CONTEXT_TEXT, DUAL_CHECK_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, + nodeTypeLabel, } from '../lib/terms' import { SqlCheckList } from './SqlChecks' import { - ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf, + ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, splitIssuanceGaps, spread, teamVerdictOf, type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx, type TeamRun, } from './decode' @@ -2296,6 +2297,22 @@ export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: Issu ) } +/** + * 出具横幅里双盲复算、反方审稿报上来的一组缺口:failed「核查未通过」、unfinished「核查未完成」。排在「校验未全部完成」 + * 上面,样子和它一样(同一种提醒色、小标题加圆点列表),一条都没有时不画 + */ +function IssuanceGaps({ kind, title, gaps }: { kind: 'failed' | 'unfinished'; title: string; gaps: string[] }) { + if (!gaps.length) return null + return ( +
+
{title}:
+
    + {gaps.map((g) =>
  • · {g}
  • )} +
+
+ ) +} + const TIER_META: Record = { formal: { color: 'var(--st-done)', soft: 'var(--st-done-soft)', hint: ISSUANCE_HINT.formal }, degraded: { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: ISSUANCE_HINT.degraded }, @@ -2312,7 +2329,9 @@ const TIER_META: Record = * * 降档的原因必须写在这里:以前只列缺指标和回指不上的数字,而「叙述模板渲染 * 为空」「指标集为空」这类校验根本没跑起来的缺口(gaps)一条都不显示——横幅 - * 说「请对照下方声明」,下方什么都没有。 + * 说「请对照下方声明」,下方什么都没有。缺口分几块:没通过 SQL 检查的单列在最前, + * 双盲复算、反方审稿报上来的分「核查未通过」「核查未完成」,其余的归「校验未全部完成」。 + * 运行详情的证据页、问数据的回答、画布右栏用的都是这一个横幅,三处说法一样。 */ export function IssuanceBanner({ issuance, runClass, evidence }: { issuance: any; runClass?: string @@ -2331,7 +2350,11 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { const sqlChecks: IssuanceSqlCheck[] = (Array.isArray(issuance?.sql_checks) ? issuance.sql_checks : []) .filter((x: unknown): x is IssuanceSqlCheck => !!x && typeof x === 'object') const sqlGaps = new Set(sqlChecks.map((x) => x.gap).filter(Boolean)) - const gaps: string[] = (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g)) + // 其余缺口里双盲复算、反方审稿报上来的另起两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」(指标无法 + // 复算),排在「校验未全部完成」上面,先后同时间线、证据页、成果节点的印章(分组见 decode.ts 的 splitIssuanceGaps)。 + // 不分的话核查查出的问题读着像校验没做完,没核成的数又像被查出了错 + const { failed: verifyFailed, unfinished: verifyUnfinished, other: gaps } = splitIssuanceGaps( + (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g))) const calibers: any[] = issuance?.calibers ?? [] const matched = Number(issuance?.matched_numbers ?? 0) // 逐个数字的出处(matched[].caliber 形如「口径名 @ v2」):有它就能按卡数清楚, @@ -2404,6 +2427,8 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { )} {!!sqlChecks.length && } + + {!!gaps.length && (
{RUN_SQL_CHECK_TEXT.otherGaps}
diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index 67239c38..96d0e51e 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -157,7 +157,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { - + {payload.tool && (
diff --git a/scripts/check-challenge.mjs b/scripts/check-challenge.mjs index ac34e417..01ad15b3 100644 --- a/scripts/check-challenge.mjs +++ b/scripts/check-challenge.mjs @@ -3,7 +3,8 @@ // // 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、 // 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式 -// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了)、 +// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了; +// 整张审批卡得把 approval.run_class 交给反方审稿那一块)、 // 反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、说明里用「本节点」(画在审批卡上,读着像审批节点)、 // 反查没完成的几句混在「无法判断」里看不出来(或者比「无法判断」还多、插在降档标记和它的说明中间)、 // 没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、 @@ -43,7 +44,7 @@ const DUP_CONTENT = { claims: fx.artifacts[MANY].claims.map((c) => (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)), } const RUNS = fx.runs -// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用 +// RUNS.given 不在这里:那次运行的 GET 一律答 500,审批带着的运行类别得照样管用 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', @@ -178,6 +179,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { const id = decodeURIComponent(run[1]) hits.runs.push(id) if (id === RUNS.runfail && state.runFail) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } }) + if (id === RUNS.given) return route.fulfill({ status: 500, json: { detail: '服务暂时不可用' } }) return RUN_CLASS[id] ? route.fulfill({ json: { id, run_class: RUN_CLASS[id], status: 'interrupted' } }) : route.fulfill({ status: 404, json: { detail: '运行不存在' } }) } @@ -193,9 +195,12 @@ const shot = async (page, name) => { mkdirSync(SHOTS, { recursive: true }) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) } -/** 等运行类别答完、界面画上:否定的断言(不写降档)得在这之后做 */ -async function runClassSettled(page, hits, runId) { - for (let i = 0; i < 50 && !hits.runs.includes(runId); i++) await page.waitForTimeout(100) +/** + * 等界面画定:否定的断言(不写降档)得在这之后做。审批带着运行类别时卡上照它说、不另取,画上就是定了;老服务端的审批 + * 不带运行类别(fetches 为真),要等卡上按运行 id 取的那一次答完 + */ +async function runClassSettled(page, hits, runId, { fetches = false } = {}) { + if (fetches) for (let i = 0; i < 50 && !hits.runs.includes(runId); i++) await page.waitForTimeout(100) await page.waitForTimeout(400) } const item = (page, unit) => page.locator(`[data-challenge-item="${unit}"]`) @@ -232,7 +237,7 @@ await section('审批卡:概况与降档提示', async () => { check('每句是一个收起的按钮', await block.locator('[data-challenge-toggle][aria-expanded="false"]').count() === 4) check('没点开之前也说清判断出自反方模型', (await text(block.locator('[data-challenge-judged]'))) === '判断由反方模型给出,并非系统核对') check('没点开之前不取工件', hits.artifacts.length === 0, hits.artifacts.join('、')) - check('运行类别按运行 id 取了一次', hits.runs.filter((r) => r === RUNS.formal).length === 1, hits.runs.join('、')) + check('审批带着运行类别:照它说,不另取', !hits.runs.includes(RUNS.formal), hits.runs.join('、')) await shot(page, 'approval-dark') check('页面没有报错', errors.length === 0, errors.join(';')) await ctx.close() @@ -377,7 +382,7 @@ await section('运行类别取不到:不说任何和出具有关的话,重 const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail') const block = page.locator('[data-challenge="write"]') await block.waitFor() - await runClassSettled(page, hits, RUNS.runfail) + await runClassSettled(page, hits, RUNS.runfail, { fetches: true }) const quiet = async () => { const all = await text(block) return await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-degrade-off], ' @@ -406,7 +411,7 @@ await section('运行类别取不到:不说任何和出具有关的话,重 await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => { // 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里 // 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的 - // GET 一律答 404 + // GET 一律答 500 const { ctx, page, hits, errors } = await open('challenge-summary-given') const block = page.locator('[data-challenge="write"]') await block.waitFor() @@ -418,6 +423,22 @@ await section('审批带着运行类别:照它说出具,不另取(另取 await ctx.close() }) +await section('整张审批卡:把审批带着的运行类别交给反方审稿那一块,运行的 GET 失败也照样写降档、不另取', async () => { + // RunPanel 的 ApprovalCard 得把 approval.run_class 传给 ChallengeSummary(同 ApprovalReports)。不传的话那一块按运行 id + // 另取一次:这里那次 GET 一律答 500,正式运行里「出具会降档」整块消失,卡顶却照样写着正式运行 + const { ctx, page, hits, errors } = await open('challenge-approval-given') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + await page.waitForTimeout(500) + check('卡顶照审批写正式运行', await page.locator('[data-approval-run-class="formal"]').count() === 1) + const degrade = page.locator('[data-challenge-degrade]') + check('……反方审稿那一块照样写「出具会降档」和原因', await degrade.count() === 1 && (await text(degrade)) === '出具会降档' + && (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block)) + check('……不另取这次运行的类别(那次 GET 答 500)', !hits.runs.includes(RUNS.given), hits.runs.join('、') || '一次都没取') + check('……页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => { // 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断 // 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它 diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 651db9af..aae71d98 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -1411,6 +1411,47 @@ await section('放弃、结构化的报错、恢复的轮次、出具横幅(RE await one.page.close() }) +await section('出具横幅:核查报上来的缺口另起「核查未通过」「核查未完成」,不归在「校验未全部完成」下,先后同时间线', async () => { + // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前全列在「校验未全部 + // 完成」下:两路结果不一致、结论被推翻读着像校验没做完。时间线、证据页、成果节点的印章早就分成「核查未通过」「核查未 + // 完成」「校验未全部完成」三组(印章的悬停说明最后一行还指向成果区),运行面板成果区、问数据回答的横幅得同样分、同样的 + // 先后。用例 issued-verify(dev/fixtures/issuance.tsx):缺口的先后照服务端,核查的和别的混在一起,另有一条没通过 + // SQL 检查的(横幅单列) + for (const [w, label] of [[1100, '宽栏'], [390, '390px']]) { + const { page, errors } = await open('syn=issued-verify', { w, h: 900, name: w === 1100 ? 'issued-verify' : undefined }) + const V = await page.evaluate(async () => (await import('/src/dev/fixtures/issuance.tsx')).ISSUED_VERIFY) + const banner = page.locator('[data-issuance-banner=degraded]') + await banner.waitFor() + const blocks = await banner.evaluate((el) => [...el.querySelectorAll('[data-issuance-verify-gaps], [data-issuance-gaps]')] + .map((g) => ({ + kind: g.getAttribute('data-issuance-verify-gaps') ?? 'other', head: g.firstElementChild?.textContent.trim(), + items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()), + }))) + const want = [{ kind: 'failed', head: '核查未通过:', items: V.failed }, { kind: 'unfinished', head: '核查未完成:', items: V.unfinished }, + { kind: 'other', head: '校验未全部完成:', items: V.other }] + check(`${label}:横幅里先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`, + JSON.stringify(blocks) === JSON.stringify(want), JSON.stringify(blocks)) + const rest = (await banner.locator('[data-issuance-gaps]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check(`${label}:……核查报上来的缺口一条都不在「校验未全部完成」下`, + ![...V.failed, ...V.unfinished].some((g) => rest.includes(g)) && !/双盲复算|反方审稿/.test(rest), rest) + const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check(`${label}:……没通过 SQL 检查的那条照旧单列在最前,不在三组里重复`, sql.includes('查询「取门店流水」(Q1)') + && !blocks.some((b) => b.items.includes(V.sql)), sql) + // 时间线上出具那一行:展开后按同样的先后列三组(时间线把「校验未全部完成」那一组叫「校验不完整」) + const row = page.locator('[data-turn] [data-step-status]').filter({ hasText: '降档出具' }).first() + await row.locator('button').first().click() + await page.waitForTimeout(150) + const line = (await row.innerText()).replace(/\s+/g, ' ') + const at = (s) => line.indexOf(s) + check(`${label}:……三组的先后和时间线上出具那一行一致`, at('核查未通过') >= 0 && at('核查未通过') < at('核查未完成') + && at('核查未完成') < at('校验不完整'), line) + const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`${label}:页面不横向溢出`, over <= 0, `${over}px`) + check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | ')) + await page.close() + } +}) + await section('吸顶的轮次头贴着顶边(REQ-8)', async () => { for (const [q, w, h, label] of [['syn=mixed', 1024, 768, '宽栏 1024'], ['syn=mixed', 1180, 800, '宽栏 1180'], ['syn=mixed&dense=1', 380, 700, '窄栏']]) { diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7cb95b43..53fd0c73 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -20,8 +20,8 @@ // 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的 // 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目 // 标出,老工件照旧纯文本; -// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」 -// 「核查未完成」两组,不放在「校验未全部完成」下; +// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的 +// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下; // - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是 @@ -1098,11 +1098,12 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅 await shot(rail.page, 'approval-rail-dark') }) -await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { +await section('出具缺口:证据页的出具横幅、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => { // 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」 - // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻) - // 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例 - // verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造 + // 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)的出具横幅里另起「核查未通过」(两路不一致、结论被推翻) + // 「核查未完成」(无法复算)两组,排在「校验未全部完成」上面,那一组只留校验没做完的;横幅外面不再另列一遍。成果节点 + // 印章的悬停说明同样分组、同样的先后。用例 verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和 + // 别的混在一起;证据图、审计接口在这里伪造 const RUN = 'run-verify-gaps' const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'] const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算'] @@ -1118,17 +1119,24 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 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(), + await page.waitForSelector('[data-evidence-issuance] [data-issuance-banner] [data-issuance-verify-gaps]') + const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-issuance] [data-issuance-banner] ' + + ':is([data-issuance-verify-gaps], [data-issuance-gaps])')].map((g) => ({ + kind: g.dataset.issuanceVerifyGaps ?? 'other', 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)) + check(`${what} 证据页:出具横幅里先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算),最后「校验未全部完成」`, + JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过:', items: FAILED }, + { kind: 'unfinished', head: '核查未完成:', items: UNFINISHED }, { kind: 'other', head: '校验未全部完成:', items: OTHER }]), + 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 outside = await page.evaluate((all) => [...document.querySelectorAll('[data-evidence-issuance] li')] + .filter((li) => !li.closest('[data-issuance-banner]') && all.some((g) => li.textContent.includes(g))).length, + [...FAILED, ...UNFINISHED]) + check(`${what} ……横幅外面不再另列一遍核查的缺口`, outside === 0 && await page.locator('[data-evidence-verify-gaps]').count() === 0, + `${outside} 条`) // 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的 const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp') await stamp.waitFor() @@ -1140,8 +1148,7 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 && 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 cs = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-issuance] [data-issuance-verify-gaps]')].map((box) => { const bgOf = (el) => { for (let n = el; n && n !== document.documentElement; n = n.parentElement) { const v = getComputedStyle(n).backgroundColor @@ -1150,9 +1157,10 @@ await section('出具缺口:证据页、成果节点的印章把核查报上 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})`) + })) + const ratios = cs.map((c) => contrast(c.fg, c.bg, c.base)) + check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratios.length === 2 && ratios.every((r) => r >= 4.5), + cs.map((c, i) => `${ratios[i].toFixed(2)}(${c.fg} on ${c.bg})`).join(';')) await shot(page, `verify-gaps-${width}-${theme}`) check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) }