mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
证据面板显示核查状态:指标步骤的双盲复算一行,整句的反方审稿判断,遮罩列的输入只写「已遮罩」
指标步骤多一行双盲复算的结果:一致(带盲算值)、不一致(带盲算值和审核人选了什么,没选的照实说)、无法复算(带原因)、 已跳过(关闭了、口径卡上没有来自查询的输入、这个指标不在复算范围内)。结果都写成字,颜色只表达要不要多看一眼。 点开一整句时,反方审稿的判断复用审批卡上的展示(run/ChallengeSummary,只读它的导出),只列这一句,点开看反方的理由和查询。 取自遮罩列的输入,标签照查询表格里遮罩格的样子写「已遮罩」,输入来源里核对不一致时只说不一致、不写两边的值。 接口字段的类型就近写在面板里,不动 types.ts;界面文案放 EVIDENCE_TEXT。 check-evidence 加一节:六种双盲状态的文字和颜色、深浅色对比度、没复核过的不画;遮罩输入的标签、代入式、遮罩说明、输入来源; 整句面板的反方审稿判断、降档说明、点开取审稿记录看理由、没审到的句子不画、390px 不横向溢出。
This commit is contained in:
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(带复制)、结果窗口(被引用的行和格高亮)、窗口说明、打开完整快照、复核。
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user