From ddc95ccbad05041fb1f5109418f9e7634ca0ee77 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 19:15:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=A0=B8=E6=9F=A5=E7=8A=B6=E6=80=81=EF=BC=9A=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E6=AD=A5=E9=AA=A4=E7=9A=84=E5=8F=8C=E7=9B=B2=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=E4=B8=80=E8=A1=8C=EF=BC=8C=E6=95=B4=E5=8F=A5=E7=9A=84?= =?UTF-8?q?=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E5=88=A4=E6=96=AD=EF=BC=8C?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E5=88=97=E7=9A=84=E8=BE=93=E5=85=A5=E5=8F=AA?= =?UTF-8?q?=E5=86=99=E3=80=8C=E5=B7=B2=E9=81=AE=E7=BD=A9=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指标步骤多一行双盲复算的结果:一致(带盲算值)、不一致(带盲算值和审核人选了什么,没选的照实说)、无法复算(带原因)、 已跳过(关闭了、口径卡上没有来自查询的输入、这个指标不在复算范围内)。结果都写成字,颜色只表达要不要多看一眼。 点开一整句时,反方审稿的判断复用审批卡上的展示(run/ChallengeSummary,只读它的导出),只列这一句,点开看反方的理由和查询。 取自遮罩列的输入,标签照查询表格里遮罩格的样子写「已遮罩」,输入来源里核对不一致时只说不一致、不写两边的值。 接口字段的类型就近写在面板里,不动 types.ts;界面文案放 EVIDENCE_TEXT。 check-evidence 加一节:六种双盲状态的文字和颜色、深浅色对比度、没复核过的不画;遮罩输入的标签、代入式、遮罩说明、输入来源; 整句面板的反方审稿判断、降档说明、点开取审稿记录看理由、没审到的句子不画、390px 不横向溢出。 --- frontend/src/lib/terms.ts | 21 ++++ frontend/src/run/EvidencePanel.tsx | 146 +++++++++++++++++++++-- scripts/check-evidence.mjs | 184 +++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index af39ded1..36adf24c 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -473,6 +473,27 @@ export const EVIDENCE_TEXT = { loading: '正在推断来源…', failed: '未能获取推断的来源', }, + + // ---- 期 4:核查状态。指标步骤上的双盲复算、取自遮罩列的输入(反方审稿的判断复用审批卡的 CHALLENGE_TEXT)---- + dualCheck: { + label: '双盲复算', + status: { agreed: '一致', disagreed: '不一致', unchecked: '无法复算', skipped: '已跳过' } as Record, + /** 服务端给了一个界面不认识的状态:照写原值,不归到哪一类 */ + unknown: (status: string) => `未知状态「${status}」`, + /** 盲算的值,服务端按口径卡的格式渲染好的 */ + blind: (value: string) => `盲算 ${value}`, + choice: { a: '审核人采用原流程的结果', b: '审核人采用盲算结果', neither: '审核人判断两边都不对' } as Record, + noChoice: '审核人没有作出选择', + /** 跳过的原因:节点直通(设置或节点里关了、口径卡上没有来自查询的输入),或者这个指标不在复算范围里 */ + skip: { + disabled: '双盲复算已关闭', + no_inputs: '口径卡上没有来自查询的输入', + not_targeted: '这个指标不在复算范围内', + } as Record, + }, + /** 指标的输入取自数据源设置了遮罩的列:标签和代入式里不显示值 */ + maskedInput: '取自设置了遮罩的列,不显示值', + maskedMismatch: '与快照不一致;取自设置了遮罩的列,不显示值', } as const /** diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index 4d8ccc9c..93fc94f6 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -22,10 +22,11 @@ import { ABSENCE_CLAIM_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, NODE_TYPE_LA import { useCatalog } from '../store/catalog' import { askKey, segmentKey, useEvidence, useExplore, useVerdicts } from '../store/evidence' import type { - EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceMerge, EvidenceOnDemand, EvidenceQuoteSource, EvidenceSeal, - EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation, + ApprovalReport, ChallengeCounts, EvidenceBlock, EvidenceDocData, EvidenceInput, EvidenceMerge, EvidenceOnDemand, + EvidenceQuoteSource, EvidenceSeal, EvidenceSegment, EvidenceStep, EvidenceUnit, EvidenceVerdict, EvidenceViolation, } from '../types' import { ArtifactViewer, ResultTable } from './AssistantStream' +import { ChallengeSummary } from './ChallengeSummary' import { useEvidenceHost } from './evidenceHost' import { EvidenceProvenance } from './EvidenceProvenance' import { CopyChip } from './Markdown' @@ -71,11 +72,59 @@ import { * 多少。推演不是出具,不写运行记录、不改报告。勾选框只在推演时出现在查询步骤的结果窗口里,只有这个指标用到的行能勾, * 表格底下带着推演结果。 * + * 核查状态(期 4):指标步骤多一行双盲复算的结果(一致、不一致和审核人选了什么、无法复算、已跳过);点开一整句时 + * 多一块反方审稿的判断,复用审批卡上的展示(run/ChallengeSummary),能点开看反方的理由和查询。取自遮罩列的输入, + * 服务端已把值换成「已遮罩」,标签照样写「已遮罩」,输入来源里只说核对结果、不写值。 + * * 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 * 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。 * 都不是模态的:开着面板照样能在正文里走。 */ +/** + * 期 4:证据接口在指标步骤上给的双盲复算状态(api/evidence.py 的 _dual_check_of)。没有双盲复算复核过这份指标集时 + * 没有这个键。只有证据面板读它,类型就近写在这里 + */ +interface DualCheckState { + status: 'agreed' | 'disagreed' | 'unchecked' | 'skipped' | (string & {}) + node_id?: string + /** 不一致时审核人的选择:a 采用原流程、b 采用盲算、neither 两边都不对。没选的没有 */ + choice?: string + /** 一致、不一致时的盲算值,服务端按口径卡的格式渲染好的 */ + blind_rendered?: string + /** 无法复算:原因(人话);已跳过:disabled / no_inputs / not_targeted */ + reason?: string +} + +/** 期 4:证据接口在句子上给的反方审稿判断(api/evidence.py 的 _challenge_of)。没审到这一句时没有这个键 */ +interface UnitChallenge { + verdict: string + node_id?: string + /** 审稿记录(challenge_artifact)。服务端保存失败时为 null:判断照样有,理由和查询看不了 */ + artifact?: string | null + /** 实际生效的处置:degrade 被推翻的计入出具缺口;flag 只标注 */ + on_refuted?: string + /** 报告撰写节点的名字 */ + report_label?: string +} + +function dualCheckOf(step?: EvidenceStep): DualCheckState | null { + const raw = (step as (EvidenceStep & { dual_check?: unknown }) | undefined)?.dual_check + return raw && typeof raw === 'object' && typeof (raw as DualCheckState).status === 'string' ? raw as DualCheckState : null +} + +function challengeOf(unit: unknown): UnitChallenge | null { + const raw = unit && typeof unit === 'object' ? (unit as { challenge?: unknown }).challenge : undefined + return raw && typeof raw === 'object' && typeof (raw as UnitChallenge).verdict === 'string' && (raw as UnitChallenge).verdict + ? raw as UnitChallenge : null +} + +/** 取自遮罩列的输入:服务端把值换成「已遮罩」,masked 写明是哪几列 */ +function maskedOf(input: EvidenceInput): string[] { + const raw = (input as EvidenceInput & { masked?: unknown }).masked + return Array.isArray(raw) ? raw.filter((c): c is string => typeof c === 'string' && !!c) : [] +} + export type PanelMode = 'side' | 'drawer' | 'inline' | 'dock' export type PanelView = { kind: 'seg'; id: string } | { kind: 'unit'; id: string } | { kind: 'violations' } @@ -537,6 +586,8 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: { const cites = (unit.cites ?? []).map((alias) => ({ alias, label: doc.catalog?.[alias]?.label as string | undefined })) // 讲方法、讲结构的句子:SQL 和字段清单直接列在挂的依据下面。正文不是封存的那份时不列(链是另一份报告的) const basis = useMemo(() => (foreign ? null : claimBasis(unit, doc)), [foreign, unit, doc]) + // 反方审稿对这一句的判断:服务端只认审的就是封存的这一份文档;正文不是那一份时不用 + const challenge = foreign ? null : challengeOf(answered?.unit) return ( <> {foreign && ( @@ -550,6 +601,7 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: { onDemand={foreign ? undefined : answered?.unit?.on_demand} answered={foreign ? undefined : answered?.unit?.verdict} pending={!!runId && !!first && (!slot || slot.status === 'loading')} /> + {challenge && } {cites.length ? (
    @@ -577,6 +629,34 @@ function ClaimBody({ doc, artifact, runId, runClass, unit, block }: { ) } +const CHALLENGE_VERDICTS = ['stands', 'weakened', 'refuted', 'inconclusive'] as const + +/** + * 反方审稿对这一句的判断(期 4)。复用审批卡上的展示(run/ChallengeSummary):只列这一句,点开看反方的理由和它执行的 + * 查询。判断是反方模型给的、不是系统核对,那一块自己写明;正式运行里被推翻、按降档处理的,同样写出具降档 + */ +function ChallengePart({ challenge, unit, report, runId }: { + challenge: UnitChallenge; unit: EvidenceUnit; report?: string; runId?: string +}) { + const reports = useMemo(() => { + const counts: ChallengeCounts = { stands: 0, weakened: 0, refuted: 0, inconclusive: 0 } + const known = CHALLENGE_VERDICTS.find((v) => v === challenge.verdict) + if (known) counts[known] = 1 + return [{ + node_id: report ?? '', label: challenge.report_label || report || '', + challenge: { + counts, items: [{ unit: unit.id, text: unitText(unit).trim(), verdict: challenge.verdict }], + artifact: challenge.artifact ?? null, on_refuted: challenge.on_refuted, + }, + }] + }, [challenge, unit, report]) + return ( +
    + +
    + ) +} + /** * 模型的解释:只对结论句。判定、理由(斜体)、「模型判断 · 模型名 · 非确定」的徽标,封存之后按需追加的 * 另写「封存后追加」;到上限没判的写明哪个上限、到哪里调;改写过一次的写明原句。探索运行里还没判过的 @@ -1168,6 +1248,8 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve // 所依据的查询对照数据目录查出了错误级的问题:值照算,结果不可靠,出具同样按缺口降档。问题和改法在查询步骤里 const sqlFailed = step?.sql_check_failed === true || entry?.sql_check_failed === true const sqlReason = step?.sql_check_reason || entry?.sql_check_reason || EVIDENCE_TEXT.sqlCheckFallback + // 期 4:双盲复算复核过这个指标才有 + const dualCheck = dualCheckOf(step) return ( @@ -1200,6 +1282,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve {upgrade && (

    {upgrade}

    )} + {dualCheck && } {missing &&

    {EVIDENCE_TEXT.missingValue}

    } {incomplete && ( @@ -1255,6 +1338,35 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve ) } +/** + * 这个指标的双盲复算结果(期 4),一行:一致(带盲算值)、不一致(带盲算值和审核人选了什么)、无法复算(带原因)、 + * 已跳过(带原因)。结果都写成字,颜色只表达要不要多看一眼:一致用确定的绿(盲值是代码按原式代回算的、和原值比对的, + * 不是模型的判断);不一致而审核人采用了原流程用提醒色,采用盲算、两边都不对、没有选的用失败色(出具计入缺口); + * 无法复算用提醒色(同样计入缺口);跳过的中性色 + */ +function DualCheckLine({ check }: { check: DualCheckState }) { + const T = EVIDENCE_TEXT.dualCheck + const host = useEvidenceHost() + const { status } = check + const blind = (status === 'agreed' || status === 'disagreed') && check.blind_rendered ? T.blind(check.blind_rendered) : '' + const detail = status === 'disagreed' ? (check.choice ? T.choice[check.choice] ?? check.choice : T.noChoice) + : status === 'skipped' ? (check.reason ? T.skip[check.reason] ?? check.reason : '') + : status === 'unchecked' ? check.reason ?? '' : '' + const color = status === 'agreed' ? 'var(--st-done)' + : status === 'disagreed' ? (check.choice === 'a' ? 'var(--st-waiting)' : 'var(--st-failed)') + : status === 'unchecked' ? 'var(--st-waiting)' : 'var(--text-dim)' + return ( +

    + {T.label}: + {T.status[status] ?? T.unknown(String(status))} + {blind && ({blind})} + {detail && ,{detail}} + {host.onNode && check.node_id && <> } +

    + ) +} + /** * 证据接口逐项复核的结果:eid 能重算、工件哈希复验、重新渲染一致、在封存范围内。 * 全过的时候不说(正常态安静);哪一项明确没过就用失败色写出来——这时候这件证据 @@ -1283,27 +1395,35 @@ function IntegrityList({ code, items }: { code: string; items: string[] }) { function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) { const host = useEvidenceHost() const missing = input.status === 'missing' || input.value == null + // 取自遮罩列(期 4):服务端已把值换成「已遮罩」,这里照查询表格里遮罩格的样子写,读着不像一个真值 + const masked = !missing && maskedOf(input).length > 0 // Agent 交来、被截断切开的数组字段:对它计数、求和只算到了取回的那部分 const cut = !missing && input.truncated === true const node = input.node_id ? host.nodeLabel?.(input.node_id) || input.node_id : '' const via = input.via ? VIA_TEXT[input.via] ?? nodeTypeLabel(input.via) : '' const from = [node && `来自节点「${node}」`, via && `(${via})`, input.role && ` · ${ROLE_TEXT[input.role] ?? input.role}`, - cut && ` · ${EVIDENCE_TEXT.inputTruncated}`].filter(Boolean).join('') + cut && ` · ${EVIDENCE_TEXT.inputTruncated}`, masked && ` · ${EVIDENCE_TEXT.maskedInput}`].filter(Boolean).join('') const style = missing || cut ? { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' } : { color: 'var(--text)' } + const shown = masked ? EVIDENCE_TEXT.masked : valueText(input.value) const body = ( - {valueText(input.value)} ← {input.path ?? NONE}{missing ? '(缺失)' : cut ? '(已截断)' : ''} + + {masked ? {shown} : shown} ← {input.path ?? NONE} + {missing ? '(缺失)' : cut ? '(已截断)' : ''} + ) return (
  • {link ? ( ) : ( - + {body} )} @@ -1333,7 +1453,7 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp
      {inputs.map((inp, i) => { - const said = inputSource(inp) + const said = maskedOf(inp).length ? maskedSource(inp) : inputSource(inp) const tone = TONE[said.tone] const link = linkOf(inp) // 查询编号写报告目录里的全局编号(接口补的 query、对上的查询步骤);agent 字段自己的 ref 是 @@ -1372,6 +1492,16 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp ) } +/** + * 取自遮罩列的输入在「输入来源」里怎么说(期 4):核对结果照说,只是不写值——核对不一致时 inputSource 会把两边的值都写 + * 出来。另附一句为什么不显示值 + */ +function maskedSource(inp: EvidenceInput): { tone: SourceTone; text: string; reason?: string } { + if (inp.status === 'mismatch') return { tone: 'warn', text: EVIDENCE_TEXT.maskedMismatch } + const said = inputSource(inp) + return { ...said, reason: said.reason ? `${said.reason};${EVIDENCE_TEXT.maskedInput}` : EVIDENCE_TEXT.maskedInput } +} + /** * 查询步骤:SQL(带复制)、结果窗口(被引用的行和格高亮)、窗口说明、打开完整快照、复核。 * diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 3da07423..698b3b92 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -3289,6 +3289,190 @@ await section('copy-r2','按后端原文匹配的地方:后端现在的原文 got.suspect.join(' | ')) }) +// ---------------------------------------------------------------- 期 4:核查状态 + +/** + * 双盲复算的几种状态,按片段挂到一期夹具的指标步骤上。形状照服务端(api/evidence.py 的 _dual_check_of):一致、不一致 + * (审核人采用原流程 / 采用盲算 / 没选)、无法复算(带原因)、已跳过(带原因代码) + */ +const DUAL = { + s6: { status: 'agreed', node_id: 'dual', blind_rendered: '45,678.5元' }, + s8: { status: 'disagreed', node_id: 'dual', choice: 'a', blind_rendered: '9.1%' }, + s10: { status: 'disagreed', node_id: 'dual', choice: 'b', blind_rendered: '1,301单' }, + s45: { status: 'disagreed', node_id: 'dual', blind_rendered: '45,900.0元' }, + s12: { status: 'unchecked', node_id: 'dual', reason: '盲算没有交出可用的数:查询全部失败' }, + s23: { status: 'skipped', node_id: 'dual', reason: 'not_targeted' }, +} +/** 虚构的反方审稿记录(challenge_artifact):整句面板里点开那一句,按它取理由 */ +const CH_ART = 'c4'.repeat(32) +const CH_RATIONALE = '扣除退款后新客首单只占增长的一小部分,老客复购反而下降。' + +/** WCAG 对比度:rgb()/rgba()/color(srgb …) 都认 */ +function contrastOf(fg, bg) { + const parse = (c) => { + const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)/) + if (m) return [m[1], m[2], m[3]].map((x) => Number(x) * 255) + return (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number) + } + const lum = (c) => { + const [r, g, b] = parse(c).map((v) => { + const x = v / 255 + return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x) + return (a + 0.05) / (b + 0.05) +} + +await section('verify', '期 4:指标步骤的双盲复算、取自遮罩列的输入、整句的反方审稿判断', async () => { + // ---- 指标步骤多一行双盲复算的结果;没复核过的不画 + const r = await probe((id, body) => { + const metric = body.chain?.find((st) => st.step === 'metric') + if (metric && DUAL[id]) metric.dual_check = DUAL[id] + if (id === 's12' && metric) { + // 服务端遮罩之后的样子:订单数取自遮罩列,标签和代入式里都写「已遮罩」 + metric.substituted = metric.substituted.replace('1234', '已遮罩') + for (const st of body.chain) { + if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] }) + } + body.redacted = { columns: ['order_cnt'], note: '这些列在数据源中设置了遮罩' } + } + return body + }) + const pg = r.page + const p = panel(pg) + const line = p.locator('[data-ev-dual-check]') + for (const [id, status, words, token] of [ + ['s6', 'agreed', ['双盲复算:', '一致', '盲算 45,678.5元'], '--st-done'], + ['s8', 'disagreed', ['不一致', '盲算 9.1%', '审核人采用原流程的结果'], '--st-waiting'], + ['s10', 'disagreed', ['不一致', '盲算 1,301单', '审核人采用盲算结果'], '--st-failed'], + ['s45', 'disagreed', ['不一致', '盲算 45,900.0元', '审核人没有作出选择'], '--st-failed'], + ['s12', 'unchecked', ['无法复算', '查询全部失败'], '--st-waiting'], + ['s23', 'skipped', ['已跳过', '这个指标不在复算范围内'], '--text-dim'], + ]) { + await openSeg(pg, id, '[data-ev-dual-check]') + const text = (await line.innerText().catch(() => '')).replace(/\s+/g, ' ') + const got = await line.getAttribute('data-ev-dual-check').catch(() => null) + const color = await line.locator('span.font-medium').evaluate((el) => getComputedStyle(el).color).catch(() => '') + check(`双盲复算 ${status}(${words.slice(1).join('、') || words[0]}):指标步骤写成一行,颜色按要不要多看一眼`, + got === status && words.every((w) => text.includes(w)) && color === await tokenColor(pg, 'color', `var(${token})`), + `${got}:${text}(${color})`) + await pg.keyboard.press('Escape') + await pg.waitForTimeout(80) + } + for (const theme of ['light', 'dark']) { + await pg.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + let worst = Infinity + for (const id of ['s6', 's10', 's8', 's23']) { + await openSeg(pg, id, '[data-ev-dual-check]') + const pair = await line.evaluate((el) => { + const back = (node) => { + for (let n = node; n; n = n.parentElement) { + const c = getComputedStyle(n).backgroundColor + if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c + } + return getComputedStyle(document.body).backgroundColor + } + return [...el.querySelectorAll('span')].map((sp) => [getComputedStyle(sp).color, back(sp)]) + }).catch(() => []) + for (const [fg, bg] of pair) worst = Math.min(worst, contrastOf(fg, bg)) + await pg.keyboard.press('Escape') + await pg.waitForTimeout(80) + } + check(`${theme}:双盲复算那一行的字对比度都 ≥ 4.5`, worst >= 4.5, worst.toFixed(2)) + } + await pg.evaluate(() => document.documentElement.removeAttribute('data-theme')) + await openSeg(pg, 's49', '[data-ev-substituted]') + check('没有双盲复算复核过的指标:不画这一行', await line.count() === 0) + await pg.keyboard.press('Escape') + + // ---- 取自遮罩列的输入:标签写「已遮罩」(遮罩格的样子),代入式里也是,底下写明遮罩不是安全边界 + await openSeg(pg, 's12', '[data-ev-substituted]') + const masked = p.locator('[data-ev-input="vars.kpi.order_cnt"]') + const chip = (await masked.innerText().catch(() => '')).replace(/\s+/g, ' ') + const italic = await masked.locator('.italic').evaluate((el) => getComputedStyle(el).fontStyle).catch(() => '') + check('遮罩列的输入:标签写「已遮罩 ← 路径」,按遮罩格的样子画,不露出原值', + await masked.getAttribute('data-ev-input-masked') === '' && chip.includes('已遮罩 ← vars.kpi.order_cnt') + && !chip.includes('1,234') && italic === 'italic', `${chip}(${italic})`) + const sub = await p.locator('[data-ev-substituted]').innerText().catch(() => '') + check('遮罩列的输入:代入式里那一处写「已遮罩」', sub === 'round(45678.5 / 已遮罩, 1)', sub) + check('没用到遮罩列的输入照常写值', (await p.locator('[data-ev-input="vars.kpi.gmv"]').innerText()).includes('45,678.5 ← vars.kpi.gmv') + && await p.locator('[data-ev-input="vars.kpi.gmv"]').getAttribute('data-ev-input-masked') === null) + check('面板底下写明遮罩的列和遮罩不是安全边界', (await p.locator('[data-ev-mask-note]').innerText().catch(() => '')).includes('order_cnt')) + await pg.keyboard.press('Escape') + check('核查状态那几页没有运行时报错', r.errors.length === 0, r.errors.join(' | ')) + await r.ctx.close() + + // ---- 输入来源:遮罩列的 Agent 字段核对不一致时只说不一致,不写两边的值 + const q = await probeQ((id, body) => { + if (body.segment?.text !== '37.0元') return body + for (const st of body.chain) { + if (st.step === 'input' && st.path === 'vars.kpi.order_cnt') { + delete st.model_value + Object.assign(st, { value: '已遮罩', masked: ['order_cnt'] }) + } + } + return body + }) + await openQ(q.page, '37.0元', { wait: '[data-ev-sources]' }) + const src = await panel(q.page).locator('[data-ev-source="vars.kpi.order_cnt"]').innerText().catch(() => '') + check('输入来源:遮罩列的字段核对不一致,只说不一致、不写值', src.includes('与快照不一致;取自设置了遮罩的列,不显示值') + && !src.includes('1,240') && !src.includes('1,234'), src.replace(/\s+/g, ' ')) + const verified = await panel(q.page).locator('[data-ev-source="vars.kpi.gmv"]').innerText().catch(() => '') + check('输入来源:没遮罩的字段照旧', verified.includes('与快照一致') && !verified.includes('遮罩'), verified.replace(/\s+/g, ' ')) + await q.ctx.close() + + // ---- 整句面板:反方审稿的判断,复用审批卡的展示,点开看理由;没审到的句子不画 + const u = fxj.formal.units + const challenge = { verdict: 'refuted', node_id: 'chal', artifact: CH_ART, on_refuted: 'degrade', report_label: '写周报' } + const segPatch = (jset, body) => { + if (jset === fxj.formal && body.unit?.id === u.bad) body.unit.challenge = challenge + return body + } + for (const [w, h] of [[1280, 1000], [390, 844]]) { + const c = await open('/ui-harness.html?evidence=1', { segPatch, w, h }) + const artifactHits = [] + await c.page.route((url) => new URL(url).pathname === `/api/artifacts/${CH_ART}`, (route) => { + artifactHits.push(route.request().url()) + return route.fulfill({ json: { id: CH_ART, content: { + report: 'write', doc_artifact: fxj.formal.doc_artifact, model: 'challenger-x', + claims: [{ unit: u.bad, text: '增长主要来自新客首单,老客复购持平。', verdict: 'refuted', rationale: CH_RATIONALE, + rationale_doc: null, queries: [] }], + } } }) + }) + const cp = panel(c.page) + const block = cp.locator('[data-ev-challenge]') + await badge(c.page, J, u.bad).click() + await c.page.waitForSelector('[data-evidence-panel] [data-ev-challenge] [data-challenge-item]', { timeout: 4000 }).catch(() => {}) + const text = (await block.innerText().catch(() => '')).replace(/\s+/g, ' ') + if (w === 1280) { + check('整句面板:反方审稿的判断(被推翻)挂在这一句下,写明是哪份报告的反方审稿', await block.getAttribute('data-ev-challenge') === 'refuted' + && text.includes('报告「写周报」的反方审稿') && text.includes('被推翻') && text.includes('新客首单'), text) + check('……判断由反方模型给出,并非系统核对', text.includes('并非系统核对'), text) + check('……正式运行、按降档处理:写明出具会降档', await block.locator('[data-challenge-degrade]').first() + .waitFor({ timeout: 3000 }).then(() => true, () => false)) + await block.locator('[data-challenge-toggle]').click() + await c.page.waitForSelector('[data-evidence-panel] [data-challenge-detail-state="ok"]', { timeout: 4000 }).catch(() => {}) + const why = await block.locator('[data-challenge-rationale]').innerText().catch(() => '') + check('……点开看得到反方的理由(按审稿记录取)', why.includes(CH_RATIONALE) && artifactHits.length === 1, why) + await badge(c.page, J, u.ok).click() + await c.page.waitForSelector('[data-evidence-panel] [data-ev-judge="supported"]', { timeout: 4000 }).catch(() => {}) + await c.page.waitForTimeout(200) + check('没审到的句子:不画反方审稿', await block.count() === 0) + } else { + const wide = await c.page.evaluate(() => { + const el = document.querySelector('[data-evidence-panel]') + return { page: document.documentElement.scrollWidth - document.documentElement.clientWidth, + panel: el ? el.scrollWidth - el.clientWidth : 99 } + }) + check('390px:整句面板带着反方审稿不横向溢出', !!text && wide.page <= 0 && wide.panel <= 1, JSON.stringify(wide)) + } + check(`${w}px:没有运行时报错`, c.errors.length === 0, c.errors.join(' | ')) + await c.ctx.close() + } +}) + check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | ')) await harness.ctx.close() await browser.close()