mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:22:00 +00:00
期 3 反方审稿(前端)复核修正:要审的报告按服务端的认法定,校验报过的不再重复,补齐降档提示的反例检查和运行类别取不到时的说明,修好显示全部的焦点与去重,处理审稿记录没存上的情况
检查器 - 「要审的报告」留空时,照服务端 schema.challenge_report 的认法:先看直接连着的报告撰写节点,恰好一个就审它;不是一个时,整个上游只有一个才取那一个。以前只认第二步,「初稿 → 终稿 → 反方审稿」这种接法下会误报「上游有 2 个,请选择」,同模型提醒也不出。现在留空项写出实际会审的那份(「未选择:审上游的「终稿」」),同模型提醒也按同一套认法判断。 - 认法写成 nodePickField / upstreamModel 的 rule 选项。缺省是 'upstream'(只看整个上游),和 F1a 的 dual_check_card 一致,双盲复算以后也能直接用。 - 选中的节点在上游、类型却不对(例如输入节点),以前写成「不在上游」,现在照实写「不是报告撰写节点」,提示里说清它是哪类节点。判断顺序和服务端一致:先看存不存在,再看类型,最后看在不在上游。 - 画布校验已经在 report / model 字段上报过问题时,不再画本地那句提示,避免同一件事一琥珀一红说两遍。判断哪些问题属于本字段,用和 Inspector 挂字段时相同的 fieldOfIssue;下拉里照实标出的那一项保留。 - 文案里的设置入口统一写成「设置 → 偏好设置 → 核查」,和设置页的真实标签、双盲复算、证据裁判的写法一致。 审批卡 - 降档的原因改为常显在概况下面,不再只放在悬停提示和读屏文字里。 - 运行类别取不到时,不再悄悄不写降档:照实写「暂时无法确认运行类别;如为正式运行,被推翻的结论会使出具降档」,并给出重试按钮;窗口重新拿到焦点时也会再取一次。 - 「显示全部」补句时按 unit 去重:两边都有 unit 时只比 unit,有一边缺 unit 才比原文。以前原文相同、unit 不同的一句会被吞掉,被吞的可能正是被推翻的那句。条目缺 unit 时不再补假的 #i。 - 键盘按「显示全部」、补句那一行的重试、一句详情里的重试、运行类别的重试之后,焦点都会接到新出现的内容上,不再掉到 body。焦点已经被用户移到别处时不抢。 - 审稿记录没存上(artifact 为 null)时,每句只列判断,不能点开,整块说一句「反方审稿记录未保存,无法查看理由和查询」;「另有 N 句未列出」照常显示,只是不给「显示全部」。 检查(check-challenge 共 153 项) - 新增预览用例:正式运行且没有被推翻的结论(降档 / 只标注)、探索运行配只标注、运行类别取不到、原文相同而 unit 不同、审稿记录没存上、链式接法的检查器。 - 「只标注、探索运行、没有反方审稿」拆成各自独立的段,一段出错不会遮住其余几段。 - 新增的检查项包括:降档原因常显、取不到运行类别时的说明和重试(含窗口重新拿到焦点后再取)、显示全部和各处重试后的焦点、按 unit 去重、审稿记录没存上、链式接法、类型不对的节点、校验报过就不重复说,以及窄屏和深浅色下新增几行说明的对比度。 - 复核时用来证明检查不够的两个变异,M1(正式运行一律写降档)和 M10(探索运行也写只标注说明),连同本次各项修正的变异,都已逐一确认会被检查发现。
This commit is contained in:
@@ -690,10 +690,13 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert,
|
||||
description: CHALLENGE_TEXT.description,
|
||||
hasTarget: true, sources: [{ id: 'out', label: '' }],
|
||||
// 上限、模型、开关都可以不写:服务端按「设置 → 核查」里反方审稿那一组取(verify_settings)。没写的项不往配置里落默认值,
|
||||
// 设置改了,没单独配过的节点跟着变
|
||||
// 上限、模型、开关都可以不写:服务端按「设置 → 偏好设置 → 核查」里反方审稿那一组取(verify_settings)。
|
||||
// 没写的项不往配置里落默认值,设置改了,没单独配过的节点跟着变
|
||||
fields: [
|
||||
nodePickField({ key: 'report', label: CHALLENGE_TEXT.field.report, nodeType: 'report', text: CHALLENGE_TEXT.reportPick }),
|
||||
// 留空时先认直接连着的报告(「初稿 → 终稿 → 反方审稿」审终稿),同服务端 schema.challenge_report
|
||||
nodePickField({
|
||||
key: 'report', label: CHALLENGE_TEXT.field.report, nodeType: 'report', rule: 'direct', text: CHALLENGE_TEXT.reportPick,
|
||||
}),
|
||||
{
|
||||
key: 'max_claims', label: CHALLENGE_TEXT.field.max_claims, type: 'number', min: 1,
|
||||
placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.max_claims,
|
||||
@@ -701,7 +704,7 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
// 和撰写报告的模型相同时提醒:同一个模型审自己写的结论,较难发现错误
|
||||
...verifyModelFields({
|
||||
labels: { provider: CHALLENGE_TEXT.field.provider, model: CHALLENGE_TEXT.field.model },
|
||||
text: CHALLENGE_TEXT.model, subject: upstreamModel('report', 'report'),
|
||||
text: CHALLENGE_TEXT.model, subject: upstreamModel('report', 'report', 'direct'),
|
||||
}),
|
||||
{
|
||||
// 没写等于 degrade:降档放第一项,没写时下拉框正好落在它上面
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useMemo } from 'react'
|
||||
import type { Edge } from '@xyflow/react'
|
||||
import clsx from 'clsx'
|
||||
import { useRadioGroup } from '../components/ui'
|
||||
import { nodeTypeLabel } from '../lib/terms'
|
||||
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
|
||||
import { useStudio, type FlowNode } from '../store/studio'
|
||||
import type { NodeType } from '../types'
|
||||
import { fieldOfIssue } from './issues'
|
||||
import type { FieldDef, FieldRenderProps } from './nodeDefs'
|
||||
|
||||
/**
|
||||
@@ -15,8 +17,22 @@ import type { FieldDef, FieldRenderProps } from './nodeDefs'
|
||||
*
|
||||
* 文案一律由调用方传进来(各节点自己那一块的 *_TEXT),控件里不写死任何一句话:两个节点说法不同(反方模型 / 盲算模型),
|
||||
* 控件是同一个。读画布节点用 studio store,不往 FieldRenderProps 里加字段。
|
||||
*
|
||||
* 本地提示(上游有好几个、模型相同……)只在画布校验还没在这一项上报问题时画:校验的话 Inspector 会挂在字段下面,
|
||||
* 再画一句就是同一件事说两遍(同结论句裁判「校验也会报,报了就不重复说」)。校验没回来、校验失败时照画。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 画布校验在这个节点的这一项上报了问题没有。认法和 Inspector 把问题挂到字段下面时一样(fieldOfIssue),
|
||||
* 两边说的「这一项有问题」不会对不上
|
||||
*/
|
||||
function useReported(nodeId: string, key: string): boolean {
|
||||
return useStudio((s) => {
|
||||
const node = s.nodes.find((n) => n.id === nodeId)
|
||||
return s.issues.some((i) => i.node_id === nodeId && fieldOfIssue(i, node)?.key === key)
|
||||
})
|
||||
}
|
||||
|
||||
/** 节点在下拉里的叫法:名字和 id 不同时把 id 带上(同 NodeRefsInput) */
|
||||
function nodeLabel(n: FlowNode): string {
|
||||
const label = String(n.data?.label || n.id)
|
||||
@@ -39,20 +55,57 @@ function upstreamOf(nodeId: string, edges: Edge[]): Set<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个节点实际要核查的是哪一个:配置里写了就是它(还在画布上的话),没写时上游恰好有一个就是那一个;
|
||||
* 上游有好几个、一个都没有时说不准,返回 undefined
|
||||
* 留空时怎么认,照各节点服务端的认法:
|
||||
* - 'upstream':整个上游里恰好一个才取它(双盲复算的口径卡,schema.dual_check_card);
|
||||
* - 'direct':先看直接连着的,恰好一个就是它,否则再看整个上游(反方审稿的报告,schema.challenge_report:
|
||||
* 「初稿 → 终稿 → 反方审稿」审终稿,不让人在初稿、终稿之间选)
|
||||
*/
|
||||
export function targetNode(nodeId: string, chosen: unknown, type: NodeType, nodes: FlowNode[], edges: Edge[]): FlowNode | undefined {
|
||||
if (typeof chosen === 'string' && chosen) return nodes.find((n) => n.id === chosen)
|
||||
export type PickRule = 'upstream' | 'direct'
|
||||
|
||||
/**
|
||||
* 配置里那一项按服务端的认法落到哪个节点上:target 是定下来的那一个;其余几种是定不下来的原因——写了的节点不存在、
|
||||
* 不是这一类、不在上游(先后同服务端),留空时上游一个都没有、不止一个
|
||||
*/
|
||||
export type PickState =
|
||||
| { kind: 'target'; node: FlowNode }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'wrongType'; node: FlowNode }
|
||||
| { kind: 'notUpstream'; node: FlowNode }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'many'; count: number }
|
||||
|
||||
export function resolvePick(
|
||||
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[],
|
||||
): PickState {
|
||||
const up = upstreamOf(nodeId, edges)
|
||||
if (typeof chosen === 'string' && chosen) {
|
||||
const node = nodes.find((n) => n.id === chosen)
|
||||
if (!node) return { kind: 'missing' }
|
||||
if (node.data?.nodeType !== type) return { kind: 'wrongType', node }
|
||||
return up.has(node.id) ? { kind: 'target', node } : { kind: 'notUpstream', node }
|
||||
}
|
||||
if (rule === 'direct') {
|
||||
const direct = new Set(edges.filter((e) => e.target === nodeId).map((e) => e.source))
|
||||
const near = nodes.filter((n) => direct.has(n.id) && n.data?.nodeType === type)
|
||||
if (near.length === 1) return { kind: 'target', node: near[0] }
|
||||
}
|
||||
const candidates = nodes.filter((n) => up.has(n.id) && n.data?.nodeType === type)
|
||||
return candidates.length === 1 ? candidates[0] : undefined
|
||||
return candidates.length === 1 ? { kind: 'target', node: candidates[0] }
|
||||
: candidates.length ? { kind: 'many', count: candidates.length } : { kind: 'none' }
|
||||
}
|
||||
|
||||
/** 这个节点实际要核查的是哪一个;定不下来(服务端这时报错、不核查)时返回 undefined */
|
||||
export function targetNode(
|
||||
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[],
|
||||
): FlowNode | undefined {
|
||||
const pick = resolvePick(nodeId, chosen, type, rule, nodes, edges)
|
||||
return pick.kind === 'target' ? pick.node : undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 选画布上的节点
|
||||
|
||||
export interface NodePickText {
|
||||
/** 留空那一项:上游恰好一个时给出它的名字 */
|
||||
/** 留空那一项:按缺省能认出是哪一个时给出它的名字 */
|
||||
auto: (only: string | null) => string
|
||||
/** 上游一个都没有 */
|
||||
none: string
|
||||
@@ -62,27 +115,39 @@ export interface NodePickText {
|
||||
missing: (id: string) => string
|
||||
/** 选中的节点不在上游(下拉里那一项的叫法) */
|
||||
notUpstream: (label: string) => string
|
||||
/** 选中的节点不是这一类(下拉里那一项的叫法) */
|
||||
wrongType: (label: string) => string
|
||||
missingHint: string
|
||||
notUpstreamHint: string
|
||||
/** type 是选中节点的类型名(「输入」) */
|
||||
wrongTypeHint: (type: string) => string
|
||||
}
|
||||
|
||||
function NodePick({ p, nodeType, text }: { p: FieldRenderProps; nodeType: NodeType; text: NodePickText }) {
|
||||
function NodePick({ p, fieldKey, nodeType, rule, text }: {
|
||||
p: FieldRenderProps; fieldKey: string; nodeType: NodeType; rule: PickRule; text: NodePickText
|
||||
}) {
|
||||
const nodes = useStudio((s) => s.nodes)
|
||||
const edges = useStudio((s) => s.edges)
|
||||
const reported = useReported(p.nodeId, fieldKey)
|
||||
const value = typeof p.value === 'string' ? p.value : ''
|
||||
const options = useMemo(() => {
|
||||
const up = upstreamOf(p.nodeId, edges)
|
||||
return nodes.filter((n) => up.has(n.id) && n.data?.nodeType === nodeType)
|
||||
}, [nodes, edges, p.nodeId, nodeType])
|
||||
const value = typeof p.value === 'string' ? p.value : ''
|
||||
const chosen = value ? nodes.find((n) => n.id === value) : undefined
|
||||
// 选中的节点被删了、挪到了下游:下拉里照实多列一项(选不回去),不让它看着像「未选择」
|
||||
const stale = value && !options.some((n) => n.id === value) ? (chosen ? 'notUpstream' : 'missing') : null
|
||||
const hint = stale === 'missing' ? text.missingHint
|
||||
: stale === 'notUpstream' ? text.notUpstreamHint
|
||||
: !options.length ? text.none
|
||||
: !value && options.length > 1 ? text.many(options.length) : ''
|
||||
const pick = useMemo(
|
||||
() => resolvePick(p.nodeId, value, nodeType, rule, nodes, edges), [p.nodeId, value, nodeType, rule, nodes, edges],
|
||||
)
|
||||
// 选中的节点被删了、不是这一类、挪到了下游:下拉里照实多列一项(选不回去),不让它看着像「未选择」。
|
||||
// 校验已经报了的,这一项照样列(看得出选的是什么),只是下面那句提示不再说
|
||||
const stale = pick.kind === 'missing' || pick.kind === 'wrongType' || pick.kind === 'notUpstream' ? pick : null
|
||||
const hint = reported ? ''
|
||||
: pick.kind === 'missing' ? text.missingHint
|
||||
: pick.kind === 'wrongType' ? text.wrongTypeHint(nodeTypeLabel(pick.node.data?.nodeType))
|
||||
: pick.kind === 'notUpstream' ? text.notUpstreamHint
|
||||
: pick.kind === 'none' ? text.none
|
||||
: pick.kind === 'many' ? text.many(pick.count) : ''
|
||||
const hintId = `${p.id}-hint`
|
||||
const only = options.length === 1 ? String(options[0].data?.label || options[0].id) : null
|
||||
const only = !value && pick.kind === 'target' ? String(pick.node.data?.label || pick.node.id) : null
|
||||
return (
|
||||
<>
|
||||
<select id={p.id} className="field" value={value} disabled={p.disabled} aria-invalid={p.invalid || undefined}
|
||||
@@ -92,12 +157,14 @@ function NodePick({ p, nodeType, text }: { p: FieldRenderProps; nodeType: NodeTy
|
||||
<option value="">{text.auto(only)}</option>
|
||||
{options.map((n) => <option key={n.id} value={n.id}>{nodeLabel(n)}</option>)}
|
||||
{stale && (
|
||||
<option value={value} disabled>{stale === 'missing' ? text.missing(value) : text.notUpstream(nodeLabel(chosen!))}</option>
|
||||
<option value={value} disabled>
|
||||
{stale.kind === 'missing' ? text.missing(value)
|
||||
: stale.kind === 'wrongType' ? text.wrongType(nodeLabel(stale.node)) : text.notUpstream(nodeLabel(stale.node))}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
{hint && (
|
||||
<div id={hintId} className="mt-1 text-2xs leading-snug" data-node-pick-hint={stale ?? (options.length ? 'many' : 'none')}
|
||||
style={{ color: 'var(--st-waiting)' }}>
|
||||
<div id={hintId} className="mt-1 text-2xs leading-snug" data-node-pick-hint={pick.kind} style={{ color: 'var(--st-waiting)' }}>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,14 +173,14 @@ function NodePick({ p, nodeType, text }: { p: FieldRenderProps; nodeType: NodeTy
|
||||
}
|
||||
|
||||
/**
|
||||
* 从画布上挑本节点上游的某一类节点。配置里存节点 id;留空(写回 undefined,不留空字符串)时服务端按缺省取上游的那一个
|
||||
* 从画布上挑本节点上游的某一类节点。配置里存节点 id;留空(写回 undefined,不留空字符串)时服务端按 rule 取缺省的那一个
|
||||
*/
|
||||
export function nodePickField(def: {
|
||||
key: string; label: string; nodeType: NodeType; text: NodePickText; help?: string; advanced?: boolean
|
||||
key: string; label: string; nodeType: NodeType; rule?: PickRule; text: NodePickText; help?: string; advanced?: boolean
|
||||
}): FieldDef {
|
||||
return {
|
||||
key: def.key, label: def.label, type: 'select', help: def.help, advanced: def.advanced,
|
||||
render: (p) => <NodePick p={p} nodeType={def.nodeType} text={def.text} />,
|
||||
render: (p) => <NodePick p={p} fieldKey={def.key} nodeType={def.nodeType} rule={def.rule ?? 'upstream'} text={def.text} />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,10 +272,14 @@ function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText
|
||||
)
|
||||
}
|
||||
|
||||
function ModelInput({ p, text, subject }: { p: FieldRenderProps; text: VerifyModelText; subject?: SubjectModel }) {
|
||||
function ModelInput({ p, fieldKey, text, subject }: {
|
||||
p: FieldRenderProps; fieldKey: string; text: VerifyModelText; subject?: SubjectModel
|
||||
}) {
|
||||
const providers = useCatalog((s) => s.providers)
|
||||
// 选择器只返回一个字符串:画布上别的节点改动不让这一栏重渲染
|
||||
const against = useStudio((s) => (subject ? subject(p.nodeId, p.config, s) : null))
|
||||
// 校验也会报模型相同(challenge.same_model 之类),报了就不重复说
|
||||
const reported = useReported(p.nodeId, fieldKey)
|
||||
const provider = typeof p.config.provider === 'string' ? p.config.provider : ''
|
||||
const model = typeof p.value === 'string' ? p.value : ''
|
||||
const name = model.trim()
|
||||
@@ -221,7 +292,7 @@ function ModelInput({ p, text, subject }: { p: FieldRenderProps; text: VerifyMod
|
||||
const fallback = enabled.find((x) => x.kind !== 'mock') ?? enabled[0]
|
||||
const byModelHint = byModel && providers.length > 0
|
||||
? text.byModel(name, owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
|
||||
const same = !!name && !!against && name === against
|
||||
const same = !!name && !!against && name === against && !reported
|
||||
return (
|
||||
<>
|
||||
<input id={p.id} className="field mono text-xs" list={`${p.id}-models`} value={model} spellCheck={false}
|
||||
@@ -260,17 +331,18 @@ export function verifyModelFields(def: {
|
||||
},
|
||||
{
|
||||
key: 'model', label: def.labels.model, type: 'model', advanced: def.advanced,
|
||||
render: (p) => <ModelInput p={p} text={def.text} subject={def.subject} />,
|
||||
render: (p) => <ModelInput p={p} fieldKey="model" text={def.text} subject={def.subject} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 被核查节点上写着的模型:refKey 是本节点配置里指向它的那一项(反方审稿的 report),没写时取上游唯一的那一个 nodeType
|
||||
* 被核查节点上写着的模型:refKey 是本节点配置里指向它的那一项(反方审稿的 report),没写时按 rule 认缺省的那一个
|
||||
* (和 nodePickField 用同一个 rule,两处认的是同一个节点)。定不下来时服务端只报选报告的错、不比模型,这里也不提醒
|
||||
*/
|
||||
export function upstreamModel(refKey: string, nodeType: NodeType): SubjectModel {
|
||||
export function upstreamModel(refKey: string, nodeType: NodeType, rule: PickRule = 'upstream'): SubjectModel {
|
||||
return (nodeId, config, { nodes, edges }) => {
|
||||
const model = targetNode(nodeId, config[refKey], nodeType, nodes, edges)?.data?.config?.model
|
||||
const model = targetNode(nodeId, config[refKey], nodeType, rule, nodes, edges)?.data?.config?.model
|
||||
return typeof model === 'string' && model.trim() ? model.trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
"explore": "run-chal-explore",
|
||||
"none": "run-chal-none",
|
||||
"many": "run-chal-many",
|
||||
"broken": "run-chal-broken"
|
||||
"broken": "run-chal-broken",
|
||||
"clear": "run-chal-clear",
|
||||
"clearFlag": "run-chal-clear-flag",
|
||||
"exploreFlag": "run-chal-explore-flag",
|
||||
"runfail": "run-chal-runfail",
|
||||
"dup": "run-chal-dup",
|
||||
"norecord": "run-chal-norecord"
|
||||
},
|
||||
"report": {
|
||||
"node_id": "write",
|
||||
|
||||
@@ -20,12 +20,18 @@ import fx from './challenge.json'
|
||||
* - ?syn=challenge-approval-many:十二句,条目只列前十句,「显示全部」从工件里补齐
|
||||
* - ?syn=challenge-approval-broken:工件取不到(检查脚本先答 404,再改成能取到,测重试)
|
||||
* - ?syn=challenge-approval-dense:同第一个,摆在 360px 的右栏里
|
||||
* - ?syn=challenge-approval-clear / -clear-flag:正式运行,没有被推翻的结论(降档 / 只标注),两种都不写降档、不写只标注
|
||||
* - ?syn=challenge-approval-explore-flag:探索运行配只标注,同样不说任何和出具有关的话
|
||||
* - ?syn=challenge-approval-runfail:运行类别取不到(检查脚本先答 500),说明并可重试
|
||||
* - ?syn=challenge-approval-dup:同 many,工件里补出来的一句和条目里的一句原文相同、unit 不同(工件由检查脚本合成)
|
||||
* - ?syn=challenge-approval-norecord:同 many,审稿记录没存上(artifact 为 null)
|
||||
*
|
||||
* 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})都由检查脚本用 page.route 伪造;直接打开时取不到,
|
||||
* 卡上照实不说降档、点开一句说无法获取。
|
||||
*
|
||||
* 检查器:?syn=challenge-inspector:一张虚构的图(两个上游报告、一个下游报告),选中反方审稿节点,旁边是同一张图的
|
||||
* 节点卡片和节点配置的实时内容。
|
||||
* 检查器:选中反方审稿节点,旁边是同一张图的节点卡片和节点配置的实时内容。
|
||||
* - ?syn=challenge-inspector:两个上游报告、一个下游报告
|
||||
* - ?syn=challenge-inspector-chain:「初稿 → 终稿 → 反方审稿」,留空时审直接连着的终稿,反方模型和终稿的模型相同
|
||||
*/
|
||||
|
||||
const report = fx.report as unknown as ApprovalReport
|
||||
@@ -52,6 +58,12 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str
|
||||
|
||||
const RUNS = fx.runs
|
||||
const withChallenge = (c: Partial<ApprovalChallenge>): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } })
|
||||
const many = fx.many as unknown as ApprovalChallenge
|
||||
/** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */
|
||||
const clear: Partial<ApprovalChallenge> = {
|
||||
counts: { stands: 1, weakened: 1, refuted: 0, inconclusive: 1 },
|
||||
items: challenge.items.filter((it) => it.verdict !== 'refuted'),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 检查器
|
||||
|
||||
@@ -86,6 +98,24 @@ const WORKFLOW = {
|
||||
},
|
||||
} as unknown as Workflow
|
||||
|
||||
/** 链式接法:初稿、终稿都在上游,直接连着的只有终稿。终稿和反方审稿写的是同一个模型 */
|
||||
const CHAIN = {
|
||||
id: 'wf-challenge-chain', name: '月报(链式,预览)', description: '', tags: [], version: 1, is_template: false, status: 'draft',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'input', type: 'input', position: at(0, 120), data: { label: '输入', config: { fields: [{ name: 'month', required: true }] } } },
|
||||
{ id: 'draft', type: 'report', position: at(260, 120), data: { label: '初稿', config: { instructions: '写初稿', model: 'spare-mini' } } },
|
||||
{ id: 'final', type: 'report', position: at(520, 120), data: { label: '终稿', config: { instructions: '改成终稿', model: 'writer-large' } } },
|
||||
{ id: 'critic', type: 'challenge', position: at(780, 120), data: { label: '反方审稿', config: { model: 'writer-large' } } },
|
||||
{ id: 'out', type: 'output', position: at(1040, 120), data: { label: '输出', config: {} } },
|
||||
],
|
||||
edges: [
|
||||
{ source: 'input', target: 'draft' }, { source: 'draft', target: 'final' },
|
||||
{ source: 'final', target: 'critic' }, { source: 'critic', target: 'out' },
|
||||
],
|
||||
},
|
||||
} as unknown as Workflow
|
||||
|
||||
const nodeTypes = { card: NodeCard }
|
||||
|
||||
/** 同一张图的节点卡片:看反方审稿节点的摘要跟着配置变 */
|
||||
@@ -109,16 +139,16 @@ function ConfigView() {
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorCase() {
|
||||
function InspectorCase({ name, workflow }: { name: string; workflow: Workflow }) {
|
||||
// 首次渲染前装好图和接入:检查器、卡片一挂上就是选中反方审稿节点的样子
|
||||
useState(() => {
|
||||
useCatalog.setState({ providers: PROVIDERS })
|
||||
useStudio.getState().load(WORKFLOW)
|
||||
useStudio.getState().load(workflow)
|
||||
useStudio.getState().select('critic')
|
||||
return true
|
||||
})
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col md:h-screen md:flex-row" data-fixture="challenge-inspector">
|
||||
<div className="flex min-h-screen flex-col md:h-screen md:flex-row" data-fixture={name}>
|
||||
<div className="w-full shrink-0 border-b bg-panel md:h-full md:w-[360px] md:border-b-0 md:border-r" data-challenge-inspector="">
|
||||
<Inspector />
|
||||
</div>
|
||||
@@ -146,8 +176,7 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
<Card name="challenge-approval-none" approval={approvalOf('ap-chal-4', RUNS.none, 'formal', report)} />
|
||||
),
|
||||
'challenge-approval-many': () => (
|
||||
<Card name="challenge-approval-many"
|
||||
approval={approvalOf('ap-chal-5', RUNS.many, 'formal', { ...report, challenge: fx.many as unknown as ApprovalChallenge })} />
|
||||
<Card name="challenge-approval-many" approval={approvalOf('ap-chal-5', RUNS.many, 'formal', { ...report, challenge: many })} />
|
||||
),
|
||||
'challenge-approval-broken': () => (
|
||||
<Card name="challenge-approval-broken"
|
||||
@@ -156,5 +185,28 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
'challenge-approval-dense': () => (
|
||||
<Card name="challenge-approval-dense" dense approval={approvalOf('ap-chal-7', RUNS.formal, 'formal', withChallenge({}))} />
|
||||
),
|
||||
'challenge-inspector': () => <InspectorCase />,
|
||||
'challenge-approval-clear': () => (
|
||||
<Card name="challenge-approval-clear" approval={approvalOf('ap-chal-8', RUNS.clear, 'formal', withChallenge(clear))} />
|
||||
),
|
||||
'challenge-approval-clear-flag': () => (
|
||||
<Card name="challenge-approval-clear-flag"
|
||||
approval={approvalOf('ap-chal-9', RUNS.clearFlag, 'formal', withChallenge({ ...clear, on_refuted: 'flag' }))} />
|
||||
),
|
||||
'challenge-approval-explore-flag': () => (
|
||||
<Card name="challenge-approval-explore-flag"
|
||||
approval={approvalOf('ap-chal-10', RUNS.exploreFlag, 'exploratory', withChallenge({ on_refuted: 'flag' }))} />
|
||||
),
|
||||
'challenge-approval-runfail': () => (
|
||||
<Card name="challenge-approval-runfail" approval={approvalOf('ap-chal-11', RUNS.runfail, 'formal', withChallenge({}))} />
|
||||
),
|
||||
'challenge-approval-dup': () => (
|
||||
<Card name="challenge-approval-dup"
|
||||
approval={approvalOf('ap-chal-12', RUNS.dup, 'formal', { ...report, challenge: { ...many, artifact: 'd'.repeat(64) } })} />
|
||||
),
|
||||
'challenge-approval-norecord': () => (
|
||||
<Card name="challenge-approval-norecord"
|
||||
approval={approvalOf('ap-chal-13', RUNS.norecord, 'formal', { ...report, challenge: { ...many, artifact: null } })} />
|
||||
),
|
||||
'challenge-inspector': () => <InspectorCase name="challenge-inspector" workflow={WORKFLOW} />,
|
||||
'challenge-inspector-chain': () => <InspectorCase name="challenge-inspector-chain" workflow={CHAIN} />,
|
||||
}
|
||||
|
||||
@@ -3343,7 +3343,8 @@ export const DUAL_CHECK_TEXT = {
|
||||
/**
|
||||
* 反方审稿节点:节点库说明、检查器、节点卡片摘要,以及审批卡上的反方审稿(run/ChallengeSummary.tsx)。
|
||||
* 四种判断全站只有这一种叫法:经受住、被削弱、被推翻、无法判断。判断由反方模型给出,不是系统核对。
|
||||
* 「出具会降档」只在正式运行、按降档处理时才说;探索运行不出具,不说任何和出具有关的话
|
||||
* 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才说;探索运行不出具,不说任何和出具有关的话;
|
||||
* 运行类别取不到时照实说取不到
|
||||
*/
|
||||
export const CHALLENGE_TEXT = {
|
||||
description: '从反方立场复核上游报告的关键结论:另一个模型只读查库,逐句判断经受住、被削弱、被推翻还是无法判断',
|
||||
@@ -3371,9 +3372,9 @@ export const CHALLENGE_TEXT = {
|
||||
},
|
||||
followSettings: '跟随设置',
|
||||
help: {
|
||||
max_claims: '每份报告最多挑几句关键结论来审。留空则使用「设置 → 核查」中的值',
|
||||
max_queries: '复核每句结论时最多执行几次查询。留空则使用「设置 → 核查」中的值',
|
||||
timeout_s: '复核每句结论的时长上限,到时按已查到的结果给出判断。留空则使用「设置 → 核查」中的值',
|
||||
max_claims: '每份报告最多挑几句关键结论来审。留空则使用「设置 → 偏好设置 → 核查」中的值',
|
||||
max_queries: '复核每句结论时最多执行几次查询。留空则使用「设置 → 偏好设置 → 核查」中的值',
|
||||
timeout_s: '复核每句结论的时长上限,到时按已查到的结果给出判断。留空则使用「设置 → 偏好设置 → 核查」中的值',
|
||||
on_refuted: '降档:被推翻的结论计入出具缺口,正式运行降档出具;只标注:在审批卡和运行记录中标出,不影响出具;'
|
||||
+ '报错:本节点失败,运行停止',
|
||||
},
|
||||
@@ -3385,13 +3386,15 @@ export const CHALLENGE_TEXT = {
|
||||
many: (n: number) => `上游有 ${formatNumber(n)} 个报告撰写节点,请选择要审的那一个`,
|
||||
missing: (id: string) => `${id}(已不在画布上)`,
|
||||
notUpstream: (label: string) => `${label}(不在上游)`,
|
||||
wrongType: (label: string) => `${label}(不是报告撰写节点)`,
|
||||
missingHint: '所选节点已不在画布上,请重新选择',
|
||||
notUpstreamHint: '所选节点不在本节点上游,运行到这里时它还没有写出报告,请重新选择或调整连线',
|
||||
wrongTypeHint: (type: string) => `所选节点是「${type}」节点,不是报告撰写节点,请重新选择`,
|
||||
},
|
||||
enabled: {
|
||||
follow: '跟随设置', on: '开启', off: '关闭',
|
||||
hint: {
|
||||
follow: '使用「设置 → 核查」中「反方审稿 · 启用」的值',
|
||||
follow: '使用「设置 → 偏好设置 → 核查」中「反方审稿 · 启用」的值',
|
||||
on: '无论设置如何都执行反方审稿',
|
||||
off: '不执行反方审稿,上游的结果原样交给下游,运行记录中记一条说明',
|
||||
},
|
||||
@@ -3403,8 +3406,8 @@ export const CHALLENGE_TEXT = {
|
||||
providerDisabled: '(已停用)',
|
||||
modelFollow: '跟随设置',
|
||||
modelDefault: '留空则使用该接入的默认模型',
|
||||
help: '接入和模型均未填写时,使用「设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,不与设置合并。'
|
||||
+ '建议反方模型与撰写报告的模型不同,同一模型较难发现自身的错误',
|
||||
help: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,'
|
||||
+ '不与设置合并。建议反方模型与撰写报告的模型不同,同一模型较难发现自身的错误',
|
||||
byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => (
|
||||
found && !found.enabled
|
||||
? `只填了模型:「${model}」属于已停用的接入「${found.name}」,调用将失败。请启用该接入或选择其他接入`
|
||||
@@ -3422,6 +3425,10 @@ export const CHALLENGE_TEXT = {
|
||||
degrade: '出具会降档',
|
||||
degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具',
|
||||
flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位',
|
||||
/** 运行类别没取到:不知道是不是正式运行,照实说,并写出正式运行时的后果(旁边给重试) */
|
||||
runClassFailed: '暂时无法确认运行类别;如为正式运行,被推翻的结论会使出具降档',
|
||||
/** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */
|
||||
noRecord: '反方审稿记录未保存,无法查看理由和查询',
|
||||
more: (n: number) => `另有 ${formatNumber(n)} 句未列出`,
|
||||
showAll: '显示全部',
|
||||
loading: '正在加载反方审稿…',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useId, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { AlertTriangle, ChevronRight, ShieldAlert } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { EVIDENCE_STATE, segmentLabel, segmentState, sourceOf, unitText, type EvidenceStateCode } from '../lib/evidence'
|
||||
@@ -18,11 +18,12 @@ export interface ChallengeSummaryProps {
|
||||
|
||||
/**
|
||||
* 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),逐句列出结论原文和判断,
|
||||
* 点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行过的查询。
|
||||
* 点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、
|
||||
* 不能点开,整块说一次。
|
||||
*
|
||||
* 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。
|
||||
* 「出具会降档」只在正式运行、被推翻的结论按降档处理时才写——只标注的不写降档,探索运行不出具,和出具有关的话一句不说。
|
||||
* 运行类别不由审批卡传进来,按运行 id 从证据 store 取(loadRunClass),取到之前不说。
|
||||
* 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才写——只标注的不写降档,探索运行不出具,和出具有关的话一句不说。
|
||||
* 运行类别不由审批卡传进来,按运行 id 从证据 store 取(loadRunClass),取到之前不说;取不到时照实说取不到,可重试。
|
||||
*
|
||||
* 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。
|
||||
*/
|
||||
@@ -38,11 +39,11 @@ export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) {
|
||||
function ChallengeList({ reviewed, runId }: {
|
||||
reviewed: { report: ApprovalReport; challenge: ApprovalChallenge }[]; runId: string
|
||||
}) {
|
||||
const runClass = useRunClass(runId)
|
||||
const run = useRunClass(runId)
|
||||
return (
|
||||
<>
|
||||
{reviewed.map(({ report, challenge }) => (
|
||||
<ChallengeBlock key={report.node_id} report={report} challenge={challenge} runClass={runClass} />
|
||||
<ChallengeBlock key={report.node_id} report={report} challenge={challenge} run={run} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
@@ -71,14 +72,14 @@ const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v
|
||||
|
||||
/**
|
||||
* payload.reports[i].challenge 认不认得:要么有计数、要么有条目。计数缺了按条目数(条目最多 10 条,计数才是全部);
|
||||
* 条目缺了 unit 的按序号补一个键,只用来区分行
|
||||
* 条目缺了 unit 的留空(行的键带序号,区分得开),不补假的:补出来的键会被当成真的 unit 去和工件里的句子比
|
||||
*/
|
||||
function challengeOf(report: ApprovalReport): ApprovalChallenge | null {
|
||||
const raw = report.challenge as unknown
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const c = raw as Record<string, unknown>
|
||||
const items = (Array.isArray(c.items) ? c.items.filter(isItem) : [])
|
||||
.map((it, i) => ({ unit: typeof it.unit === 'string' && it.unit ? it.unit : `#${i}`, text: it.text, verdict: it.verdict }))
|
||||
.map((it) => ({ unit: typeof it.unit === 'string' ? it.unit : '', text: it.text, verdict: it.verdict }))
|
||||
// 计数里一个数都没有(格式不对)也按条目数,免得概况写四个 0、下面却列着几句
|
||||
const given = c.counts && typeof c.counts === 'object' && VERDICTS.some((v) => typeof (c.counts as any)[v] === 'number')
|
||||
? c.counts as Record<string, unknown> : null
|
||||
@@ -86,17 +87,40 @@ function challengeOf(report: ApprovalReport): ApprovalChallenge | null {
|
||||
? count(given[v]) : items.filter((it) => it.verdict === v).length])) as ChallengeCounts
|
||||
if (!given && !items.length) return null
|
||||
return {
|
||||
counts, items, artifact: typeof c.artifact === 'string' ? c.artifact : '',
|
||||
counts, items, artifact: typeof c.artifact === 'string' && c.artifact ? c.artifact : null,
|
||||
on_refuted: typeof c.on_refuted === 'string' ? c.on_refuted : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function useRunClass(runId: string): 'formal' | 'exploratory' | null {
|
||||
interface RunClass {
|
||||
runClass: 'formal' | 'exploratory' | null
|
||||
/** 取失败了、还没取到(重取期间也算:那一行和焦点所在的重试按钮不跟着闪掉) */
|
||||
failed: boolean
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
function useRunClass(runId: string): RunClass {
|
||||
const slot = useEvidence((s) => (runId ? s.runClasses[runId] : undefined))
|
||||
const load = useEvidence((s) => s.loadRunClass)
|
||||
useEffect(() => { if (runId) void load(runId) }, [runId, load])
|
||||
if (slot?.status !== 'ok') return null
|
||||
return slot.data === 'formal' || slot.data === 'exploratory' ? slot.data : null
|
||||
const status = slot?.status
|
||||
const [hadError, setHadError] = useState(false)
|
||||
useEffect(() => {
|
||||
if (status === 'error') setHadError(true)
|
||||
else if (status === 'ok') setHadError(false)
|
||||
}, [status])
|
||||
const failed = status === 'error' || (hadError && status === 'loading')
|
||||
// 取失败了不就此作罢:窗口重新拿到焦点时再取一次(store 对出错的槽会重发)。只取一次就放弃的话,一次网络抖动
|
||||
// 会让「出具会降档」一直不出现,而卡顶按审批自带的运行类别照样写着正式运行
|
||||
useEffect(() => {
|
||||
if (status !== 'error' || !runId) return
|
||||
const again = () => { void load(runId) }
|
||||
window.addEventListener('focus', again)
|
||||
return () => window.removeEventListener('focus', again)
|
||||
}, [status, runId, load])
|
||||
const retry = useCallback(() => { if (runId) void load(runId) }, [runId, load])
|
||||
const runClass = status === 'ok' && (slot?.data === 'formal' || slot?.data === 'exploratory') ? slot.data : null
|
||||
return { runClass, failed, retry }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 取工件
|
||||
@@ -168,29 +192,41 @@ function useChallengeArtifact(id: string, want: boolean): Detail {
|
||||
return id ? { ...state, retry } : { status: 'error' }
|
||||
}
|
||||
|
||||
/** 这一句在工件里的完整内容:按 unit 认,unit 对不上(老服务端没给)再按原文认 */
|
||||
/**
|
||||
* 工件里的一句和审批条目里的一句是不是同一句:两边都有 unit 时只按 unit 认——同一句话在摘要和正文各出现一次、各自被挑来审,
|
||||
* 原文一样、unit 不同,是两句(按原文认会把其中一句吞掉,被吞的可能正是被推翻的那句);有一边没给 unit(老服务端)才按原文认
|
||||
*/
|
||||
const sameClaim = (a: ChallengeItem, b: ChallengeItem) => (a.unit && b.unit ? a.unit === b.unit : a.text === b.text)
|
||||
|
||||
/** 这一句在工件里的完整内容 */
|
||||
function claimOf(artifact: ChallengeArtifact | undefined, item: ChallengeItem): ChallengeClaim | undefined {
|
||||
if (!artifact) return undefined
|
||||
return artifact.claims.find((c) => c.unit && c.unit === item.unit) ?? artifact.claims.find((c) => c.text === item.text)
|
||||
return artifact?.claims.find((c) => sameClaim(c, item))
|
||||
}
|
||||
|
||||
/** 补齐、重试之后接住焦点的几处:本身不在 Tab 顺序里(tabIndex -1),键盘用户得看得见焦点落在哪 */
|
||||
const FOCUS_RING = 'rounded-sm focus:outline-none focus-visible:outline focus-visible:outline-solid focus-visible:outline-2 '
|
||||
+ 'focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]'
|
||||
|
||||
/** 焦点丢了(按过的按钮被拿掉,落回 body)才替它接住;取的这一会儿用户已经去了别处的,不抢 */
|
||||
const focusLost = () => !document.activeElement || document.activeElement === document.body
|
||||
|
||||
// ---------------------------------------------------------------- 卡片
|
||||
|
||||
function ChallengeBlock({ report, challenge, runClass }: {
|
||||
report: ApprovalReport; challenge: ApprovalChallenge; runClass: 'formal' | 'exploratory' | null
|
||||
function ChallengeBlock({ report, challenge, run }: {
|
||||
report: ApprovalReport; challenge: ApprovalChallenge; run: RunClass
|
||||
}) {
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set())
|
||||
const [all, setAll] = useState(false)
|
||||
const detail = useChallengeArtifact(challenge.artifact, all || open.size > 0)
|
||||
const detail = useChallengeArtifact(challenge.artifact ?? '', all || open.size > 0)
|
||||
const { counts, items } = challenge
|
||||
const total = VERDICTS.reduce((n, v) => n + counts[v], 0)
|
||||
const refuted = counts.refuted > 0 && runClass === 'formal'
|
||||
// 审批里没带工件:服务端保存审稿记录失败了。每句都看不了理由和查询,不画成能点开的样子,整块说一次
|
||||
const noRecord = !challenge.artifact
|
||||
const refuted = counts.refuted > 0 && run.runClass === 'formal'
|
||||
// 没给 on_refuted(老服务端)按降档;报错的节点到不了审批,不会是 fail
|
||||
const flagged = challenge.on_refuted === 'flag'
|
||||
// 条目之外的几句(条目最多 10 条):要看全部时从工件里补上,排在后面,顺序照工件
|
||||
const extra = all && detail.data
|
||||
? detail.data.claims.filter((c) => !items.some((it) => (c.unit && c.unit === it.unit) || c.text === it.text))
|
||||
: []
|
||||
const extra = all && detail.data ? detail.data.claims.filter((c) => !items.some((it) => sameClaim(c, it))) : []
|
||||
const rows: ChallengeItem[] = [...items, ...extra]
|
||||
const hidden = Math.max(0, total - items.length)
|
||||
const toggle = (key: string) => setOpen((cur) => {
|
||||
@@ -199,13 +235,45 @@ function ChallengeBlock({ report, challenge, runClass }: {
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
|
||||
// 「显示全部」那一行补齐后整行拿掉、取的时候按钮也停用,焦点会掉到页面上,下一个 Tab 就跳过了新补出来的几句:
|
||||
// 按过「显示全部」或那一行的「重试」之后,取到了落到补出来的第一句上(计数和工件对不上、什么也没补到时落到最后一句),
|
||||
// 又没取到落到那一行新出现的「重试」上
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const moreRef = useRef<HTMLDivElement>(null)
|
||||
const focusMore = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!focusMore.current || !all || (detail.status !== 'ok' && detail.status !== 'error')) return
|
||||
focusMore.current = false
|
||||
if (!focusLost()) return
|
||||
if (detail.status === 'error') {
|
||||
moreRef.current?.querySelector<HTMLElement>('button')?.focus()
|
||||
return
|
||||
}
|
||||
const toggles = blockRef.current?.querySelectorAll<HTMLElement>('[data-challenge-toggle]')
|
||||
;(toggles?.[items.length] ?? toggles?.[toggles.length - 1])?.focus()
|
||||
}, [all, detail.status, items.length])
|
||||
const showAll = () => { focusMore.current = true; setAll(true) }
|
||||
const retryAll = () => { focusMore.current = true; detail.retry?.() }
|
||||
|
||||
// 运行类别重取成功后,「暂时无法确认」那一行(连同焦点所在的重试按钮)换成降档说明:焦点接到概况那一行上
|
||||
const countsRef = useRef<HTMLDivElement>(null)
|
||||
const runRetried = useRef(false)
|
||||
useEffect(() => {
|
||||
if (run.failed || !runRetried.current) return
|
||||
runRetried.current = false
|
||||
if (focusLost()) countsRef.current?.focus()
|
||||
}, [run.failed])
|
||||
const retryRun = () => { runRetried.current = true; run.retry() }
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded border bg-bg p-2" data-challenge={report.node_id}>
|
||||
<div ref={blockRef} className="mb-2 rounded border bg-bg p-2" data-challenge={report.node_id}>
|
||||
<div className="mb-1 flex items-center gap-1 text-2xs font-medium text-faint">
|
||||
<ShieldAlert size={11} aria-hidden /> {CHALLENGE_TEXT.head(report.label || report.node_id)}
|
||||
</div>
|
||||
{total > 0 || items.length ? (
|
||||
<div className="tnum flex flex-wrap items-center gap-x-1.5 gap-y-1 text-2xs" data-challenge-counts="">
|
||||
<div ref={countsRef} tabIndex={-1} className={`tnum flex flex-wrap items-center gap-x-1.5 gap-y-1 text-2xs ${FOCUS_RING}`}
|
||||
data-challenge-counts="">
|
||||
{VERDICTS.map((v, i) => (
|
||||
<Fragment key={v}>
|
||||
{i > 0 && <span className="text-faint" aria-hidden>·</span>}
|
||||
@@ -215,23 +283,41 @@ function ChallengeBlock({ report, challenge, runClass }: {
|
||||
</Fragment>
|
||||
))}
|
||||
{refuted && !flagged && (
|
||||
<span className="chip shrink-0 font-semibold" data-challenge-degrade="" title={CHALLENGE_TEXT.degradeHint}
|
||||
<span className="chip shrink-0 font-semibold" data-challenge-degrade=""
|
||||
style={{ color: 'var(--st-failed)', borderColor: 'var(--st-failed)' }}>
|
||||
<AlertTriangle size={11} aria-hidden /> {CHALLENGE_TEXT.degrade}
|
||||
<span className="sr-only">:{CHALLENGE_TEXT.degradeHint}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xs text-dim" data-challenge-empty="">{CHALLENGE_TEXT.none}</div>
|
||||
)}
|
||||
{/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */}
|
||||
{refuted && !flagged && (
|
||||
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-degrade-note="">{CHALLENGE_TEXT.degradeHint}</div>
|
||||
)}
|
||||
{refuted && flagged && (
|
||||
<div className="mt-0.5 text-2xs leading-relaxed text-dim" data-challenge-flag="">{CHALLENGE_TEXT.flagNote}</div>
|
||||
)}
|
||||
{/* 运行类别取不到:不知道是不是正式运行,不能装作没事(卡顶按审批自带的运行类别照样写着正式运行),照实说并可重试 */}
|
||||
{counts.refuted > 0 && !flagged && run.failed && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs leading-relaxed" data-challenge-runclass="failed">
|
||||
<span style={{ color: 'var(--st-waiting)' }}>{CHALLENGE_TEXT.runClassFailed}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={retryRun}>{CHALLENGE_TEXT.retry}</button>
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-0.5" data-challenge-items="">
|
||||
{rows.map((item, i) => {
|
||||
const key = `${item.unit}\u0000${i}`
|
||||
if (noRecord) {
|
||||
return (
|
||||
<li key={key} data-challenge-item={item.unit} data-challenge-verdict={item.verdict}
|
||||
className="flex min-w-0 items-start gap-1.5 px-1 py-0.5 text-2xs leading-relaxed">
|
||||
<ClaimLine item={item} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ClaimRow key={key} item={item} open={open.has(key)} onToggle={() => toggle(key)}>
|
||||
<ClaimDetail item={item} detail={detail} />
|
||||
@@ -240,23 +326,29 @@ function ChallengeBlock({ report, challenge, runClass }: {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{/* 工件取回来了、却没有条目之外的句子(计数和工件对不上):没什么可补的,这一行也不留 */}
|
||||
{hidden > 0 && !extra.length && challenge.artifact && !(all && detail.status === 'ok') && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-dim" data-challenge-more={hidden}>
|
||||
{/* 工件取回来了、却没有条目之外的句子(计数和工件对不上):没什么可补的,这一行也不留。没有工件时照样交代
|
||||
还有几句没列出,只是补不了 */}
|
||||
{hidden > 0 && !extra.length && !(all && detail.status === 'ok') && (
|
||||
<div ref={moreRef} className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-dim" data-challenge-more={hidden}>
|
||||
<span>{CHALLENGE_TEXT.more(hidden)}</span>
|
||||
{detail.status === 'error' && all ? (
|
||||
{noRecord ? null : detail.status === 'error' && all ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--st-waiting)' }} role="alert">{CHALLENGE_TEXT.failed}</span>
|
||||
{detail.retry && <button type="button" className="btn btn-xs" onClick={detail.retry}>{CHALLENGE_TEXT.retry}</button>}
|
||||
{detail.retry && <button type="button" className="btn btn-xs" onClick={retryAll}>{CHALLENGE_TEXT.retry}</button>}
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn btn-xs" disabled={all && detail.status === 'loading'}
|
||||
data-challenge-show-all="" onClick={() => setAll(true)}>
|
||||
data-challenge-show-all="" onClick={showAll}>
|
||||
{all && detail.status === 'loading' ? CHALLENGE_TEXT.loading : CHALLENGE_TEXT.showAll}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{noRecord && (total > 0 || items.length > 0) && (
|
||||
<div className="mt-1 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-challenge-norecord="">
|
||||
{CHALLENGE_TEXT.noRecord}
|
||||
</div>
|
||||
)}
|
||||
{/* 判断标签看着像结论,得说清是谁判的:整块说一次,工件取回来之后带上模型名 */}
|
||||
{(total > 0 || items.length > 0) && (
|
||||
<p className="mt-1 text-2xs leading-relaxed text-faint" data-challenge-judged="">
|
||||
@@ -278,6 +370,19 @@ function VerdictTag({ verdict }: { verdict: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 一句的判断标签和结论原文 */
|
||||
function ClaimLine({ item }: { item: ChallengeItem }) {
|
||||
return (
|
||||
<>
|
||||
<VerdictTag verdict={item.verdict} />
|
||||
{/* 原文取自报告文档,是 Markdown:粗体、行内代码照样画出来;链接不画成链接,按钮里不能再套一个可点的东西 */}
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]" data-challenge-text="">
|
||||
<StyledSlice text={item.text} styles={inlineStyles(item.text)} from={0} to={item.text.length} links={false} />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ClaimRow({ item, open, onToggle, children }: {
|
||||
item: ChallengeItem; open: boolean; onToggle: () => void; children: ReactNode
|
||||
}) {
|
||||
@@ -288,11 +393,7 @@ function ClaimRow({ item, open, onToggle, children }: {
|
||||
className="flex w-full min-w-0 items-start gap-1.5 rounded px-1 py-0.5 text-left text-2xs leading-relaxed hover:bg-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)]">
|
||||
<ChevronRight size={11} aria-hidden className="mt-[3px] shrink-0 text-faint"
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
<VerdictTag verdict={item.verdict} />
|
||||
{/* 原文取自报告文档,是 Markdown:粗体、行内代码照样画出来;链接不画成链接,按钮里不能再套一个可点的东西 */}
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]" data-challenge-text="">
|
||||
<StyledSlice text={item.text} styles={inlineStyles(item.text)} from={0} to={item.text.length} links={false} />
|
||||
</span>
|
||||
<ClaimLine item={item} />
|
||||
</button>
|
||||
{open && (
|
||||
<div id={detailId} className="mb-1 ml-4 mt-1 min-w-0 space-y-2 rounded border p-2" data-challenge-detail={item.unit}>
|
||||
@@ -317,22 +418,43 @@ function ClaimDetail({ item, detail }: { item: ChallengeItem; detail: Detail })
|
||||
part.dataset.flash = 'focus'
|
||||
setTimeout(() => { if (part.dataset.flash === 'focus') delete part.dataset.flash }, 1600)
|
||||
}, [baseId])
|
||||
// 点了「重试」,按钮在加载时就拿掉了:取到了焦点落到理由上,又没取到落到新的「重试」上,不掉到页面上
|
||||
const retried = useRef(false)
|
||||
const landing = useRef<HTMLElement | null>(null)
|
||||
const land = useCallback((el: HTMLElement | null) => { landing.current = el }, [])
|
||||
useEffect(() => {
|
||||
if (!retried.current || (detail.status !== 'ok' && detail.status !== 'error')) return
|
||||
retried.current = false
|
||||
if (focusLost()) landing.current?.focus()
|
||||
}, [detail.status])
|
||||
if (detail.status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-2xs" data-challenge-detail-state="error">
|
||||
<span role="alert" style={{ color: 'var(--st-waiting)' }}>{CHALLENGE_TEXT.failed}</span>
|
||||
{detail.retry && <button type="button" className="btn btn-xs" onClick={detail.retry}>{CHALLENGE_TEXT.retry}</button>}
|
||||
{detail.retry && (
|
||||
<button ref={land} type="button" className="btn btn-xs"
|
||||
onClick={() => { retried.current = true; detail.retry?.() }}>
|
||||
{CHALLENGE_TEXT.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (detail.status !== 'ok') {
|
||||
return <div className="text-2xs text-faint" data-challenge-detail-state="loading">{CHALLENGE_TEXT.loading}</div>
|
||||
}
|
||||
if (!claim) return <div className="text-2xs text-dim" data-challenge-detail-state="missing">{CHALLENGE_TEXT.claimMissing}</div>
|
||||
if (!claim) {
|
||||
return (
|
||||
<div ref={land} tabIndex={-1} className={`text-2xs text-dim ${FOCUS_RING}`}
|
||||
data-challenge-detail-state="missing">
|
||||
{CHALLENGE_TEXT.claimMissing}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const aliases = new Set(claim.queries.map((q) => q.alias))
|
||||
return (
|
||||
<>
|
||||
<section data-challenge-detail-state="ok" data-challenge-rationale="">
|
||||
<section ref={land} tabIndex={-1} className={FOCUS_RING} data-challenge-detail-state="ok" data-challenge-rationale="">
|
||||
<div className="mb-0.5 text-2xs font-medium text-faint">{CHALLENGE_TEXT.rationale}</div>
|
||||
<Rationale claim={claim} onJump={(alias) => (aliases.has(alias) ? jump(alias) : undefined)} />
|
||||
</section>
|
||||
|
||||
@@ -3507,7 +3507,8 @@ export interface ChallengeItem {
|
||||
export interface ApprovalChallenge {
|
||||
counts: ChallengeCounts
|
||||
items: ChallengeItem[]
|
||||
artifact: string
|
||||
/** challenge_artifact 工件 id。服务端保存审稿记录失败时为 null:判断照样有,理由和查询看不了 */
|
||||
artifact: string | null
|
||||
on_refuted?: 'degrade' | 'flag' | (string & {})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ const ORDER = [
|
||||
// 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算;
|
||||
// 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测
|
||||
const ESTIMATE = {
|
||||
challenge: 17,
|
||||
challenge: 30,
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
|
||||
+275
-43
@@ -1,12 +1,15 @@
|
||||
// 反方审稿的回归检查:审批卡上的反方审稿(run/ChallengeSummary.tsx)和检查器里反方审稿节点的配置(canvas/nodeDefs.ts 的
|
||||
// challenge、canvas/verifyFields.tsx)。
|
||||
//
|
||||
// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档、只标注时也写降档、
|
||||
// 探索运行里说出具、没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、
|
||||
// 理由里的数字看不出引用了哪次查询、裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、
|
||||
// 工件取不到时卡死在加载中、条目只列前十句时看不到其余的;检查器里的字段写不进节点配置(或者写进去的不是约定的形状:
|
||||
// 跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了下游的报告、选中的报告被删了还看着像「未选择」、反方模型和
|
||||
// 撰写报告的模型相同不提醒、节点卡片的摘要还写着功能开发中;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。
|
||||
// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、
|
||||
// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时降档提示悄悄消失、没经过反方审稿的报告
|
||||
// 也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
|
||||
// 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、
|
||||
// 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上);
|
||||
// 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了
|
||||
// 下游的报告、留空时认的报告和服务端不一样(「初稿 → 终稿 → 反方审稿」审终稿)、选中的报告被删了还看着像「未选择」、
|
||||
// 选中的不是报告撰写节点却说成不在上游、反方模型和撰写报告的模型相同不提醒、画布校验已经报了的又在字段下面说一遍、
|
||||
// 节点卡片的摘要还写着功能开发中;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。
|
||||
//
|
||||
// 跑在预览页上:/preview.html?syn=challenge-*(用例见 frontend/src/dev/fixtures/challenge.tsx)。夹具在
|
||||
// frontend/src/dev/fixtures/challenge.json,理由文档是后端 run_verify_agent + compose_doc 真跑出来的(虚构的库和数字)。
|
||||
@@ -28,14 +31,27 @@ const ART = fx.challenge.artifact
|
||||
const DOC = fx.doc_u8
|
||||
const MANY = fx.many.artifact
|
||||
const BROKEN = 'e'.repeat(64)
|
||||
// challenge-approval-dup 的工件:同 many,只把补出来的 u30(被推翻)的原文改成和条目 u20 一字不差
|
||||
const DUP = 'd'.repeat(64)
|
||||
const DUP_CONTENT = {
|
||||
...fx.artifacts[MANY],
|
||||
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
|
||||
const RUN_CLASS = {
|
||||
[RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal',
|
||||
[RUNS.many]: 'formal', [RUNS.broken]: 'formal',
|
||||
[RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal',
|
||||
[RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal',
|
||||
}
|
||||
const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }
|
||||
const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位'
|
||||
const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具'
|
||||
const FAILED = '无法获取反方审稿的详细内容'
|
||||
// F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面
|
||||
const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份'
|
||||
const SERVER_GHOST = '「反方审稿」的「要审的报告」选择了「ghost」,但工作流中不存在这个节点'
|
||||
const SERVER_SAME = '反方模型与报告「写月报」的写作模型相同(「writer-large」),等于自己审自己,难以发现写作模型自身的错误。'
|
||||
+ '请在「反方模型」中选择另一个模型'
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
@@ -54,7 +70,8 @@ async function section(name, fn) {
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/**
|
||||
* 开一个预览用例。工件按夹具答(BROKEN 先答 404,state.broken 置 false 后答正常内容),运行类别按 RUN_CLASS 答;
|
||||
* 开一个预览用例。工件按夹具答(BROKEN 先答 404,state.broken 置 false 后答正常内容),运行类别按 RUN_CLASS 答
|
||||
* (runfail 先答 500,state.runFail 置 false 后照常答),画布校验答 state.issues;
|
||||
* 记下取了哪些工件、问了哪些运行、拦下了哪些写请求
|
||||
*/
|
||||
async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
@@ -62,14 +79,16 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
ctx.setDefaultTimeout(6000)
|
||||
const page = await ctx.newPage()
|
||||
const hits = { artifacts: [], runs: [], writes: [] }
|
||||
const state = { broken: true }
|
||||
const state = { broken: true, runFail: true, issues: [] }
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
|
||||
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
|
||||
const req = route.request()
|
||||
const path = new URL(req.url()).pathname
|
||||
if (req.method() === 'POST' && path === '/api/workflows/validate') return route.fulfill({ json: { ok: true, issues: [] } })
|
||||
if (req.method() === 'POST' && path === '/api/workflows/validate') {
|
||||
return route.fulfill({ json: { ok: !state.issues.some((i) => i.level === 'error'), issues: state.issues } })
|
||||
}
|
||||
if (req.method() === 'POST' && path === '/api/workflows/variables') return route.fulfill({ json: { variables: [], issues: [] } })
|
||||
// 画布载入一张图时会找(没有就建)它的助手会话:答一份假的,不碰真实会话
|
||||
if (path === '/api/conversations') {
|
||||
@@ -84,6 +103,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
const id = decodeURIComponent(art[1])
|
||||
hits.artifacts.push(id)
|
||||
if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } })
|
||||
if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } })
|
||||
const content = fx.artifacts[id]
|
||||
return content ? route.fulfill({ json: { id, content } })
|
||||
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
|
||||
@@ -92,6 +112,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
if (run) {
|
||||
const id = decodeURIComponent(run[1])
|
||||
hits.runs.push(id)
|
||||
if (id === RUNS.runfail && state.runFail) 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: '运行不存在' } })
|
||||
}
|
||||
@@ -128,8 +149,13 @@ await section('审批卡:概况与降档提示', async () => {
|
||||
&& counts.refuted === '被推翻 1 句' && counts.inconclusive === '无法判断 1 句', JSON.stringify(counts))
|
||||
await page.waitForSelector('[data-challenge-degrade]')
|
||||
check('正式运行、没写处理方式(按降档)、有被推翻的:醒目提示「出具会降档」',
|
||||
(await text(page.locator('[data-challenge-degrade]'))).startsWith('出具会降档'))
|
||||
(await text(page.locator('[data-challenge-degrade]'))) === '出具会降档')
|
||||
// 原因不能只放在悬停提示和读屏文字里:触屏、键盘用户看不到(只标注那一档反而有常显的说明)
|
||||
const note = page.locator('[data-challenge-degrade-note]')
|
||||
check('降档的原因常显在概况下面,不靠悬停', await note.count() === 1 && await note.isVisible() && (await text(note)) === DEGRADE_NOTE
|
||||
&& (await page.locator('[data-challenge-degrade]').getAttribute('title')) === null, await note.count() ? await text(note) : '没有')
|
||||
check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0)
|
||||
check('运行类别取到了:没有「暂时无法确认运行类别」', await page.locator('[data-challenge-runclass]').count() === 0)
|
||||
const rows = await block.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => ({
|
||||
unit: el.getAttribute('data-challenge-item'),
|
||||
tag: el.querySelector('[data-challenge-tag]')?.textContent?.trim(),
|
||||
@@ -225,41 +251,96 @@ await section('点开一句:取工件,显示理由与查询', async () => {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('只标注、探索运行、没有反方审稿', async () => {
|
||||
{
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-flag')
|
||||
// 下面几段各开各的页面、各自成段:一段中途出错(比如等一个不该出现的元素超时)不遮住其余几段
|
||||
await section('只标注:不写降档', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-flag')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.flag)
|
||||
const all = await text(page.locator('[data-challenge="write"]'))
|
||||
check('只标注:不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0
|
||||
&& !all.includes('降档'), all)
|
||||
check('只标注:写明被推翻的结论只做标注', (await text(page.locator('[data-challenge-flag]'))) === FLAG_NOTE)
|
||||
check('只标注:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('探索运行:不说出具', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-explore')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.explore)
|
||||
const all = await text(page.locator('[data-challenge="write"]'))
|
||||
check('探索运行:反方审稿里不说任何和出具有关的话', !all.includes('出具')
|
||||
&& await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-flag]').count() === 0, all)
|
||||
check('探索运行:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句'
|
||||
&& await page.locator('[data-challenge-item]').count() === 4)
|
||||
check('探索运行:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('探索运行配只标注:同样不说出具', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-explore-flag')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.exploreFlag)
|
||||
const all = await text(page.locator('[data-challenge="write"]'))
|
||||
check('探索运行、只标注:不写「只做标注、不影响出具档位」', await page.locator('[data-challenge-flag]').count() === 0 && !all.includes('出具'), all)
|
||||
check('探索运行、只标注:也不写降档', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0)
|
||||
check('探索运行、只标注:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('正式运行、没有被推翻的结论:不写降档', async () => {
|
||||
for (const [syn, run, what] of [['challenge-approval-clear', RUNS.clear, '按降档'], ['challenge-approval-clear-flag', RUNS.clearFlag, '只标注']]) {
|
||||
const { ctx, page, hits, errors } = await open(syn)
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.flag)
|
||||
await runClassSettled(page, hits, run)
|
||||
const all = await text(page.locator('[data-challenge="write"]'))
|
||||
check('只标注:不写「出具会降档」', await page.locator('[data-challenge-degrade]').count() === 0 && !all.includes('降档'), all)
|
||||
check('只标注:写明被推翻的结论只做标注', (await text(page.locator('[data-challenge-flag]'))) === FLAG_NOTE)
|
||||
check('只标注:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-explore')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.explore)
|
||||
const all = await text(page.locator('[data-challenge="write"]'))
|
||||
check('探索运行:反方审稿里不说任何和出具有关的话', !all.includes('出具') && await page.locator('[data-challenge-degrade], [data-challenge-flag]').count() === 0, all)
|
||||
check('探索运行:概况和条目照常显示', (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 1 句'
|
||||
&& await page.locator('[data-challenge-item]').count() === 4)
|
||||
check('探索运行:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
{
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-none')
|
||||
await page.locator('[data-approval-report="write"]').waitFor()
|
||||
await page.waitForTimeout(500)
|
||||
check('报告没有经过反方审稿:什么都不画', await page.locator('[data-challenge]').count() === 0)
|
||||
check('报告没有经过反方审稿:不问运行类别、不取工件', !hits.runs.includes(RUNS.none) && hits.artifacts.length === 0,
|
||||
`${hits.runs.join('、')} / ${hits.artifacts.join('、')}`)
|
||||
check('报告核对概况照常显示', (await text(page.locator('[data-approval-report="write"]'))).includes('报告「写月报」的核对'))
|
||||
check('没有反方审稿:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
check(`正式运行、${what}、被推翻 0 句:概况照实写 0 句`, (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 0 句'
|
||||
&& await page.locator('[data-challenge-item]').count() === 3, all)
|
||||
check(`正式运行、${what}、被推翻 0 句:既不写降档,也不写只做标注`,
|
||||
await page.locator('[data-challenge-degrade], [data-challenge-degrade-note], [data-challenge-flag]').count() === 0
|
||||
&& !all.includes('降档') && !all.includes('只做标注'), all)
|
||||
check(`正式运行、${what}、被推翻 0 句:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('运行类别取不到:说明并可重试', async () => {
|
||||
const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await runClassSettled(page, hits, RUNS.runfail)
|
||||
const failedLine = page.locator('[data-challenge-runclass="failed"]')
|
||||
check('取不到:不写降档,也不装作没事——写明暂时无法确认运行类别', await page.locator('[data-challenge-degrade]').count() === 0
|
||||
&& await failedLine.count() === 1 && (await text(failedLine)).startsWith('暂时无法确认运行类别'),
|
||||
await failedLine.count() ? await text(failedLine) : '没有说明')
|
||||
// 窗口重新拿到焦点时再取一次(store 对出错的槽会重发)
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||||
for (let i = 0; i < 30 && hits.runs.filter((r) => r === RUNS.runfail).length < 2; i++) await page.waitForTimeout(100)
|
||||
check('窗口重新拿到焦点:再取一次运行类别', hits.runs.filter((r) => r === RUNS.runfail).length === 2, hits.runs.join('、'))
|
||||
state.runFail = false
|
||||
await failedLine.locator('button', { hasText: '重试' }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector('[data-challenge-degrade]')
|
||||
check('按重试:取到正式运行后写「出具会降档」,说明随之消失', await failedLine.count() === 0
|
||||
&& hits.runs.filter((r) => r === RUNS.runfail).length === 3, hits.runs.join('、'))
|
||||
await page.waitForTimeout(100)
|
||||
check('按重试:那一行拿掉后焦点接到概况上,不掉到页面上', await page.evaluate(() =>
|
||||
document.activeElement?.hasAttribute('data-challenge-counts') ?? false), await page.evaluate(() => document.activeElement?.tagName))
|
||||
check('取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('没有反方审稿:什么都不画', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-none')
|
||||
await page.locator('[data-approval-report="write"]').waitFor()
|
||||
await page.waitForTimeout(500)
|
||||
check('报告没有经过反方审稿:什么都不画', await page.locator('[data-challenge]').count() === 0)
|
||||
check('报告没有经过反方审稿:不问运行类别、不取工件', !hits.runs.includes(RUNS.none) && hits.artifacts.length === 0,
|
||||
`${hits.runs.join('、')} / ${hits.artifacts.join('、')}`)
|
||||
check('报告核对概况照常显示', (await text(page.locator('[data-approval-report="write"]'))).includes('报告「写月报」的核对'))
|
||||
check('没有反方审稿:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('工件取不到:说明并可重试', async () => {
|
||||
const { ctx, page, state, hits, errors } = await open('challenge-approval-broken')
|
||||
await toggle(page, 'u3').click()
|
||||
@@ -268,10 +349,16 @@ await section('工件取不到:说明并可重试', async () => {
|
||||
&& await detail(page, 'u3').locator('button', { hasText: '重试' }).count() === 1)
|
||||
check('取不到:不停在「正在加载」', await page.locator('[data-challenge-detail-state="loading"]').count() === 0)
|
||||
state.broken = false
|
||||
await detail(page, 'u3').locator('button', { hasText: '重试' }).click()
|
||||
// 用键盘按重试:按钮在加载时就拿掉了,取到之后焦点得接到理由上
|
||||
await detail(page, 'u3').locator('button', { hasText: '重试' }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await detail(page, 'u3').locator('[data-challenge-doc]').waitFor()
|
||||
check('重试:再取一次,取到后显示理由', hits.artifacts.filter((a) => a === BROKEN).length === 2
|
||||
&& (await text(detail(page, 'u3').locator('[data-challenge-doc]'))).includes('151,000'))
|
||||
await page.waitForTimeout(100)
|
||||
check('键盘按重试:取到后焦点落到这一句的理由上,不掉到页面上', await page.evaluate(() =>
|
||||
document.activeElement?.matches('[data-challenge-detail="u3"] [data-challenge-rationale]') ?? false),
|
||||
await page.evaluate(() => document.activeElement?.tagName))
|
||||
check('取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -284,10 +371,18 @@ await section('超过十句:显示全部', async () => {
|
||||
&& (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 2 句')
|
||||
check('写明另有几句未列出', (await text(page.locator('[data-challenge-more]'))).startsWith('另有 2 句未列出'))
|
||||
check('没要之前不取工件', hits.artifacts.length === 0)
|
||||
await page.locator('[data-challenge-show-all]').click()
|
||||
// 用键盘按:按钮所在那一行补齐后会拿掉,焦点得落到补出来的第一句上,不能掉到页面上、下一个 Tab 跳过新补的几句
|
||||
await page.locator('[data-challenge-show-all]').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-challenge-item]').length === 12)
|
||||
check('显示全部:从工件里补齐其余两句,排在后面', JSON.stringify(await page.locator('[data-challenge-item]').evaluateAll((els) =>
|
||||
els.slice(10).map((el) => el.getAttribute('data-challenge-item')))) === JSON.stringify(['u30', 'u31']) && hits.artifacts.includes(MANY))
|
||||
await page.waitForTimeout(100)
|
||||
const focused = await page.evaluate(() => ({
|
||||
toggle: document.activeElement?.hasAttribute('data-challenge-toggle') ?? false,
|
||||
unit: document.activeElement?.closest('[data-challenge-item]')?.getAttribute('data-challenge-item') ?? document.activeElement?.tagName,
|
||||
}))
|
||||
check('键盘按「显示全部」:焦点落到补出来的第一句上', focused.toggle && focused.unit === 'u30', JSON.stringify(focused))
|
||||
check('显示全部之后不再写「另有」', await page.locator('[data-challenge-more]').count() === 0)
|
||||
check('补出来的一句带着自己的判断标签', (await text(item(page, 'u30').locator('[data-challenge-tag]'))) === '被推翻')
|
||||
await toggle(page, 'u30').click()
|
||||
@@ -298,6 +393,43 @@ await section('超过十句:显示全部', async () => {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('显示全部:原文相同、unit 不同的一句不被吞掉', async () => {
|
||||
// 同一句结论在摘要和正文各出现一次、各自被选中审稿:补的时候按 unit 认,不按原文
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-dup')
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
await page.locator('[data-challenge-show-all]').click()
|
||||
await item(page, 'u31').waitFor()
|
||||
const units = await page.locator('[data-challenge-item]').evaluateAll((els) => els.map((el) => el.getAttribute('data-challenge-item')))
|
||||
check('补齐十二句,原文和 u20 相同的 u30 也在', units.length === 12 && units.includes('u30') && hits.artifacts.includes(DUP), JSON.stringify(units))
|
||||
check('补出来的 u30 带着自己的判断(被推翻)', await item(page, 'u30').count() === 1
|
||||
&& (await text(item(page, 'u30').locator('[data-challenge-tag]'))) === '被推翻')
|
||||
await toggle(page, 'u20').click()
|
||||
await detail(page, 'u20').locator('[data-challenge-rationale-text]').waitFor()
|
||||
check('点开原文相同的 u20:显示的是 u20 自己的理由', !(await text(detail(page, 'u20'))).includes('白汀区的退款金额高于云屿区这一说法与查询结果不符'),
|
||||
await text(detail(page, 'u20')))
|
||||
check('原文相同:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('审稿记录没存上:不能展开,说明原因', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval-norecord')
|
||||
const block = page.locator('[data-challenge="write"]')
|
||||
await block.waitFor()
|
||||
await page.waitForTimeout(300)
|
||||
check('概况、判断照常列出', (await text(block.locator('[data-challenge-count="stands"]'))) === '经受住 7 句'
|
||||
&& await block.locator('[data-challenge-item]').count() === 10)
|
||||
check('每句不再是能点开的按钮(点开也看不到东西)', await block.locator('[data-challenge-toggle]').count() === 0
|
||||
&& await block.locator('[data-challenge-item] [data-challenge-tag]').count() === 10)
|
||||
const norecord = block.locator('[data-challenge-norecord]')
|
||||
check('整块说一句审稿记录没存上、看不了理由和查询', await norecord.count() === 1
|
||||
&& (await text(norecord)) === '反方审稿记录未保存,无法查看理由和查询', await norecord.count() ? await text(norecord) : '没有')
|
||||
check('「另有 N 句未列出」照样交代,只是没有「显示全部」', (await text(block.locator('[data-challenge-more]'))) === '另有 2 句未列出'
|
||||
&& await block.locator('[data-challenge-show-all]').count() === 0)
|
||||
check('没有工件 id:不发取工件的请求', hits.artifacts.length === 0, hits.artifacts.join('、'))
|
||||
check('审稿记录没存上:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('检查器:字段写进节点配置', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-inspector', { width: 1280, height: 1000 })
|
||||
await page.locator('[data-field="report"]').waitFor()
|
||||
@@ -356,7 +488,10 @@ await section('检查器:字段写进节点配置', async () => {
|
||||
const group = page.locator('[data-field="enabled"] [role="radiogroup"]')
|
||||
check('启用:一组单选,组名是「启用」,默认跟随设置', (await group.getAttribute('aria-label')) === '启用'
|
||||
&& (await group.getAttribute('data-tri-state')) === 'follow' && !('enabled' in (await config(page))))
|
||||
check('跟随设置时说明跟的是哪一项', (await text(page.locator('[data-tri-hint]'))) === '使用「设置 → 核查」中「反方审稿 · 启用」的值')
|
||||
// 指到真实存在的入口:设置页的标签是 模型接入 / 偏好设置 / 运行环境,「核查」一节在偏好设置里
|
||||
check('跟随设置时说明跟的是哪一项(入口写全:设置 → 偏好设置 → 核查)',
|
||||
(await text(page.locator('[data-tri-hint]'))) === '使用「设置 → 偏好设置 → 核查」中「反方审稿 · 启用」的值')
|
||||
check('检查器里提到设置的地方都写全入口', !(await text(panel)).includes('「设置 → 核查」'))
|
||||
await group.locator('[data-tri="off"]').click()
|
||||
check('点关闭:写进 enabled: false', (await config(page))?.enabled === false && (await group.getAttribute('data-tri-state')) === 'off')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
@@ -395,12 +530,86 @@ await section('检查器:字段写进节点配置', async () => {
|
||||
await page.waitForTimeout(100)
|
||||
check('选中的报告已不在画布上:照实标出,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === 'ghost(已不在画布上)'
|
||||
&& await page.locator('[data-node-pick-hint="missing"]').count() === 1)
|
||||
// 选中的是上游的输入节点(助手改图、导入 JSON 会写出这种配置):它在上游,错在类型,不能说成「不在上游」
|
||||
await page.evaluate(() => {
|
||||
const s = window.__studio.getState()
|
||||
const n = s.nodes.find((x) => x.id === 'critic')
|
||||
s.updateNode('critic', { config: { ...n.data.config, report: 'input' } })
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
const notReport = page.locator('[data-node-pick-hint="wrongType"]')
|
||||
check('选中的是上游的非报告节点:下拉照实标「不是报告撰写节点」,提示说清它是哪类节点',
|
||||
(await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '输入(input)(不是报告撰写节点)'
|
||||
&& await notReport.count() === 1 && (await text(notReport)) === '所选节点是「输入」节点,不是报告撰写节点,请重新选择'
|
||||
&& await page.locator('[data-node-pick-hint="notUpstream"]').count() === 0,
|
||||
`${await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)} · ${await notReport.count() ? await text(notReport) : '没有提示'}`)
|
||||
await shot(page, 'inspector-dark')
|
||||
check('检查器:没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、'))
|
||||
check('检查器:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('检查器:链式接法按服务端的认法定要审的报告', async () => {
|
||||
// schema.challenge_report:留空时先看直接连着的报告撰写节点,恰好一个就审它(「初稿 → 终稿 → 反方审稿」审终稿),
|
||||
// 否则才看整个上游是否只有一个
|
||||
const { ctx, page, errors } = await open('challenge-inspector-chain', { width: 1280, height: 1000 })
|
||||
await page.locator('[data-field="report"]').waitFor()
|
||||
const report = page.locator('[data-field="report"] select')
|
||||
const options = await report.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.textContent]))
|
||||
check('留空那一项写出实际会审的那份(终稿),两份上游报告都可选', JSON.stringify(options) === JSON.stringify([
|
||||
['', '未选择:审上游的「终稿」'], ['draft', '初稿(draft)'], ['final', '终稿(final)']]), JSON.stringify(options))
|
||||
check('服务端照常审终稿、不报错:检查器也不要求选择', await page.locator('[data-node-pick-hint]').count() === 0,
|
||||
await page.locator('[data-node-pick-hint]').count() ? await text(page.locator('[data-node-pick-hint]')) : '')
|
||||
check('反方模型和终稿的模型相同:提醒', await page.locator('[data-verify-same-model]').count() === 1
|
||||
&& (await text(page.locator('[data-verify-same-model]'))).includes('「writer-large」'))
|
||||
// 改成审初稿(模型不同):同模型提醒随之消失
|
||||
await report.selectOption('draft')
|
||||
check('改审初稿:配置写进 report,同模型提醒消失', (await config(page))?.report === 'draft'
|
||||
&& await page.locator('[data-verify-same-model]').count() === 0)
|
||||
await shot(page, 'inspector-chain-dark')
|
||||
check('链式接法:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('检查器:画布校验报了就不重复说', async () => {
|
||||
// 服务端(F3a 的 _check_challenge)在同样情形下会发 report / model 字段上的问题,检查器把它们挂在字段下面;
|
||||
// 本地那句提示再画一遍就是同一件事说两遍,还一句琥珀一句红(同结论句裁判「校验也会报,报了就不重复说」)
|
||||
const { ctx, page, state, errors } = await open('challenge-inspector', { width: 1280, height: 1000 })
|
||||
await page.locator('[data-field="report"]').waitFor()
|
||||
const reportField = page.locator('[data-field="report"]')
|
||||
const settle = async (issues, wait) => {
|
||||
state.issues = issues
|
||||
await page.evaluate(() => window.__studio.getState().analyzeNow())
|
||||
if (wait) await page.getByText(wait, { exact: false }).first().waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
check('校验没报时:本地提醒照常(上游有两个)', await page.locator('[data-node-pick-hint="many"]').count() === 1)
|
||||
await settle([{ level: 'error', node_id: 'critic', field: 'report', code: 'challenge.report_invalid', message: SERVER_MANY }], SERVER_MANY)
|
||||
check('校验报了「上游有多个」:字段下只剩服务端那一句', await page.locator('[data-node-pick-hint]').count() === 0
|
||||
&& (await text(reportField)).includes(SERVER_MANY), await text(reportField))
|
||||
await page.evaluate(() => {
|
||||
const s = window.__studio.getState()
|
||||
const n = s.nodes.find((x) => x.id === 'critic')
|
||||
s.updateNode('critic', { config: { ...n.data.config, report: 'ghost' } })
|
||||
})
|
||||
await settle([{ level: 'error', node_id: 'critic', field: 'report', code: 'challenge.report_invalid', message: SERVER_GHOST }], SERVER_GHOST)
|
||||
check('校验报了「不存在这个节点」:不再画本地的「已不在画布上」提示,下拉里照实标出的那一项保留',
|
||||
await page.locator('[data-node-pick-hint]').count() === 0
|
||||
&& (await reportField.locator('select').evaluate((s) => s.options[s.selectedIndex]?.textContent)) === 'ghost(已不在画布上)',
|
||||
await text(reportField))
|
||||
await reportField.locator('select').selectOption('write')
|
||||
await page.locator('[data-field="model"] input').fill('writer-large')
|
||||
await settle([], null)
|
||||
check('校验没报同模型时:本地提醒照常', await page.locator('[data-verify-same-model]').count() === 1)
|
||||
await settle([{ level: 'warning', node_id: 'critic', field: 'model', code: 'challenge.same_model', message: SERVER_SAME }], SERVER_SAME)
|
||||
const modelField = page.locator('[data-field="model"]')
|
||||
check('校验报了「模型相同」:字段下只剩服务端那一句', await page.locator('[data-verify-same-model]').count() === 0
|
||||
&& (await text(modelField)).includes(SERVER_SAME), await text(modelField))
|
||||
await shot(page, 'inspector-dedupe-dark')
|
||||
check('校验报重复:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
/** 页面和某个容器都没有横向滚动(SQL 框自己的滚动条不算) */
|
||||
async function overflow(page, sel) {
|
||||
return page.evaluate((s) => {
|
||||
@@ -465,6 +674,29 @@ await section('窄屏与深浅色', async () => {
|
||||
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
// 这次新加的几行说明(降档原因、运行类别取不到、审稿记录没存上):窄屏不撑宽,深浅色都看得清
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const notes = [
|
||||
['challenge-approval', '[data-challenge-degrade-note]'],
|
||||
['challenge-approval-runfail', '[data-challenge-runclass="failed"] span'],
|
||||
['challenge-approval-norecord', '[data-challenge-norecord]'],
|
||||
]
|
||||
for (const [syn, sel] of notes) {
|
||||
const { ctx, page, errors } = await open(syn, { width: 390, height: 844, theme })
|
||||
await page.locator(sel).waitFor()
|
||||
const o = await overflow(page, '[data-challenge="write"]')
|
||||
const c = await page.evaluate((s) => ({
|
||||
fg: getComputedStyle(document.querySelector(s)).color,
|
||||
bg: getComputedStyle(document.querySelector('[data-challenge="write"]')).backgroundColor,
|
||||
base: getComputedStyle(document.body).backgroundColor,
|
||||
}), sel)
|
||||
const r = contrast(c.fg, c.bg, c.base)
|
||||
check(`390px(${theme})${syn}:说明不横向溢出、对比度 ≥ 4.5`, o.page <= 0 && o.box <= 1 && r >= 4.5, `${JSON.stringify(o)} · ${r.toFixed(2)}`)
|
||||
await shot(page, `${syn.replace('challenge-', '')}-narrow-${theme}`)
|
||||
check(`390px(${theme})${syn}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
{
|
||||
const { ctx, page } = await open('challenge-approval-dense', { width: 1100, height: 900 })
|
||||
await page.locator('[data-challenge="write"]').waitFor()
|
||||
|
||||
Reference in New Issue
Block a user