证据面板显示核查状态:指标步骤的双盲复算一行,整句的反方审稿判断,遮罩列的输入只写「已遮罩」

指标步骤多一行双盲复算的结果:一致(带盲算值)、不一致(带盲算值和审核人选了什么,没选的照实说)、无法复算(带原因)、
已跳过(关闭了、口径卡上没有来自查询的输入、这个指标不在复算范围内)。结果都写成字,颜色只表达要不要多看一眼。
点开一整句时,反方审稿的判断复用审批卡上的展示(run/ChallengeSummary,只读它的导出),只列这一句,点开看反方的理由和查询。
取自遮罩列的输入,标签照查询表格里遮罩格的样子写「已遮罩」,输入来源里核对不一致时只说不一致、不写两边的值。
接口字段的类型就近写在面板里,不动 types.ts;界面文案放 EVIDENCE_TEXT。

check-evidence 加一节:六种双盲状态的文字和颜色、深浅色对比度、没复核过的不画;遮罩输入的标签、代入式、遮罩说明、输入来源;
整句面板的反方审稿判断、降档说明、点开取审稿记录看理由、没审到的句子不画、390px 不横向溢出。
This commit is contained in:
ThearchyHelios
2026-10-05 19:15:14 +08:00
parent 59f1868dac
commit ddc95ccbad
3 changed files with 343 additions and 8 deletions
+21
View File
@@ -473,6 +473,27 @@ export const EVIDENCE_TEXT = {
loading: '正在推断来源…',
failed: '未能获取推断的来源',
},
// ---- 期 4:核查状态。指标步骤上的双盲复算、取自遮罩列的输入(反方审稿的判断复用审批卡的 CHALLENGE_TEXT)----
dualCheck: {
label: '双盲复算',
status: { agreed: '一致', disagreed: '不一致', unchecked: '无法复算', skipped: '已跳过' } as Record<string, string>,
/** 服务端给了一个界面不认识的状态:照写原值,不归到哪一类 */
unknown: (status: string) => `未知状态「${status}」`,
/** 盲算的值,服务端按口径卡的格式渲染好的 */
blind: (value: string) => `盲算 ${value}`,
choice: { a: '审核人采用原流程的结果', b: '审核人采用盲算结果', neither: '审核人判断两边都不对' } as Record<string, string>,
noChoice: '审核人没有作出选择',
/** 跳过的原因:节点直通(设置或节点里关了、口径卡上没有来自查询的输入),或者这个指标不在复算范围里 */
skip: {
disabled: '双盲复算已关闭',
no_inputs: '口径卡上没有来自查询的输入',
not_targeted: '这个指标不在复算范围内',
} as Record<string, string>,
},
/** 指标的输入取自数据源设置了遮罩的列:标签和代入式里不显示值 */
maskedInput: '取自设置了遮罩的列,不显示值',
maskedMismatch: '与快照不一致;取自设置了遮罩的列,不显示值',
} as const
/**
+138 -8
View File
@@ -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 && <ChallengePart challenge={challenge} unit={unit} report={report} runId={runId} />}
<Part title={JUDGE_TEXT.cites} data-ev-cites="">
{cites.length ? (
<ul className="space-y-0.5">
@@ -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<ApprovalReport[]>(() => {
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 (
<div data-ev-challenge={challenge.verdict}>
<ChallengeSummary reports={reports} runId={runId ?? ''} />
</div>
)
}
/**
* 模型的解释:只对结论句。判定、理由(斜体)、「模型判断 · 模型名 · 非确定」的徽标,封存之后按需追加的
* 另写「封存后追加」;到上限没判的写明哪个上限、到哪里调;改写过一次的写明原句。探索运行里还没判过的
@@ -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 (
<Part title={EVIDENCE_TEXT.metric} data-ev-metric="">
@@ -1200,6 +1282,7 @@ function MetricPart({ step, entry, inputs, runId, seal, pending, failed, resolve
{upgrade && (
<p className="mt-0.5 text-2xs" style={{ color: 'var(--st-waiting)' }} data-ev-caliber-upgrade="">{upgrade}</p>
)}
{dualCheck && <DualCheckLine check={dualCheck} />}
<Integrity step={step} seal={seal} />
{missing && <p className="mt-1" style={{ color: 'var(--st-waiting)' }} data-ev-missing="">{EVIDENCE_TEXT.missingValue}</p>}
{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 (
<p className="mt-1 text-2xs leading-relaxed [overflow-wrap:anywhere]" data-ev-dual-check={status}
data-ev-dual-check-choice={status === 'disagreed' ? check.choice ?? 'none' : undefined}>
<span className="text-faint">{T.label}:</span>
<span className="font-medium" style={{ color }}>{T.status[status] ?? T.unknown(String(status))}</span>
{blind && <span className="tnum text-dim">({blind})</span>}
{detail && <span className="text-dim">,{detail}</span>}
{host.onNode && check.node_id && <> <NodeChip id={check.node_id} /></>}
</p>
)
}
/**
* 证据接口逐项复核的结果: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 = (
<span className="truncate">{valueText(input.value)} ← {input.path ?? NONE}{missing ? '(缺失)' : cut ? '(已截断)' : ''}</span>
<span className="truncate">
{masked ? <span className="font-sans italic text-faint">{shown}</span> : shown} ← {input.path ?? NONE}
{missing ? '(缺失)' : cut ? '(已截断)' : ''}
</span>
)
return (
<li className="max-w-full">
{link ? (
<button type="button" className="chip mono tnum max-w-full transition-colors hover:bg-hover" style={style}
data-ev-input={input.path ?? ''} title={`${from ? `${from} · ` : ''}${EVIDENCE_TEXT.gotoQuery(link.alias)}`}
aria-label={`${valueText(input.value)} ← ${input.path ?? NONE},${EVIDENCE_TEXT.gotoQuery(link.alias)}`}
data-ev-input={input.path ?? ''} data-ev-input-masked={masked ? '' : undefined}
title={`${from ? `${from} · ` : ''}${EVIDENCE_TEXT.gotoQuery(link.alias)}`}
aria-label={`${shown} ← ${input.path ?? NONE},${EVIDENCE_TEXT.gotoQuery(link.alias)}`}
onClick={link.go}>
{body}<CornerDownRight size={9} className="shrink-0 opacity-60" aria-hidden />
</button>
) : (
<span className="chip mono tnum max-w-full" data-ev-input={input.path ?? ''} style={style} title={from || undefined}>
<span className="chip mono tnum max-w-full" data-ev-input={input.path ?? ''} data-ev-input-masked={masked ? '' : undefined}
style={style} title={from || undefined}>
{body}
</span>
)}
@@ -1333,7 +1453,7 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp
<Part title={EVIDENCE_TEXT.sources} data-ev-sources="">
<ul className="space-y-1.5">
{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(带复制)、结果窗口(被引用的行和格高亮)、窗口说明、打开完整快照、复核。
*
+184
View File
@@ -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()