diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index d479f078..0d7083af 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -96,7 +96,7 @@ function summarize(type: NodeType, config: Record): string { return config.workflow_id ? '嵌套工作流' : '未选择工作流' // 还是占位的两种:卡片上直接说尚未生效,免得以为放上去就会执行 case 'challenge': - return CHALLENGE_TEXT.pending + return CHALLENGE_TEXT.summary(config) default: return '' } diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 8aae5f85..e39f3413 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -8,6 +8,7 @@ import { } from '../lib/terms' import type { NodeType } from '../types' import { dualCheckFields, dualCheckSummary } from './DualCheckFields' +import { nodePickField, triStateField, upstreamModel, verifyModelFields } from './verifyFields' export type FieldType = | 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch' @@ -735,9 +736,44 @@ export const NODE_DEFS: Record = { }, challenge: { type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert, - description: `${CHALLENGE_TEXT.description}。${CHALLENGE_TEXT.pending}`, + description: CHALLENGE_TEXT.description, hasTarget: true, sources: [{ id: 'out', label: '' }], - fields: [], + // 上限、模型、开关都可以不写:服务端按「设置 → 偏好设置 → 核查」里反方审稿那一组取(verify_settings)。 + // 没写的项不往配置里落默认值,设置改了,没单独配过的节点跟着变 + fields: [ + // 留空时先认直接连着的报告(「初稿 → 终稿 → 反方审稿」审终稿),同服务端 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, + }, + // 和撰写报告的模型相同时提醒:同一个模型审自己写的结论,较难发现错误 + ...verifyModelFields({ + labels: { provider: CHALLENGE_TEXT.field.provider, model: CHALLENGE_TEXT.field.model }, + text: CHALLENGE_TEXT.model, subject: upstreamModel('report', 'report', 'direct'), + }), + { + // 没写等于 degrade:降档放第一项,没写时下拉框正好落在它上面 + key: 'on_refuted', label: CHALLENGE_TEXT.field.on_refuted, type: 'select', + options: [ + { value: 'degrade', label: CHALLENGE_TEXT.onRefuted.degrade }, + { value: 'flag', label: CHALLENGE_TEXT.onRefuted.flag }, + { value: 'fail', label: CHALLENGE_TEXT.onRefuted.fail }, + ], + help: CHALLENGE_TEXT.help.on_refuted, unknownLabel: CHALLENGE_TEXT.unknownValue, + }, + triStateField({ key: 'enabled', label: CHALLENGE_TEXT.field.enabled, text: CHALLENGE_TEXT.enabled }), + { + key: 'max_queries', label: CHALLENGE_TEXT.field.max_queries, type: 'number', min: 1, advanced: true, + placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.max_queries, + }, + { + key: 'timeout_s', label: CHALLENGE_TEXT.field.timeout_s, type: 'number', min: 1, advanced: true, + placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.timeout_s, + }, + ], defaults: {}, }, } diff --git a/frontend/src/canvas/verifyFields.tsx b/frontend/src/canvas/verifyFields.tsx new file mode 100644 index 00000000..caac6ab2 --- /dev/null +++ b/frontend/src/canvas/verifyFields.tsx @@ -0,0 +1,348 @@ +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' + +/** + * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,nodeDefs 用下面的工厂函数拼进字段表: + * - nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; + * - triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); + * - verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择。 + * + * 文案一律由调用方传进来(各节点自己那一块的 *_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) + return label === n.id ? label : `${label}(${n.id})` +} + +/** 本节点的全部上游(沿连线往回找)。只认上游:下游的节点运行到这里时还没有产出 */ +function upstreamOf(nodeId: string, edges: Edge[]): Set { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(nodeId) ?? [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id) || id === nodeId) continue + seen.add(id) + stack.push(...(parents.get(id) ?? [])) + } + return seen +} + +/** + * 留空时怎么认,照各节点服务端的认法: + * - 'upstream':整个上游里恰好一个才取它(双盲复算的口径卡,schema.dual_check_card); + * - 'direct':先看直接连着的,恰好一个就是它,否则再看整个上游(反方审稿的报告,schema.challenge_report: + * 「初稿 → 终稿 → 反方审稿」审终稿,不让人在初稿、终稿之间选) + */ +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 ? { 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 + /** 上游不止一个、又没有选 */ + many: (n: number) => string + /** 选中的节点已不在画布上(下拉里那一项的叫法) */ + missing: (id: string) => string + /** 选中的节点不在上游(下拉里那一项的叫法) */ + notUpstream: (label: string) => string + /** 选中的节点不是这一类(下拉里那一项的叫法) */ + wrongType: (label: string) => string + missingHint: string + notUpstreamHint: string + /** type 是选中节点的类型名(「输入」) */ + wrongTypeHint: (type: string) => string +} + +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 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 = !value && pick.kind === 'target' ? String(pick.node.data?.label || pick.node.id) : null + return ( + <> + + {hint && ( +
+ {hint} +
+ )} + + ) +} + +/** + * 从画布上挑本节点上游的某一类节点。配置里存节点 id;留空(写回 undefined,不留空字符串)时服务端按 rule 取缺省的那一个 + */ +export function nodePickField(def: { + 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) => , + } +} + +// ---------------------------------------------------------------- 三态开关 + +export interface TriStateText { + follow: string + on: string + off: string + /** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样 */ + hint: { follow: string; on: string; off: string } +} + +type Tri = 'follow' | 'on' | 'off' +const TRI: readonly Tri[] = ['follow', 'on', 'off'] + +function TriState({ p, label, text }: { p: FieldRenderProps; label: string; text: TriStateText }) { + // 只认真正的布尔:手写的 "false"、0 不当成关闭,按跟随设置显示(服务端校验会报出来) + const state: Tri = p.value === true ? 'on' : p.value === false ? 'off' : 'follow' + // 跟随设置写回 undefined:配置里不留这一项,和没配过的节点一样 + const set = (t: Tri) => p.onChange(t === 'on' ? true : t === 'off' ? false : undefined) + const radio = useRadioGroup(TRI, state, set) + const hintId = `${p.id}-hint` + return ( + <> + {/* 字段标签的 for 指不到一组按钮,组名另写在 aria-label 上 */} +
+ {TRI.map((t) => ( + + ))} +
+
{text.hint[state]}
+ + ) +} + +/** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 null)/ true / false */ +export function triStateField(def: { key: string; label: string; text: TriStateText; advanced?: boolean }): FieldDef { + return { + key: def.key, label: def.label, type: 'select', advanced: def.advanced, + render: (p) => , + } +} + +// ---------------------------------------------------------------- 核查模型 + +export interface VerifyModelText { + /** 接入、模型都留空 */ + providerFollow: string + /** 接入留空、模型填了:服务端按模型名找接入,不再跟随设置 */ + providerFromModel: string + providerMissing: (name: string) => string + /** 接在已停用的接入名后面 */ + providerDisabled: string + /** 模型框的占位:接入也没选 / 选了接入 */ + modelFollow: string + modelDefault: string + /** 取值链的说明,常显在模型框下面 */ + help: string + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => string + /** 和被核查的模型是同一个 */ + same: (model: string) => string +} + +/** 被核查的那个模型:节点上明确写着的模型名;说不准(没写、跟随默认)时为 null,不提醒 */ +export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null + +function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) { + const providers = useCatalog((s) => s.providers) + const provider = typeof p.value === 'string' ? p.value : '' + const model = typeof p.config.model === 'string' ? p.config.model.trim() : '' + const known = provider ? providers.find((x) => x.name === provider) : undefined + return ( + + ) +} + +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() + const pv = provider ? providers.find((x) => x.name === provider) : undefined + const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))] + // 只填了模型:这一级写了就整组用节点上的(verify_model_spec 不跨级拼),服务端按模型名找接入 + const byModel = !provider && !!name + const owner = byModel ? providerOfModel(providers, name) : undefined + const enabled = providers.filter((x) => x.enabled) + 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 && !reported + return ( + <> + p.onChange(e.target.value.trim() ? e.target.value : undefined)} /> + {models.map((m) => + {byModelHint && ( +
+ {byModelHint} +
+ )} + {same && ( +
+ {text.same(name)} +
+ )} +
{text.help}
+ + ) +} + +/** + * 核查模型:接入、模型两个字段(配置里的 provider、model)。都留空时用设置里的核查模型;写了任一项就整组以节点为准。 + * subject 给出被核查的那个模型,相同时提醒(等于自己审自己) + */ +export function verifyModelFields(def: { + labels: { provider: string; model: string }; text: VerifyModelText; subject?: SubjectModel; advanced?: boolean +}): FieldDef[] { + return [ + { + key: 'provider', label: def.labels.provider, type: 'select', advanced: def.advanced, + render: (p) => , + }, + { + key: 'model', label: def.labels.model, type: 'model', advanced: def.advanced, + render: (p) => , + }, + ] +} + +/** + * 被核查节点上写着的模型:refKey 是本节点配置里指向它的那一项(反方审稿的 report),没写时按 rule 认缺省的那一个 + * (和 nodePickField 用同一个 rule,两处认的是同一个节点)。定不下来时服务端只报选报告的错、不比模型,这里也不提醒 + */ +export function upstreamModel(refKey: string, nodeType: NodeType, rule: PickRule = 'upstream'): SubjectModel { + return (nodeId, config, { nodes, edges }) => { + const model = targetNode(nodeId, config[refKey], nodeType, rule, nodes, edges)?.data?.config?.model + return typeof model === 'string' && model.trim() ? model.trim() : null + } +} diff --git a/frontend/src/dev/fixtures/challenge.json b/frontend/src/dev/fixtures/challenge.json new file mode 100644 index 00000000..02e296fd --- /dev/null +++ b/frontend/src/dev/fixtures/challenge.json @@ -0,0 +1,1080 @@ +{ + "//": "反方审稿预览用例与 check-challenge 共用的夹具。理由文档由后端 run_verify_agent + compose_doc 真跑出来(虚构的 SQLite 库),表名、地名、数字都是虚构的", + "runs": { + "formal": "run-chal-formal", + "flag": "run-chal-flag", + "explore": "run-chal-explore", + "none": "run-chal-none", + "many": "run-chal-many", + "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", + "label": "写月报", + "claims": "off", + "stats": { + "numbers": 6, + "numbers_cited": 6, + "uncited_numbers": 0, + "unresolved": 0 + } + }, + "challenge": { + "counts": { + "stands": 1, + "weakened": 1, + "refuted": 1, + "inconclusive": 1 + }, + "items": [ + { + "unit": "u3", + "text": "9 月销售额环比增长 12.4%,增长主要来自新客。", + "verdict": "refuted" + }, + { + "unit": "u5", + "text": "青岚区贡献了一半以上的增长。", + "verdict": "weakened" + }, + { + "unit": "u8", + "text": "9 月退款率保持在 2% 以下。", + "verdict": "stands" + }, + { + "unit": "u11", + "text": "线上渠道的客单价高于门店。", + "verdict": "inconclusive" + } + ], + "artifact": "43381f302615492cab9a2d28acfbc251afb2c2a6a4f35f6e8b6543827b5f7845" + }, + "many": { + "counts": { + "stands": 7, + "weakened": 2, + "refuted": 2, + "inconclusive": 1 + }, + "items": [ + { + "unit": "u20", + "text": "白汀区 9 月订单量高于 8 月。", + "verdict": "stands" + }, + { + "unit": "u21", + "text": "云屿区仍是销售额最高的区。", + "verdict": "stands" + }, + { + "unit": "u22", + "text": "老客复购率明显上升。", + "verdict": "weakened" + }, + { + "unit": "u23", + "text": "退款集中在云屿区。", + "verdict": "stands" + }, + { + "unit": "u24", + "text": "青岚区新客数量翻了一番。", + "verdict": "refuted" + }, + { + "unit": "u25", + "text": "三个区的销售额都在增长。", + "verdict": "stands" + }, + { + "unit": "u26", + "text": "周末订单占比有所提高。", + "verdict": "inconclusive" + }, + { + "unit": "u27", + "text": "大额订单数量与上月持平。", + "verdict": "stands" + }, + { + "unit": "u28", + "text": "客单价的提升主要来自青岚区。", + "verdict": "weakened" + }, + { + "unit": "u29", + "text": "9 月没有出现单日销售额为零的门店。", + "verdict": "stands" + } + ], + "artifact": "dad9224ddaed549de56eac6e96902cf448291e8aaabb1699d09f25b87fc3541d", + "on_refuted": "degrade" + }, + "doc_u8": "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90", + "artifacts": { + "43381f302615492cab9a2d28acfbc251afb2c2a6a4f35f6e8b6543827b5f7845": { + "report": "write", + "doc_artifact": "1b03b7ae4cdda13ae128a29e85ac985a3b1d66041318f4a53166c970e99fdf81", + "claims": [ + { + "unit": "u3", + "text": "9 月销售额环比增长 12.4%,增长主要来自新客。", + "verdict": "refuted", + "rationale": "按客户类型拆分:9 月新客销售额 151,000 元,低于 8 月的 182,000 元;老客从 962,000 元增至 1,135,000 元。增长来自老客,新客反而下降。", + "rationale_doc": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "按客户类型拆分:9 月新客销售额 151,000 元,低于 8 月的 182,000 元;老客从 962,000 元增至 1,135,000 元。增长来自老客,新客反而下降。", + "source": "按客户类型拆分:9 月新客销售额 [[v:Q1.r0.amount]] 元,低于 8 月的 [[v:Q2.r0.amount]] 元;老客从 [[v:Q2.r1.amount]] 元增至 [[v:Q1.r1.amount]] 元。增长来自老客,新客反而下降。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:1db67c3eb22cd18c", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "4a1da399f511fabb37b962da2749e34406f67c56014550a7630f5bb4d98fc163", + "call_id": "u3-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "customer_type", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__lanshan_retail · 2 行" + }, + "Q2": { + "alias": "Q2", + "kind": "query", + "eid": "ev:query:79f93ddf166ebee9", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "b13138a32f067d0d5b330fe15955029a4ed953c64ccc9636dfa9f443f67f7a0d", + "call_id": "u3-c1", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "customer_type", + "amount" + ], + "rows": 2, + "truncated": false, + "label": "db_query__lanshan_retail · 2 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 73 + ], + "cites": [ + "Q1", + "Q2" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "按客户类型拆分:9 月新客销售额 ", + "state": "neutral", + "span": [ + 0, + 17 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "151,000", + "ref": "v:Q1.r0.amount", + "cite": { + "ref": "Q1.r0.amount", + "alias": "Q1", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:0a0f13bc23a46da7", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 151000.0, + "rendered": "151,000" + }, + "state": "deterministic", + "span": [ + 17, + 24 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,低于 8 月的 ", + "state": "neutral", + "span": [ + 24, + 35 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "182,000", + "ref": "v:Q2.r0.amount", + "cite": { + "ref": "Q2.r0.amount", + "alias": "Q2", + "locator": { + "row": 0, + "column": "amount" + }, + "eid": "ev:cell:ba5847ac3ea0a7a4", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 182000.0, + "rendered": "182,000" + }, + "state": "deterministic", + "span": [ + 35, + 42 + ], + "id": "s3" + }, + { + "kind": "text", + "text": " 元;老客从 ", + "state": "neutral", + "span": [ + 42, + 49 + ], + "id": "s4" + }, + { + "kind": "number", + "text": "962,000", + "ref": "v:Q2.r1.amount", + "cite": { + "ref": "Q2.r1.amount", + "alias": "Q2", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:d71b3885da80963a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 962000.0, + "rendered": "962,000" + }, + "state": "deterministic", + "span": [ + 49, + 56 + ], + "id": "s5" + }, + { + "kind": "text", + "text": " 元增至 ", + "state": "neutral", + "span": [ + 56, + 61 + ], + "id": "s6" + }, + { + "kind": "number", + "text": "1,135,000", + "ref": "v:Q1.r1.amount", + "cite": { + "ref": "Q1.r1.amount", + "alias": "Q1", + "locator": { + "row": 1, + "column": "amount" + }, + "eid": "ev:cell:dc6888990b89154a", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 1135000.0, + "rendered": "1,135,000" + }, + "state": "deterministic", + "span": [ + 61, + 70 + ], + "id": "s7" + }, + { + "kind": "text", + "text": " 元。", + "state": "neutral", + "span": [ + 70, + 73 + ], + "id": "s8" + } + ] + }, + { + "id": "u1", + "kind": "claim", + "span": [ + 73, + 87 + ], + "cites": [], + "see": [], + "segments": [ + { + "kind": "text", + "text": "增长来自老客,新客反而下降。", + "state": "neutral", + "span": [ + 73, + 87 + ], + "id": "s9" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 2, + "segments": 10, + "claims": 2, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 4, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 1, + "violations": 0 + }, + "violations": [] + }, + "queries": [ + { + "alias": "Q1", + "artifact": "4a1da399f511fabb37b962da2749e34406f67c56014550a7630f5bb4d98fc163", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE is_refund = 0 AND month = '2026-09' GROUP BY customer_type ORDER BY customer_type", + "row_count": 2, + "truncated": false, + "source": "lanshan_retail" + }, + { + "alias": "Q2", + "artifact": "b13138a32f067d0d5b330fe15955029a4ed953c64ccc9636dfa9f443f67f7a0d", + "sql": "SELECT customer_type, SUM(amount) AS amount FROM orders WHERE is_refund = 0 AND month = '2026-08' GROUP BY customer_type ORDER BY customer_type", + "row_count": 2, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 581, + "output_tokens": 3, + "total_tokens": 584, + "cost_usd": 0.0, + "calls": 4 + } + }, + { + "unit": "u5", + "text": "青岚区贡献了一半以上的增长。", + "verdict": "weakened", + "rationale": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "rationale_doc": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "source": "青岚区增量 [[v:Q1.r0.delta]] 元,是三个区中最多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:b1668f44771fbb87", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "5aae93c426f3710e06d98f0ada1afcc84387a422cf5739802ae6f6ce9069d4bb", + "call_id": "u5-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "district", + "delta" + ], + "rows": 3, + "truncated": false, + "label": "db_query__lanshan_retail · 3 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 58 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "青岚区增量 ", + "state": "neutral", + "span": [ + 0, + 6 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "72,000", + "ref": "v:Q1.r0.delta", + "cite": { + "ref": "Q1.r0.delta", + "alias": "Q1", + "locator": { + "row": 0, + "column": "delta" + }, + "eid": "ev:cell:e6298c09825e45b9", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 72000.0, + "rendered": "72,000" + }, + "state": "deterministic", + "span": [ + 6, + 12 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,是", + "span": [ + 12, + 16 + ], + "state": "neutral", + "id": "s2" + }, + { + "kind": "number", + "text": "三", + "span": [ + 16, + 17 + ], + "state": "none", + "issue": "uncited_number", + "id": "s3" + }, + { + "kind": "text", + "text": "个区中最多的;但白汀区、云屿区合计增量也有 ", + "span": [ + 17, + 39 + ], + "state": "neutral", + "id": "s4" + }, + { + "kind": "number", + "text": "70000", + "span": [ + 39, + 44 + ], + "state": "none", + "issue": "uncited_number", + "id": "s5" + }, + { + "kind": "text", + "text": " 元,青岚区的占比不到", + "span": [ + 44, + 55 + ], + "state": "neutral", + "id": "s6" + }, + { + "kind": "number", + "text": "一半", + "span": [ + 55, + 57 + ], + "state": "none", + "issue": "uncited_number", + "id": "s7" + }, + { + "kind": "text", + "text": "。", + "span": [ + 57, + 58 + ], + "state": "neutral", + "id": "s8" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 9, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 4, + "numbers_cited": 1, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 3, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 3 + }, + "violations": [ + { + "code": "uncited_number", + "message": "数字「三」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「三」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 16, + 17 + ], + "text": "三", + "context": "青岚区增量 72,000 元,是三个区中最多的;但白汀区、云屿区合计增", + "segment": "s3", + "unit": "u0" + }, + { + "code": "uncited_number", + "message": "数字「70000」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「70000」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 39, + 44 + ], + "text": "70000", + "context": "多的;但白汀区、云屿区合计增量也有 70000 元,青岚区的占比不到一半。", + "segment": "s5", + "unit": "u0" + }, + { + "code": "uncited_number", + "message": "数字「一半」没有出处:报告中直接写出了数字,系统无法核对", + "for_model": "数字「一半」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字", + "span": [ + 55, + 57 + ], + "text": "一半", + "context": "有 70000 元,青岚区的占比不到一半。", + "segment": "s7", + "unit": "u0" + } + ] + }, + "queries": [ + { + "alias": "Q1", + "artifact": "5aae93c426f3710e06d98f0ada1afcc84387a422cf5739802ae6f6ce9069d4bb", + "sql": "SELECT district, SUM(CASE WHEN month = '2026-09' THEN amount ELSE 0 END) - SUM(CASE WHEN month = '2026-08' THEN amount ELSE 0 END) AS delta FROM orders WHERE is_refund = 0 GROUP BY district ORDER BY delta DESC", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 264, + "output_tokens": 2, + "total_tokens": 266, + "cost_usd": 0.0, + "calls": 3 + } + }, + { + "unit": "u8", + "text": "9 月退款率保持在 2% 以下。", + "verdict": "stands", + "rationale": "9 月退款金额 21,500 元,占当月订单金额的比例为 0.0164,结论成立。", + "rationale_doc": "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90", + "queries": [ + { + "alias": "Q1", + "artifact": "8a0a49da574589f1108757ac8c7bc72eba7d48bb905627a201134764c55beef9", + "sql": "SELECT SUM(CASE WHEN is_refund = 1 THEN amount ELSE 0 END) AS refund, ROUND(SUM(CASE WHEN is_refund = 1 THEN amount ELSE 0 END) * 1.0 / SUM(amount), 4) AS rate FROM orders WHERE month = '2026-09'", + "row_count": 1, + "truncated": false, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 251, + "output_tokens": 2, + "total_tokens": 253, + "cost_usd": 0.0, + "calls": 3 + } + }, + { + "unit": "u11", + "text": "线上渠道的客单价高于门店。", + "verdict": "inconclusive", + "rationale": "数据源中没有区分线上与门店的字段,无法判断这句结论。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "62217061d25cfac1db2b8dd81892066d9b4971a29e4637f1ef14f26eeee3d386", + "sql": "SELECT * FROM orders LIMIT 50", + "row_count": 50, + "truncated": true, + "source": "lanshan_retail" + } + ], + "usage": { + "input_tokens": 233, + "output_tokens": 41, + "total_tokens": 274, + "cost_usd": 0.0012, + "calls": 2 + } + } + ], + "model": "critic-large" + }, + "8da1c00e7a24b0eabe6f33efc13ec5d2e6578fddd8aaf67de5fb9d303cb12b90": { + "schema": "agentlab.report/1", + "entity_syntax": 2, + "provenance": 1, + "run_id": "run-chal-formal", + "node_id": "critic", + "markdown": "9 月退款金额 21,500 元,占当月订单金额的比例为 0.0164,结论成立。", + "source": "9 月退款金额 [[v:Q1.r0.refund]] 元,占当月订单金额的比例为 [[v:Q1.r0.rate]],结论成立。", + "catalog": { + "Q1": { + "alias": "Q1", + "kind": "query", + "eid": "ev:query:054504d0cb205b77", + "locator": {}, + "node_id": "critic", + "exec": 1, + "artifact": "8a0a49da574589f1108757ac8c7bc72eba7d48bb905627a201134764c55beef9", + "call_id": "u8-c0", + "tool": "db_query__lanshan_retail", + "source": "lanshan_retail", + "columns": [ + "refund", + "rate" + ], + "rows": 1, + "truncated": false, + "label": "db_query__lanshan_retail · 1 行" + } + }, + "blocks": [ + { + "type": "paragraph", + "units": [ + { + "id": "u0", + "kind": "claim", + "span": [ + 0, + 41 + ], + "cites": [ + "Q1" + ], + "see": [], + "segments": [ + { + "kind": "text", + "text": "9 月退款金额 ", + "state": "neutral", + "span": [ + 0, + 8 + ], + "id": "s0" + }, + { + "kind": "number", + "text": "21,500", + "ref": "v:Q1.r0.refund", + "cite": { + "ref": "Q1.r0.refund", + "alias": "Q1", + "locator": { + "row": 0, + "column": "refund" + }, + "eid": "ev:cell:d1776657622dc7a2", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 21500.0, + "rendered": "21,500" + }, + "state": "deterministic", + "span": [ + 8, + 14 + ], + "id": "s1" + }, + { + "kind": "text", + "text": " 元,占当月订单金额的比例为 ", + "state": "neutral", + "span": [ + 14, + 29 + ], + "id": "s2" + }, + { + "kind": "number", + "text": "0.0164", + "ref": "v:Q1.r0.rate", + "cite": { + "ref": "Q1.r0.rate", + "alias": "Q1", + "locator": { + "row": 0, + "column": "rate" + }, + "eid": "ev:cell:0a01eeda467fe3ce", + "kind": "cell", + "role": "value", + "status": "resolved", + "value": 0.0164, + "rendered": "0.0164" + }, + "state": "deterministic", + "span": [ + 29, + 35 + ], + "id": "s3" + }, + { + "kind": "text", + "text": ",结论成立。", + "state": "neutral", + "span": [ + 35, + 41 + ], + "id": "s4" + } + ] + } + ], + "id": "b0" + } + ], + "stats": { + "units": 1, + "segments": 5, + "claims": 1, + "connective": 0, + "headings": 0, + "numbers": 2, + "numbers_cited": 2, + "values": 0, + "entities": 0, + "quotes": 0, + "uncited_numbers": 0, + "unresolved": 0, + "unknown_entities": 0, + "unverified_entities": 0, + "see": 0, + "uncited_claims": 0, + "violations": 0 + }, + "violations": [] + }, + "dad9224ddaed549de56eac6e96902cf448291e8aaabb1699d09f25b87fc3541d": { + "report": "write", + "doc_artifact": "1b03b7ae4cdda13ae128a29e85ac985a3b1d66041318f4a53166c970e99fdf81", + "claims": [ + { + "unit": "u20", + "text": "白汀区 9 月订单量高于 8 月。", + "verdict": "stands", + "rationale": "按 orders 表复核:白汀区 9 月订单量高于 8 月这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "cc2aa1d637a2ac89c00a5307661e7ff4f3bac699db375a703bfdcf38201d06dd", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u21", + "text": "云屿区仍是销售额最高的区。", + "verdict": "stands", + "rationale": "按 orders 表复核:云屿区仍是销售额最高的区这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "edf596cc32996af36b5e78fbcc6e60946841e8d6704f494b83a7e9606475a68d", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u22", + "text": "老客复购率明显上升。", + "verdict": "weakened", + "rationale": "按 orders 表复核:老客复购率明显上升这一说法只部分成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "8a66366737b3140d307236b8fce3f0297a3c093b60529e0d3d15a50e13382004", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u23", + "text": "退款集中在云屿区。", + "verdict": "stands", + "rationale": "按 orders 表复核:退款集中在云屿区这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "b6c81578dfbab675d02554e5a5524da25174e636344cfed90131367b0d5e193b", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u24", + "text": "青岚区新客数量翻了一番。", + "verdict": "refuted", + "rationale": "按 orders 表复核:青岚区新客数量翻了一番这一说法与查询结果不符。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "752364b7b25e4de0bee322582d7a205ecab5476ba1f62290b842486883fcbf53", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u25", + "text": "三个区的销售额都在增长。", + "verdict": "stands", + "rationale": "按 orders 表复核:三个区的销售额都在增长这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "fa96ed5ded14063346879e23109aa7200e87ce7ecd0631643ef6512a024f0ea2", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u26", + "text": "周末订单占比有所提高。", + "verdict": "inconclusive", + "rationale": "按 orders 表复核:周末订单占比有所提高这一说法无法从现有数据判断。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "c890124d6e3357b415f1e7a51235ce76657b4519e6013d440855d12a4e9ff6aa", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u27", + "text": "大额订单数量与上月持平。", + "verdict": "stands", + "rationale": "按 orders 表复核:大额订单数量与上月持平这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "2ca8f43235a32539d4f65cacd6d3de6d5f28c618a2ffde74fa2eae0f110d6d5e", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u28", + "text": "客单价的提升主要来自青岚区。", + "verdict": "weakened", + "rationale": "按 orders 表复核:客单价的提升主要来自青岚区这一说法只部分成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "bf09144566e4263917ea89ae2d52f5e23ebab1889c516088c3cd5d0eb4e194bd", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u29", + "text": "9 月没有出现单日销售额为零的门店。", + "verdict": "stands", + "rationale": "按 orders 表复核:9 月没有出现单日销售额为零的门店这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "4ecf3bf8b99e3770a4fbe68dacae228d8b4a71e87e5659a3da2c0bc6c24b9212", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u30", + "text": "白汀区的退款金额高于云屿区。", + "verdict": "refuted", + "rationale": "按 orders 表复核:白汀区的退款金额高于云屿区这一说法与查询结果不符。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "a5c007cfee1b26ede1cb1361d33e007c49f563dc2c8d584660a316ee66089d75", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + }, + { + "unit": "u31", + "text": "9 月销售额创年内新高。", + "verdict": "stands", + "rationale": "按 orders 表复核:9 月销售额创年内新高这一说法成立。", + "rationale_doc": null, + "queries": [ + { + "alias": "Q1", + "artifact": "f5619f980c194fbe53fc1beedc18bbd8448e3352b19440a4a435000527d85ad3", + "sql": "SELECT district, SUM(amount) AS amount FROM orders WHERE month = '2026-09' GROUP BY district", + "row_count": 3, + "truncated": false, + "source": "lanshan_retail" + } + ] + } + ], + "model": "critic-large" + } + } +} \ No newline at end of file diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx new file mode 100644 index 00000000..8c872e24 --- /dev/null +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -0,0 +1,212 @@ +import { useState, type JSX } from 'react' +import { ReactFlow, ReactFlowProvider } from '@xyflow/react' +import { Inspector } from '../../canvas/Inspector' +import { NodeCard } from '../../canvas/NodeCard' +import { ApprovalCard } from '../../run/RunPanel' +import { useCatalog } from '../../store/catalog' +import { useStudio } from '../../store/studio' +import type { Approval, ApprovalChallenge, ApprovalReport, Provider, Workflow } from '../../types' +import fx from './challenge.json' + +/** + * 反方审稿的预览用例,scripts/check-challenge.mjs 按它们检查。夹具在 challenge.json(检查脚本读同一份):理由文档是后端 + * run_verify_agent + compose_doc 真跑出来的,库、表名、地名、数字都是虚构的。 + * + * 审批卡(payload.reports[0].challenge): + * - ?syn=challenge-approval:正式运行,没写 on_refuted(按降档),四种判断各一句,有被推翻的 →「出具会降档」 + * - ?syn=challenge-approval-flag:正式运行,只标注,不写降档 + * - ?syn=challenge-approval-explore:探索运行,不说任何和出具有关的话 + * - ?syn=challenge-approval-none:审的报告没有经过反方审稿,什么都不画 + * - ?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-chain:「初稿 → 终稿 → 反方审稿」,留空时审直接连着的终稿,反方模型和终稿的模型相同 + */ + +const report = fx.report as unknown as ApprovalReport +const challenge = fx.challenge as unknown as ApprovalChallenge + +function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', reviewed: ApprovalReport): Approval { + return { + id, run_id: runId, node_id: 'review', mode: 'approve', title: '本月月报可以发出吗?', status: 'pending', response: {}, + payload: { kind: 'human_node', message: '请审核本月月报。', reports: [reviewed] }, + created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''), + workflow_name: '月报', node_label: '主管审批', run_class: runClass, + } +} + +function Card({ approval, name, dense = false }: { approval: Approval; name: string; dense?: boolean }) { + return ( +
+
+ undefined} /> +
+
+ ) +} + +const RUNS = fx.runs +const withChallenge = (c: Partial): ApprovalReport => ({ ...report, challenge: { ...challenge, ...c } }) +const many = fx.many as unknown as ApprovalChallenge +/** 没有被推翻的结论:同第一个用例去掉被推翻的那句,计数跟着改 */ +const clear: Partial = { + counts: { stands: 1, weakened: 1, refuted: 0, inconclusive: 1 }, + items: challenge.items.filter((it) => it.verdict !== 'refuted'), +} + +// ---------------------------------------------------------------- 检查器 + +const PROVIDERS = [ + { + id: 'pv-main', name: '主力接入', kind: 'anthropic', base_url: null, default_model: 'writer-large', + models: [{ id: 'writer-large' }, { id: 'critic-large' }], enabled: true, extra: {}, api_key_masked: '', has_key: true, + }, + { + id: 'pv-spare', name: '备用接入', kind: 'openai', base_url: null, default_model: 'spare-mini', + models: [{ id: 'spare-mini' }], enabled: false, extra: {}, api_key_masked: '', has_key: true, + }, +] as Provider[] + +const at = (x: number, y: number) => ({ x, y }) +const WORKFLOW = { + id: 'wf-challenge-preview', 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: 'write', type: 'report', position: at(260, 0), data: { label: '写月报', config: { instructions: '写本月月报', model: 'writer-large' } } }, + { id: 'write2', type: 'report', position: at(260, 240), data: { label: '写周报', config: { instructions: '写本周周报' } } }, + { id: 'critic', type: 'challenge', position: at(540, 120), data: { label: '反方审稿', config: {} } }, + { id: 'later', type: 'report', position: at(820, 120), data: { label: '写摘要', config: { instructions: '写一段摘要' } } }, + { id: 'out', type: 'output', position: at(1100, 120), data: { label: '输出', config: {} } }, + ], + edges: [ + { source: 'input', target: 'write' }, { source: 'input', target: 'write2' }, + { source: 'write', target: 'critic' }, { source: 'write2', target: 'critic' }, + { source: 'critic', target: 'later' }, { source: 'later', target: 'out' }, + ], + }, +} 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 } + +/** 同一张图的节点卡片:看反方审稿节点的摘要跟着配置变 */ +function MiniCanvas() { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + return ( + + + + ) +} + +function ConfigView() { + const config = useStudio((s) => s.nodes.find((n) => n.id === 'critic')?.data.config) + return ( +
+      {JSON.stringify(config ?? null, null, 1)}
+    
+ ) +} + +function InspectorCase({ name, workflow }: { name: string; workflow: Workflow }) { + // 首次渲染前装好图和接入:检查器、卡片一挂上就是选中反方审稿节点的样子 + useState(() => { + useCatalog.setState({ providers: PROVIDERS }) + useStudio.getState().load(workflow) + useStudio.getState().select('critic') + return true + }) + return ( +
+
+ +
+
+
+ +
+
+ ) +} + +export const cases: Record JSX.Element> = { + 'challenge-approval': () => ( + + ), + 'challenge-approval-flag': () => ( + + ), + 'challenge-approval-explore': () => ( + + ), + 'challenge-approval-none': () => ( + + ), + 'challenge-approval-many': () => ( + + ), + 'challenge-approval-broken': () => ( + + ), + 'challenge-approval-dense': () => ( + + ), + 'challenge-approval-clear': () => ( + + ), + 'challenge-approval-clear-flag': () => ( + + ), + 'challenge-approval-explore-flag': () => ( + + ), + 'challenge-approval-runfail': () => ( + + ), + 'challenge-approval-dup': () => ( + + ), + 'challenge-approval-norecord': () => ( + + ), + 'challenge-inspector': () => , + 'challenge-inspector-chain': () => , +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8d8f19ad..af39ded1 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3598,10 +3598,111 @@ export const DUAL_CHECK_TEXT = { }, } -/** 反方审稿节点:节点库说明、检查器和运行结果 */ +/** + * 反方审稿节点:节点库说明、检查器、节点卡片摘要,以及审批卡上的反方审稿(run/ChallengeSummary.tsx)。 + * 四种判断全站只有这一种叫法:经受住、被削弱、被推翻、无法判断。判断由反方模型给出,不是系统核对。 + * 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才说;探索运行不出具,不说任何和出具有关的话; + * 运行类别取不到时照实说取不到 + */ export const CHALLENGE_TEXT = { - description: '从反方立场审阅上游报告,逐条提出质疑', - pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`, + description: '从反方立场复核上游报告的关键结论:另一个模型只读查库,逐句判断经受住、被削弱、被推翻还是无法判断', + verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }, + /** 服务端给了一个界面不认识的判断:照写原值,不归到哪一类 */ + verdictUnknown: (v: string) => `未知判断「${v}」`, + + // ---- 节点卡片的摘要。report 是节点 id:卡片摘要只看配置 ---- + summary: (c: { report?: unknown; max_claims?: unknown; enabled?: unknown }) => [ + c.enabled === false ? '已关闭' : '', + typeof c.report === 'string' && c.report.trim() ? `审报告节点 ${c.report.trim()}` : '审上游报告', + typeof c.max_claims === 'number' && c.max_claims > 0 ? `最多审 ${formatNumber(c.max_claims)} 句结论` : '句数跟随设置', + ].filter(Boolean).join(' · '), + + // ---- 检查器 ---- + field: { + report: '要审的报告', + max_claims: '最多审几句', + max_queries: '每句最多查询次数', + timeout_s: '每句时长上限(秒)', + provider: '反方模型 · 接入', + model: '反方模型', + on_refuted: '结论被推翻时', + enabled: '启用', + }, + followSettings: '跟随设置', + help: { + max_claims: '每份报告最多挑几句关键结论来审。留空则使用「设置 → 偏好设置 → 核查」中的值', + max_queries: '复核每句结论时最多执行几次查询。留空则使用「设置 → 偏好设置 → 核查」中的值', + timeout_s: '复核每句结论的时长上限,到时按已查到的结果给出判断。留空则使用「设置 → 偏好设置 → 核查」中的值', + on_refuted: '降档:被推翻的结论计入出具缺口,正式运行降档出具;只标注:在审批卡和运行记录中标出,不影响出具;' + + '报错:本节点失败,运行停止', + }, + onRefuted: { degrade: '出具降档(默认)', flag: '只标注', fail: '报错并停止运行' }, + unknownValue: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, + reportPick: { + auto: (only: string | null) => (only ? `未选择:审上游的「${only}」` : '未选择:审上游的报告撰写节点'), + none: '上游没有报告撰写节点,请先在本节点之前连接一个报告撰写节点', + 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: '使用「设置 → 偏好设置 → 核查」中「反方审稿 · 启用」的值', + on: '无论设置如何都执行反方审稿', + off: '不执行反方审稿,上游的结果原样交给下游,运行记录中记一条说明', + }, + }, + model: { + providerFollow: '跟随设置', + providerFromModel: '按模型名找接入', + providerMissing: (name: string) => `${name}(不存在)`, + providerDisabled: '(已停用)', + modelFollow: '跟随设置', + modelDefault: '留空则使用该接入的默认模型', + help: '接入和模型均未填写时,使用「设置 → 偏好设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,' + + '不与设置合并。建议反方模型与撰写报告的模型不同,同一模型较难发现自身的错误', + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => ( + found && !found.enabled + ? `只填了模型:「${model}」属于已停用的接入「${found.name}」,调用将失败。请启用该接入或选择其他接入` + : found + ? `只填了模型:将按模型名使用接入「${found.name}」。接入和模型都未填写时,才跟随设置中的核查模型` + : `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用;` + + '如该模型不属于默认接入,请选择对应的接入'), + same: (model: string) => `反方模型与撰写报告的模型相同(「${model}」),审稿效果有限`, + }, + + // ---- 审批卡 ---- + head: (label: string) => `报告「${label}」的反方审稿`, + count: (verdict: string, n: number) => `${verdict} ${formatNumber(n)} 句`, + none: '报告中没有需要反方复核的关键结论', + degrade: '出具会降档', + degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具', + flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位', + /** 运行类别没取到:不知道是不是正式运行,照实说,并写出正式运行时的后果(旁边给重试) */ + runClassFailed: '暂时无法确认运行类别;如为正式运行,被推翻的结论会使出具降档', + /** 审批里没带工件(服务端保存审稿记录失败):每句都看不了理由和查询,整块说一次 */ + noRecord: '反方审稿记录未保存,无法查看理由和查询', + more: (n: number) => `另有 ${formatNumber(n)} 句未列出`, + showAll: '显示全部', + loading: '正在加载反方审稿…', + failed: '无法获取反方审稿的详细内容', + retry: '重试', + claimMissing: '反方审稿记录中没有这一句的详细内容', + judgedBy: (model: string | null) => `判断由反方模型${model ? `「${model}」` : ''}给出,并非系统核对`, + rationale: '理由', + rationaleEmpty: '反方没有给出理由', + docLoading: '正在加载理由…', + queries: (n: number) => `反方执行的查询(${formatNumber(n)} 次)`, + noQueries: '反方没有执行查询', + rows: (n: number) => `${formatNumber(n)} 行`, + truncated: '结果已截断,数据库中还有更多数据', + /** 理由里有出处的数字:读屏听到的是「数字,出处:查询 Q2 · 第 1 行 · amount」,点一下跳到那次查询 */ + cited: (text: string, source: string) => `${text},出处:${source},跳到这次查询`, } /** diff --git a/frontend/src/run/ChallengeSummary.tsx b/frontend/src/run/ChallengeSummary.tsx index e901b8b1..e09907ea 100644 --- a/frontend/src/run/ChallengeSummary.tsx +++ b/frontend/src/run/ChallengeSummary.tsx @@ -1,4 +1,14 @@ -import type { ApprovalReport } from '../types' +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' +import { CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT } from '../lib/terms' +import { asDoc, useEvidence } from '../store/evidence' +import type { + ApprovalChallenge, ApprovalReport, ChallengeArtifact, ChallengeClaim, ChallengeCounts, ChallengeItem, ChallengeQuery, + ChallengeVerdict, EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, +} from '../types' +import { BlockView, CopyChip, Markdown, StyledSlice, inlineStyles, numericColumns } from './Markdown' export interface ChallengeSummaryProps { /** 这张审批卡审的报告(payload.reports,和 ApprovalReports 拿到的是同一份) */ @@ -7,11 +17,642 @@ export interface ChallengeSummaryProps { } /** - * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面。 + * 审批卡里反方审稿的摘要,紧跟在报告核对概况(ApprovalReports)后面:一行概况(四种判断各几句),逐句列出结论原文和判断, + * 点开一句再按 challenge_artifact 取完整内容,看反方的理由和它执行过的查询。审稿记录没存上(没有工件)时只列判断、 + * 不能点开,整块说一次。 * - * 现在是占位,什么都不画;反方审稿的工作包在这里补上反驳意见的摘要。审的不是报告、或者报告没经过 - * 反方审稿时也应该什么都不画,卡片和以前一样。 + * 判断是反方模型给的,不是系统核对:颜色照结论句裁判的那一套,「经受住」也不用确定性的绿。 + * 「出具会降档」只在正式运行、有被推翻的结论、按降档处理时才写——只标注的不写降档,探索运行不出具,和出具有关的话一句不说。 + * 运行类别不由审批卡传进来,按运行 id 从证据 store 取(loadRunClass),取到之前不说;取不到时照实说取不到,可重试。 + * + * 审的报告都没有经过反方审稿时什么都不画,也不发任何请求,卡片和以前一样。 */ -export function ChallengeSummary(_props: ChallengeSummaryProps) { - return null +export function ChallengeSummary({ reports, runId }: ChallengeSummaryProps) { + const reviewed = useMemo(() => reports.flatMap((r) => { + const challenge = challengeOf(r) + return challenge ? [{ report: r, challenge }] : [] + }), [reports]) + if (!reviewed.length) return null + return +} + +function ChallengeList({ reviewed, runId }: { + reviewed: { report: ApprovalReport; challenge: ApprovalChallenge }[]; runId: string +}) { + const run = useRunClass(runId) + return ( + <> + {reviewed.map(({ report, challenge }) => ( + + ))} + + ) +} + +// ---------------------------------------------------------------- 认 payload + +const VERDICTS: readonly ChallengeVerdict[] = ['stands', 'weakened', 'refuted', 'inconclusive'] +const isVerdict = (v: unknown): v is ChallengeVerdict => typeof v === 'string' && (VERDICTS as readonly string[]).includes(v) + +/** 颜色同结论句裁判(lib/evidence 的 EVIDENCE_STATE):经受住同「有依据」,被削弱同「部分有依据」,被推翻同「证据相矛盾」 */ +const VERDICT_COLOR: Record = { + stands: 'var(--st-running)', + weakened: 'var(--st-waiting)', + refuted: 'var(--st-failed)', + inconclusive: 'var(--st-cancelled)', +} + +const verdictLabel = (v: string) => (isVerdict(v) ? CHALLENGE_TEXT.verdict[v] : CHALLENGE_TEXT.verdictUnknown(v)) + +function isItem(x: unknown): x is ChallengeItem { + return !!x && typeof x === 'object' && typeof (x as ChallengeItem).text === 'string' && typeof (x as ChallengeItem).verdict === 'string' +} + +const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0) + +/** + * payload.reports[i].challenge 认不认得:要么有计数、要么有条目。计数缺了按条目数(条目最多 10 条,计数才是全部); + * 条目缺了 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 + const items = (Array.isArray(c.items) ? c.items.filter(isItem) : []) + .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 : null + const counts = Object.fromEntries(VERDICTS.map((v) => [v, given + ? 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 ? c.artifact : null, + on_refuted: typeof c.on_refuted === 'string' ? c.on_refuted : undefined, + } +} + +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]) + 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 } +} + +// ---------------------------------------------------------------- 取工件 + +/** 工件 id → 取回的内容。工件内容寻址、不可变,取到的永久缓存;出错的不缓存成定论,下次点开再取 */ +const fetched = new Map>() + +function loadChallenge(id: string): Promise { + let pending = fetched.get(id) + if (!pending) { + pending = api.artifact(id).then((res) => { + const content = asChallenge(res?.content) + if (!content) throw new Error(CHALLENGE_TEXT.failed) + return content + }) + pending.catch(() => { fetched.delete(id) }) + fetched.set(id, pending) + } + return pending +} + +const text = (v: unknown) => (typeof v === 'string' ? v : '') + +function asQuery(x: unknown): ChallengeQuery | null { + if (!x || typeof x !== 'object') return null + const q = x as Record + return { + alias: text(q.alias), artifact: text(q.artifact), sql: text(q.sql), + row_count: typeof q.row_count === 'number' && Number.isFinite(q.row_count) ? q.row_count : null, + truncated: q.truncated === true, source: typeof q.source === 'string' && q.source ? q.source : null, + } +} + +/** 工件内容认不认得:至少得有 claims 列表;每一句缺的字段按空的补,界面照实少显示 */ +function asChallenge(content: unknown): ChallengeArtifact | null { + if (!content || typeof content !== 'object' || !Array.isArray((content as ChallengeArtifact).claims)) return null + const c = content as Record + const claims: ChallengeClaim[] = (c.claims as unknown[]).filter(isItem).map((it) => { + const raw = it as unknown as Record + const doc = raw.rationale_doc + return { + unit: text(raw.unit), text: it.text, verdict: it.verdict, rationale: text(raw.rationale), + rationale_doc: typeof doc === 'string' || (doc && typeof doc === 'object') ? doc as ChallengeClaim['rationale_doc'] : null, + queries: (Array.isArray(raw.queries) ? raw.queries : []).map(asQuery).filter((q): q is ChallengeQuery => !!q), + } + }) + return { report: text(c.report), doc_artifact: text(c.doc_artifact), claims, model: typeof c.model === 'string' && c.model ? c.model : null } +} + +type Fetch = { status: 'idle' | 'loading' | 'ok' | 'error'; data?: ChallengeArtifact } +/** retry 没有时不给重试:审批里根本没带工件 id,再取也是一样 */ +type Detail = Fetch & { retry?: () => void } + +/** 整份反方审稿:want 为真(点开过一句、要看全部)之后才取,一张卡里各句共用这一次 */ +function useChallengeArtifact(id: string, want: boolean): Detail { + const [state, setState] = useState({ status: 'idle' }) + const [attempt, setAttempt] = useState(0) + useEffect(() => { + if (!want || !id) return + let alive = true + setState((s) => (s.status === 'ok' ? s : { status: 'loading' })) + loadChallenge(id).then( + (data) => { if (alive) setState({ status: 'ok', data }) }, + () => { if (alive) setState({ status: 'error' }) }, + ) + return () => { alive = false } + }, [id, want, attempt]) + const retry = useCallback(() => setAttempt((n) => n + 1), []) + return id ? { ...state, retry } : { status: 'error' } +} + +/** + * 工件里的一句和审批条目里的一句是不是同一句:两边都有 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 { + 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, run }: { + report: ApprovalReport; challenge: ApprovalChallenge; run: RunClass +}) { + const [open, setOpen] = useState>(() => new Set()) + const [all, setAll] = useState(false) + const detail = useChallengeArtifact(challenge.artifact ?? '', all || open.size > 0) + const { counts, items } = challenge + const total = VERDICTS.reduce((n, v) => n + counts[v], 0) + // 审批里没带工件:服务端保存审稿记录失败了。每句都看不了理由和查询,不画成能点开的样子,整块说一次 + 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) => sameClaim(c, it))) : [] + const rows: ChallengeItem[] = [...items, ...extra] + const hidden = Math.max(0, total - items.length) + const toggle = (key: string) => setOpen((cur) => { + const next = new Set(cur) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + + // 「显示全部」那一行补齐后整行拿掉、取的时候按钮也停用,焦点会掉到页面上,下一个 Tab 就跳过了新补出来的几句: + // 按过「显示全部」或那一行的「重试」之后,取到了落到补出来的第一句上(计数和工件对不上、什么也没补到时落到最后一句), + // 又没取到落到那一行新出现的「重试」上 + const blockRef = useRef(null) + const moreRef = useRef(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('button')?.focus() + return + } + const toggles = blockRef.current?.querySelectorAll('[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(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 ( +
+
+ {CHALLENGE_TEXT.head(report.label || report.node_id)} +
+ {total > 0 || items.length ? ( +
+ {VERDICTS.map((v, i) => ( + + {i > 0 && ·} + + {CHALLENGE_TEXT.count(CHALLENGE_TEXT.verdict[v], counts[v])} + + + ))} + {refuted && !flagged && ( + + {CHALLENGE_TEXT.degrade} + + )} +
+ ) : ( +
{CHALLENGE_TEXT.none}
+ )} + {/* 降档的原因常显,不放在悬停提示里:触屏、键盘用户也得看得到为什么、会怎样(只标注那一档同样常显一行) */} + {refuted && !flagged && ( +
{CHALLENGE_TEXT.degradeHint}
+ )} + {refuted && flagged && ( +
{CHALLENGE_TEXT.flagNote}
+ )} + {/* 运行类别取不到:不知道是不是正式运行,不能装作没事(卡顶按审批自带的运行类别照样写着正式运行),照实说并可重试 */} + {counts.refuted > 0 && !flagged && run.failed && ( +
+ {CHALLENGE_TEXT.runClassFailed} + +
+ )} + {rows.length > 0 && ( +
    + {rows.map((item, i) => { + const key = `${item.unit}\u0000${i}` + if (noRecord) { + return ( +
  • + +
  • + ) + } + return ( + toggle(key)}> + + + ) + })} +
+ )} + {/* 工件取回来了、却没有条目之外的句子(计数和工件对不上):没什么可补的,这一行也不留。没有工件时照样交代 + 还有几句没列出,只是补不了 */} + {hidden > 0 && !extra.length && !(all && detail.status === 'ok') && ( +
+ {CHALLENGE_TEXT.more(hidden)} + {noRecord ? null : detail.status === 'error' && all ? ( + <> + {CHALLENGE_TEXT.failed} + {detail.retry && } + + ) : ( + + )} +
+ )} + {noRecord && (total > 0 || items.length > 0) && ( +
+ {CHALLENGE_TEXT.noRecord} +
+ )} + {/* 判断标签看着像结论,得说清是谁判的:整块说一次,工件取回来之后带上模型名 */} + {(total > 0 || items.length > 0) && ( +

+ {CHALLENGE_TEXT.judgedBy(detail.data?.model ?? null)} +

+ )} +
+ ) +} + +/** 判断标签:文字加颜色(去掉颜色也读得出),不铺底色——浅色主题下底色会把小字的对比度压到 4.5 以下 */ +function VerdictTag({ verdict }: { verdict: string }) { + const color = isVerdict(verdict) ? VERDICT_COLOR[verdict] : 'var(--text-dim)' + return ( + + {verdictLabel(verdict)} + + ) +} + +/** 一句的判断标签和结论原文 */ +function ClaimLine({ item }: { item: ChallengeItem }) { + return ( + <> + + {/* 原文取自报告文档,是 Markdown:粗体、行内代码照样画出来;链接不画成链接,按钮里不能再套一个可点的东西 */} + + + + + ) +} + +function ClaimRow({ item, open, onToggle, children }: { + item: ChallengeItem; open: boolean; onToggle: () => void; children: ReactNode +}) { + const detailId = useId() + return ( +
  • + + {open && ( +
    + {children} +
    + )} +
  • + ) +} + +/** 点开的一句:工件取回来之前说正在加载;取不到给重试;取到了画理由和查询 */ +function ClaimDetail({ item, detail }: { item: ChallengeItem; detail: Detail }) { + const baseId = useId() + const claim = claimOf(detail.data, item) + // 理由里的数字点一下:焦点落到那次查询上,描一下边(减少动效时也看得见) + const jump = useCallback((alias: string) => { + const part = document.getElementById(`${baseId}-q-${alias}`) + const head = part?.querySelector('[data-challenge-query-head]') + if (!part || !head) return + head.focus({ preventScroll: true }) + head.scrollIntoView({ block: 'nearest' }) + part.dataset.flash = 'focus' + setTimeout(() => { if (part.dataset.flash === 'focus') delete part.dataset.flash }, 1600) + }, [baseId]) + // 点了「重试」,按钮在加载时就拿掉了:取到了焦点落到理由上,又没取到落到新的「重试」上,不掉到页面上 + const retried = useRef(false) + const landing = useRef(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 ( +
    + {CHALLENGE_TEXT.failed} + {detail.retry && ( + + )} +
    + ) + } + if (detail.status !== 'ok') { + return
    {CHALLENGE_TEXT.loading}
    + } + if (!claim) { + return ( +
    + {CHALLENGE_TEXT.claimMissing} +
    + ) + } + const aliases = new Set(claim.queries.map((q) => q.alias)) + return ( + <> +
    +
    {CHALLENGE_TEXT.rationale}
    + (aliases.has(alias) ? jump(alias) : undefined)} /> +
    +
    +
    + {claim.queries.length ? CHALLENGE_TEXT.queries(claim.queries.length) : CHALLENGE_TEXT.noQueries} +
    +
    + {claim.queries.map((q, i) => )} +
    +
    + + ) +} + +function QueryCard({ id, query }: { id: string; query: ChallengeQuery }) { + return ( +
    +
    + {/* 从理由里的数字跳过来时焦点落在这里:得看得见落在哪 */} + + {EVIDENCE_TEXT.query(query.alias)} + {query.source && {query.source}} + + {query.sql && query.sql} />} +
    + {query.sql && ( +
    +          {query.sql}
    +        
    + )} +
    + {query.row_count != null && {CHALLENGE_TEXT.rows(query.row_count)}} + {query.truncated && ( + {CHALLENGE_TEXT.truncated} + )} +
    +
    + ) +} + +// ---------------------------------------------------------------- 理由 + +/** + * 理由正文。有理由文档时按文档画:数字的出处照报告里的样子(有出处的实线、裸数字点状线加「无证据」),有出处的数字后面 + * 挂查询编号,点一下跳到那次查询。文档可能直接放在工件里,也可能是另一个工件的 id(约定没定死,两种都认); + * 都没有、或者取不到时按渲染后的正文显示,数字照写,只是不标出处 + */ +function Rationale({ claim, onJump }: { claim: ChallengeClaim; onJump: (alias: string) => void }) { + const ref = claim.rationale_doc + const inline = ref && typeof ref === 'object' ? asDoc(ref) : null + const artifact = typeof ref === 'string' ? ref : '' + const slot = useEvidence((s) => (artifact ? s.docs[artifact] : undefined)) + const loadDoc = useEvidence((s) => s.loadDoc) + useEffect(() => { if (artifact) void loadDoc(artifact) }, [artifact, loadDoc]) + const doc = inline ?? (slot?.status === 'ok' ? slot.data ?? null : null) + if (doc && (doc.blocks ?? []).length) return + if (artifact && slot?.status !== 'error' && !doc) { + return
    {CHALLENGE_TEXT.docLoading}
    + } + if (!claim.rationale.trim()) return
    {CHALLENGE_TEXT.rationaleEmpty}
    + return
    +} + +/** 列表项从哪一个片段开始:同 EvidenceDoc(行首的「- 」「1. 」是结构片段) */ +const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ + +function RationaleDoc({ doc, onJump }: { doc: EvidenceDocData; onJump: (alias: string) => void }) { + return ( +
    + {doc.blocks.map((block, b) => )} +
    + ) +} + +/** 一块:外观借 Markdown 的 BlockView,和报告文档、普通答案长得一样(块的分法同 EvidenceDoc 的 DocBlock) */ +function RationaleBlock({ block, doc, onJump }: { block: EvidenceBlock; doc: EvidenceDocData; onJump: (alias: string) => void }) { + const units = block.units ?? [] + const unit = (u: EvidenceUnit, code = false) => + switch (block.type) { + case 'heading': + return unit(u)) }} /> + case 'hr': + return + case 'quote': + return unit(u)) }} /> + case 'code': + return unit(u, true)) }} /> + case 'list': { + const items: { key: string; depth: number; units: EvidenceUnit[] }[] = [] + for (const u of units) { + const lead = u.segments?.[0] + if (!items.length || (lead?.kind === 'structural' && ITEM_START.test(lead.text))) { + items.push({ key: u.id, depth: u.depth ?? 0, units: [u] }) + } else items[items.length - 1].units.push(u) + } + return ( + ({ key: it.key, depth: it.depth, body: it.units.map((u) => unit(u)) })), + }} /> + ) + } + case 'table': { + const cells = new Map() + let cols = 0 + const rowIds: number[] = [] + for (const u of units) { + const loc = u.loc ?? { row: 0, col: 0 } + cells.set(`${loc.row}:${loc.col}`, [...(cells.get(`${loc.row}:${loc.col}`) ?? []), u]) + cols = Math.max(cols, loc.col + 1) + if (loc.row >= 0 && !rowIds.includes(loc.row)) rowIds.push(loc.row) + } + rowIds.sort((a, b) => a - b) + const plain = (r: number, c: number) => (cells.get(`${r}:${c}`) ?? []).map(unitText).join('') + const node = (r: number, c: number) => (cells.get(`${r}:${c}`) ?? []).map((u) => unit(u)) + const head = Array.from({ length: cols }, (_, c) => plain(-1, c)) + const body = rowIds.map((r) => Array.from({ length: cols }, (_, c) => plain(r, c))) + return ( + node(-1, c)), + rows: rowIds.map((r) => Array.from({ length: cols }, (_, c) => node(r, c))), + numeric: numericColumns(head, body), + csv: () => [head, ...body].map((r) => r.join(',')).join('\n'), + }} /> + ) + } + default: + return unit(u)) }} /> + } +} + +/** 片段的颜色写成变量交给 index.css 的 .ev-seg / .ev-mark(同 EvidenceDoc) */ +const evStyle = (state: EvidenceStateCode) => ({ + '--ev-line': EVIDENCE_STATE[state].decoration, '--ev-soft': EVIDENCE_STATE[state].soft, +}) as CSSProperties + +/** 引用的是查询结果里的一格:返回查询编号 */ +function cellAlias(seg: EvidenceSegment, doc: EvidenceDocData): string | null { + const cite = seg.cite + if (!cite?.alias || segmentState(seg) !== 'deterministic') return null + const entry = doc.catalog?.[cite.alias] + return cite.kind === 'cell' || (cite.kind !== 'metric' && cite.kind !== 'input' && entry?.kind === 'query') ? cite.alias : null +} + +/** + * 一句话:文字按行内 Markdown 渲染(整句算一次样式,同 EvidenceDoc 的 UnitView)。有出处的数字是按钮,后面挂查询编号; + * 裸数字、疑似不存在的名称画点状线,句末挂「无证据」「疑似不存在的名称」。别的片段(表名、字段名的链接)照普通文字 + */ +function RationaleUnit({ unit, doc, code, onJump }: { + unit: EvidenceUnit; doc: EvidenceDocData; code: boolean; onJump: (alias: string) => void +}) { + const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural') + const whole = segs.map((s) => s.text).join('') + const styles = useMemo(() => (code ? null : inlineStyles(whole)), [whole, code]) + const alerts = new Set() + let pos = 0 + const nodes = segs.map((s) => { + const from = pos + pos += s.text.length + const state = segmentState(s) + const alias = cellAlias(s, doc) + const body = styles ? : s.text + if (alias) { + const source = sourceOf(s, doc) + return ( + + ) + } + if (state === 'none' || state === 'suspect') { + alerts.add(state) + return ( + + {body}({EVIDENCE_STATE_LABEL[state]}) + + ) + } + return {s.strong ? {body} : body} + }) + return ( + <> + {nodes} + {!code && [...alerts].map((state) => ( + + ))} + + ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f7557129..cd09e0ba 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3617,3 +3617,76 @@ export interface VerifySpend { unpriced_calls?: number daily_max_usd?: number | null } + +// --------------------------------------------------------------------------- +// 反方审稿(challenge 节点):审批卡上的概况、challenge_artifact 工件的内容。形状照前后端共用的接口约定,字段名一个不改; +// 判断由反方模型给出,界面只排版,理由正文、行数这些都是服务端给的 +// --------------------------------------------------------------------------- + +/** 一句结论经受住反方的复核没有:经受住 / 被削弱 / 被推翻 / 无法判断 */ +export type ChallengeVerdict = 'stands' | 'weakened' | 'refuted' | 'inconclusive' + +/** 各种判断的句数 */ +export type ChallengeCounts = Record + +/** 审批卡、节点产出里列的一句:unit 是报告文档里这一句的 id,text 是结论原文 */ +export interface ChallengeItem { + unit: string + text: string + verdict: ChallengeVerdict | (string & {}) +} + +/** + * 审批卡上的反方审稿概况(human 节点 payload.reports[i].challenge)。items 最多 10 条,完整内容在 artifact 指的工件里。 + * on_refuted 是实际生效的处理方式:degrade 被推翻的结论计入出具缺口;flag 只标注。报错(fail)时节点已经失败,到不了审批。 + * 老服务端不给时按 degrade + */ +export interface ApprovalChallenge { + counts: ChallengeCounts + items: ChallengeItem[] + /** challenge_artifact 工件 id。服务端保存审稿记录失败时为 null:判断照样有,理由和查询看不了 */ + artifact: string | null + on_refuted?: 'degrade' | 'flag' | (string & {}) +} + +/** + * 审批审的报告经过反方审稿时多一个 challenge。和上面的 ApprovalReport 是同一个接口(声明合并):反方审稿的类型集中在 + * 这一段,这一项也就近写在这里 + */ +export interface ApprovalReport { + challenge?: ApprovalChallenge +} + +/** 反方为复核一句结论执行的一次只读查询:编号是它自己的(Q1、Q2…),和报告里的查询编号无关 */ +export interface ChallengeQuery { + alias: string + /** 查询快照工件 */ + artifact: string + sql: string + row_count: number | null + truncated: boolean + /** 数据源名 */ + source: string | null +} + +/** 工件里的一句:判断、理由、查询 */ +export interface ChallengeClaim extends ChallengeItem { + /** 理由渲染后的正文(数字已换成查询结果里的值) */ + rationale: string + /** + * 理由组装成的文档(和报告文档同一种形状,数字带出处、裸数字被标出)。约定没说是工件 id 还是文档本身,两种都认; + * 没有时只显示 rationale + */ + rationale_doc?: string | EvidenceDocData | null + queries: ChallengeQuery[] + usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number; cost_usd?: number | null; calls?: number } +} + +/** challenge_artifact 工件的内容:审的哪份报告、每句的复核,以及反方用的模型 */ +export interface ChallengeArtifact { + /** 被审的报告撰写节点 */ + report: string + doc_artifact: string + claims: ChallengeClaim[] + model: string | null +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 4d77467c..be969cad 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -86,6 +86,7 @@ const ORDER = [ 'compare', // 运行对比 'verify', // 双盲复算、反方审稿 'inquiry', // 审批质询 + 'challenge', // 反方审稿:审批卡上的概况与逐句展开、检查器 'bundle', // 可验证报告 'whatif', // 数字推演 'ui', // 各页面端到端,连真数据 @@ -93,6 +94,7 @@ const ORDER = [ // 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算; // 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测 const ESTIMATE = { + 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, // 占位,换成真检查时按实测改 diff --git a/scripts/check-challenge.mjs b/scripts/check-challenge.mjs new file mode 100644 index 00000000..44f13877 --- /dev/null +++ b/scripts/check-challenge.mjs @@ -0,0 +1,729 @@ +// 反方审稿的回归检查:审批卡上的反方审稿(run/ChallengeSummary.tsx)和检查器里反方审稿节点的配置(canvas/nodeDefs.ts 的 +// challenge、canvas/verifyFields.tsx)。 +// +// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、 +// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时降档提示悄悄消失、没经过反方审稿的报告 +// 也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、 +// 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、 +// 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上); +// 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了 +// 下游的报告、留空时认的报告和服务端不一样(「初稿 → 终稿 → 反方审稿」审终稿)、选中的报告被删了还看着像「未选择」、 +// 选中的不是报告撰写节点却说成不在上游、反方模型和撰写报告的模型相同不提醒、画布校验已经报了的又在字段下面说一遍、 +// 节点卡片的摘要还写着功能开发中;窄屏(390px)被撑出横向滚动、浅色主题下标签看不清。 +// +// 跑在预览页上:/preview.html?syn=challenge-*(用例见 frontend/src/dev/fixtures/challenge.tsx)。夹具在 +// frontend/src/dev/fixtures/challenge.json,理由文档是后端 run_verify_agent + compose_doc 真跑出来的(虚构的库和数字)。 +// 运行类别(GET /api/runs/{id})、工件(GET /api/artifacts/{id})、画布校验(两个纯计算的 POST)在这里伪造,别的非 GET +// 一律拦掉并记下来,别的 GET 放行。 +// +// node scripts/check-challenge.mjs +// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-challenge.mjs +// CHALLENGE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。 +import { mkdirSync, readFileSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const SHOTS = process.env.CHALLENGE_SHOTS ?? '' +const root = new URL('..', import.meta.url).pathname +const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/challenge.json`, 'utf8')) +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.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 = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +async function section(name, fn) { + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } +} + +const browser = await chromium.launch({ executablePath: CHROME }) + +/** + * 开一个预览用例。工件按夹具答(BROKEN 先答 404,state.broken 置 false 后答正常内容),运行类别按 RUN_CLASS 答 + * (runfail 先答 500,state.runFail 置 false 后照常答),画布校验答 state.issues; + * 记下取了哪些工件、问了哪些运行、拦下了哪些写请求 + */ +async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) { + const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) + ctx.setDefaultTimeout(6000) + const page = await ctx.newPage() + const hits = { artifacts: [], runs: [], writes: [] } + 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: !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') { + return route.fulfill({ json: req.method() === 'GET' ? [] : { id: 'conv-challenge', kind: 'canvas', title: '', turns: [] } }) + } + if (req.method() !== 'GET') { + hits.writes.push(`${req.method()} ${path}`) + return route.abort() + } + const art = path.match(/^\/api\/artifacts\/([^/]+)$/) + if (art) { + 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: '工件不存在,可能已被删除' } }) + } + const run = path.match(/^\/api\/runs\/([^/]+)$/) + 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: '运行不存在' } }) + } + return route.continue() + }) + await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' }) + return { ctx, page, hits, state, errors } +} + +const text = async (loc) => (await loc.innerText()).replace(/\s+/g, ' ').trim() +const shot = async (page, name) => { + if (!SHOTS) return + mkdirSync(SHOTS, { recursive: true }) + await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) +} +/** 等运行类别答完、界面画上:否定的断言(不写降档)得在这之后做 */ +async function runClassSettled(page, hits, runId) { + for (let i = 0; i < 50 && !hits.runs.includes(runId); i++) await page.waitForTimeout(100) + await page.waitForTimeout(400) +} +const item = (page, unit) => page.locator(`[data-challenge-item="${unit}"]`) +const toggle = (page, unit) => item(page, unit).locator('[data-challenge-toggle]') +const detail = (page, unit) => page.locator(`[data-challenge-detail="${unit}"]`) +const config = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'critic')?.data.config ?? null) + +await section('审批卡:概况与降档提示', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval') + const block = page.locator('[data-challenge="write"]') + await block.waitFor() + check('审批卡里有这份报告的反方审稿', (await text(block.locator('div').first())).includes('报告「写月报」的反方审稿')) + const counts = {} + for (const v of Object.keys(LABEL)) counts[v] = await text(block.locator(`[data-challenge-count="${v}"]`)) + check('一行概况:四种判断各几句', counts.stands === '经受住 1 句' && counts.weakened === '被削弱 1 句' + && counts.refuted === '被推翻 1 句' && counts.inconclusive === '无法判断 1 句', JSON.stringify(counts)) + await page.waitForSelector('[data-challenge-degrade]') + check('正式运行、没写处理方式(按降档)、有被推翻的:醒目提示「出具会降档」', + (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(), + text: el.querySelector('[data-challenge-text]')?.textContent?.trim(), + }))) + const expected = fx.challenge.items.map((it) => ({ unit: it.unit, tag: LABEL[it.verdict], text: it.text })) + check('逐句列出结论原文和判断标签,顺序照审批的条目', JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows)) + check('每句是一个收起的按钮', await block.locator('[data-challenge-toggle][aria-expanded="false"]').count() === 4) + check('没点开之前也说清判断出自反方模型', (await text(block.locator('[data-challenge-judged]'))) === '判断由反方模型给出,并非系统核对') + check('没点开之前不取工件', hits.artifacts.length === 0, hits.artifacts.join('、')) + check('运行类别按运行 id 取了一次', hits.runs.filter((r) => r === RUNS.formal).length === 1, hits.runs.join('、')) + await shot(page, 'approval-dark') + check('页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +await section('点开一句:取工件,显示理由与查询', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval') + await toggle(page, 'u3').click() + await detail(page, 'u3').locator('[data-challenge-doc]').waitFor() + check('点开后标为展开、指向详情', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true' + && await page.locator(`#${CSS_ESC(await toggle(page, 'u3').getAttribute('aria-controls'))}[data-challenge-detail="u3"]`).count() === 1) + check('按 challenge_artifact 取了一次工件', JSON.stringify(hits.artifacts) === JSON.stringify([ART]), hits.artifacts.join('、')) + const u3 = detail(page, 'u3') + const rationale = await text(u3.locator('[data-challenge-doc]')) + check('显示理由正文(数字是查询结果里的值)', rationale.includes('9 月新客销售额 151,000') && rationale.includes('1,135,000'), rationale) + const cites = await u3.locator('[data-challenge-cite]').evaluateAll((els) => els.map((el) => [el.getAttribute('data-challenge-cite'), + el.querySelector('sup')?.textContent, el.getAttribute('aria-label'), el.getAttribute('data-ev-line')])) + check('有出处的数字照证据的样子画实线,后面挂查询编号', cites.length === 4 + && cites.every(([alias, sup, , line]) => alias === sup && line === 'solid') + && JSON.stringify(cites.map((c) => c[0])) === JSON.stringify(['Q1', 'Q2', 'Q2', 'Q1']), JSON.stringify(cites.map((c) => c.slice(0, 2)))) + check('读屏听得到引用的是哪次查询、哪一格', (cites[0]?.[2] ?? '').includes('出处:查询 Q1 · 第 1 行 · amount'), cites[0]?.[2]) + const queries = await u3.locator('[data-challenge-query]').evaluateAll((els) => els.map((el) => ({ + alias: el.getAttribute('data-challenge-query'), sql: el.querySelector('[data-challenge-sql]')?.textContent ?? '', + rows: el.querySelector('[data-challenge-rows]')?.textContent ?? '', cut: !!el.querySelector('[data-challenge-truncated]'), + }))) + check('列出反方执行的查询:SQL、行数', queries.length === 2 && queries[0].alias === 'Q1' && queries[1].alias === 'Q2' + && queries.every((q) => q.sql.includes('GROUP BY customer_type') && q.rows === '2 行' && !q.cut), JSON.stringify(queries.map((q) => [q.alias, q.rows]))) + check('查询一节写明共几次', (await text(u3.locator('[data-challenge-queries] > div').first())) === '反方执行的查询(2 次)') + check('写明判断出自反方模型、不是系统核对(取到工件后带上模型名,整块只说一次)', + (await text(page.locator('[data-challenge="write"] [data-challenge-judged]'))) === '判断由反方模型「critic-large」给出,并非系统核对' + && await page.locator('[data-challenge-judged]').count() === 1) + + // 点理由里的数字:焦点落到那次查询上,描一下边 + await u3.locator('[data-challenge-cite="Q2"]').first().click() + await page.waitForTimeout(100) + const jumped = await page.evaluate(() => { + const head = document.activeElement + const card = head?.closest('[data-challenge-query]') + return { head: head?.hasAttribute('data-challenge-query-head'), alias: card?.getAttribute('data-challenge-query'), flash: card?.getAttribute('data-flash') } + }) + check('点有出处的数字:跳到它引用的那次查询并描边', jumped.head && jumped.alias === 'Q2' && jumped.flash === 'focus', JSON.stringify(jumped)) + + // 裸数字被标出来 + await toggle(page, 'u5').click() + const u5 = detail(page, 'u5') + await u5.locator('[data-challenge-doc]').waitFor() + const bare = await u5.locator('.ev-mark[data-ev-state="none"]').allInnerTexts() + check('理由里的裸数字画点状线,句末挂「无证据」', JSON.stringify(bare.map((t) => t.replace(/\s*(无证据)$/, ''))) === JSON.stringify(['三', '70000', '一半']) + && (await u5.locator('[data-ev-tag="none"]').innerText()).includes('无证据'), JSON.stringify(bare)) + check('有出处的那个照样挂编号', await u5.locator('[data-challenge-cite="Q1"]').count() === 1) + check('同一张卡点第二句不再取工件', hits.artifacts.filter((a) => a === ART).length === 1, hits.artifacts.join('、')) + + // 理由文档是另一个工件 + await toggle(page, 'u8').click() + const u8 = detail(page, 'u8') + await u8.locator('[data-challenge-doc]').waitFor() + const u8Text = await text(u8.locator('[data-challenge-doc]')) + check('理由文档是工件 id 时去取它,按文档显示', hits.artifacts.includes(DOC) && u8Text.includes('9 月退款金额 21,500') + && await u8.locator('[data-challenge-cite]').count() === 2, `${hits.artifacts.length} 次取工件 · ${u8Text}`) + + // 没有理由文档:按渲染后的正文显示;查询截断照实说 + await toggle(page, 'u11').click() + const u11 = detail(page, 'u11') + await u11.locator('[data-challenge-rationale-text]').waitFor() + check('没有理由文档时显示渲染后的正文', (await text(u11.locator('[data-challenge-rationale-text]'))) === '数据源中没有区分线上与门店的字段,无法判断这句结论。') + check('查询结果截断时写明', (await text(u11.locator('[data-challenge-rows]'))) === '50 行' + && (await text(u11.locator('[data-challenge-truncated]'))) === '结果已截断,数据库中还有更多数据') + await shot(page, 'approval-open-dark') + + // 收起;键盘开合 + await toggle(page, 'u3').click() + check('再点一下收起', await detail(page, 'u3').count() === 0 && (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'false') + await toggle(page, 'u3').focus() + await page.keyboard.press('Enter') + await detail(page, 'u3').locator('[data-challenge-doc]').waitFor() + check('键盘:回车展开', (await toggle(page, 'u3').getAttribute('aria-expanded')) === 'true') + await page.keyboard.press('Space') + await page.waitForTimeout(100) + check('键盘:空格收起', await detail(page, 'u3').count() === 0) + check('没有发出任何写请求', hits.writes.length === 0, hits.writes.join('、')) + check('页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() +}) + +// 下面几段各开各的页面、各自成段:一段中途出错(比如等一个不该出现的元素超时)不遮住其余几段 +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, run) + const all = await text(page.locator('[data-challenge="write"]')) + 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() + await page.waitForSelector('[data-challenge-detail="u3"] [data-challenge-detail-state="error"]') + check('取不到:写明无法获取,给重试', (await text(detail(page, 'u3'))).includes(FAILED) + && 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: '重试' }).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() +}) + +await section('超过十句:显示全部', async () => { + const { ctx, page, hits, errors } = await open('challenge-approval-many') + await page.locator('[data-challenge="write"]').waitFor() + check('条目只列前十句', await page.locator('[data-challenge-item]').count() === 10) + check('计数是全部十二句', (await text(page.locator('[data-challenge-count="stands"]'))) === '经受住 7 句' + && (await text(page.locator('[data-challenge-count="refuted"]'))) === '被推翻 2 句') + check('写明另有几句未列出', (await text(page.locator('[data-challenge-more]'))).startsWith('另有 2 句未列出')) + check('没要之前不取工件', hits.artifacts.length === 0) + // 用键盘按:按钮所在那一行补齐后会拿掉,焦点得落到补出来的第一句上,不能掉到页面上、下一个 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() + await detail(page, 'u30').locator('[data-challenge-rationale-text]').waitFor() + const u30 = await text(detail(page, 'u30')) + check('补出来的那几句也能点开', u30.includes('白汀区的退款金额高于云屿区这一说法与查询结果不符'), u30) + check('超过十句:页面没有报错', errors.length === 0, errors.join(';')) + 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() + const panel = page.locator('[data-challenge-inspector]') + check('节点说明不再写功能开发中', !(await text(panel)).includes('功能开发中')) + const card = page.locator('.react-flow__node[data-id="critic"]') + await card.waitFor() + check('节点卡片摘要:没配时写审上游报告、句数跟随设置', (await text(card)).includes('审上游报告 · 句数跟随设置'), await text(card)) + + // 要审的报告:只列上游的报告撰写节点 + 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([ + ['', '未选择:审上游的报告撰写节点'], ['write', '写月报(write)'], ['write2', '写周报(write2)']]), JSON.stringify(options)) + check('上游有两个、又没选:提醒选一个', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 个报告撰写节点,请选择要审的那一个') + await report.selectOption('write') + check('选了报告:写进 report', (await config(page))?.report === 'write') + check('选了之后不再提醒', await page.locator('[data-node-pick-hint]').count() === 0) + + await page.locator('[data-field="max_claims"] input').fill('3') + check('最多审几句:写进 max_claims(数)', (await config(page))?.max_claims === 3) + await page.waitForTimeout(150) + check('节点卡片摘要写清审哪份报告、最多几句', (await text(card)).includes('审报告节点 write · 最多审 3 句结论'), await text(card)) + + // 反方模型 + const provider = page.locator('[data-field="provider"] select') + const providers = await provider.locator('option').evaluateAll((els) => els.map((o) => o.textContent)) + check('反方模型 · 接入:跟随设置在第一项,停用的接入标出来', JSON.stringify(providers) === JSON.stringify(['跟随设置', '主力接入', '备用接入(已停用)']), + JSON.stringify(providers)) + await provider.selectOption('主力接入') + const model = page.locator('[data-field="model"] input') + await model.fill('writer-large') + let c = await config(page) + check('接入、模型写进 provider、model', c?.provider === '主力接入' && c?.model === 'writer-large', JSON.stringify(c)) + check('和撰写报告的模型相同:提醒', (await text(page.locator('[data-verify-same-model]'))).includes('「writer-large」')) + await model.fill('critic-large') + check('换一个模型:不再提醒', await page.locator('[data-verify-same-model]').count() === 0) + check('模型框给出所选接入的模型', JSON.stringify(await page.locator('[data-field="model"] datalist option').evaluateAll((els) => els.map((o) => o.value))) + === JSON.stringify(['writer-large', 'critic-large'])) + await provider.selectOption('') + c = await config(page) + check('接入改回留空:配置里拿掉 provider', !('provider' in (c ?? {})) || c.provider === undefined, JSON.stringify(c)) + check('只填了模型:接入一项写「按模型名找接入」,并说明会用哪个接入', + (await provider.locator('option').first().textContent()) === '按模型名找接入' + && (await text(page.locator('[data-verify-by-model="found"]'))).includes('「主力接入」')) + + // 被推翻时怎么办 + const onRefuted = page.locator('[data-field="on_refuted"] select') + check('结论被推翻时:没写时落在「出具降档(默认)」', (await onRefuted.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '出具降档(默认)') + await onRefuted.selectOption('flag') + check('选只标注:写进 on_refuted', (await config(page))?.on_refuted === 'flag') + await onRefuted.selectOption('fail') + check('选报错:写进 on_refuted', (await config(page))?.on_refuted === 'fail') + + // 启用:三态 + 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(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') + check('键盘:方向键在组内移动并选中(关闭 → 开启)', (await config(page))?.enabled === true + && await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on') + await group.locator('[data-tri="follow"]').click() + check('点跟随设置:配置里拿掉 enabled,不写 null 也不写空字符串', !('enabled' in (await config(page))) || (await config(page)).enabled === undefined, + JSON.stringify(await config(page))) + await group.locator('[data-tri="off"]').click() + await page.waitForTimeout(150) + check('关掉的节点:卡片摘要写已关闭', (await text(card)).includes('已关闭 · 审报告节点 write'), await text(card)) + + // 高级选项里的两项上限 + await panel.locator('button', { hasText: '高级选项' }).click() + await page.locator('[data-field="max_queries"] input').fill('2') + await page.locator('[data-field="timeout_s"] input').fill('60') + c = await config(page) + check('每句最多查询次数、每句时长上限写进 max_queries、timeout_s', c?.max_queries === 2 && c?.timeout_s === 60, JSON.stringify(c)) + await page.locator('[data-field="max_queries"] input').fill('') + check('清空上限:配置里拿掉这一项(跟随设置)', (await config(page))?.max_queries === undefined) + + // 选中的报告挪到下游、被删掉:照实列出来,不像「未选择」 + 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: 'later' } }) + }) + await page.waitForTimeout(100) + check('选中的报告在下游:下拉里照实标「不在上游」,并提醒', (await report.evaluate((s) => s.options[s.selectedIndex]?.textContent)) === '写摘要(later)(不在上游)' + && await page.locator('[data-node-pick-hint="notUpstream"]').count() === 1) + 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 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) => { + const doc = document.documentElement + const box = document.querySelector(s) + return { page: doc.scrollWidth - doc.clientWidth, box: box ? box.scrollWidth - box.clientWidth : -1 } + }, sel) +} + +/** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */ +function contrast(fg, bg, base) { + const parse = (c) => { + const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/) + if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])] + const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/) + return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null + } + const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3])) + const lum = ([r, g, b]) => { + const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) + } + const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg) + if (!b0 || !b1 || !f0) return 0 + const under = over(b1, b0.slice(0, 3)) + const a = lum(over(f0, under)); const b = lum(under) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +await section('窄屏与深浅色', async () => { + for (const theme of ['dark', 'light']) { + const { ctx, page, errors } = await open('challenge-approval', { width: 390, height: 844, theme }) + await page.waitForSelector('[data-challenge-degrade]') + for (const unit of ['u3', 'u5', 'u11']) { + await toggle(page, unit).click() + await detail(page, unit).locator('[data-challenge-queries]').waitFor() + } + const o = await overflow(page, '[data-challenge="write"]') + check(`390px(${theme}):展开三句也没有横向溢出`, o.page <= 0 && o.box <= 1, JSON.stringify(o)) + const colors = await page.evaluate(() => { + const block = document.querySelector('[data-challenge="write"]') + const base = getComputedStyle(document.body).backgroundColor + const bg = getComputedStyle(block).backgroundColor + const pick = (el) => ({ fg: getComputedStyle(el).color, weight: Number(getComputedStyle(el).fontWeight) }) + return { + base, bg, + degrade: pick(document.querySelector('[data-challenge-degrade]')), + tags: [...document.querySelectorAll('[data-challenge-tag]')].slice(0, 4).map((el) => ({ v: el.getAttribute('data-challenge-tag'), ...pick(el) })), + counts: [...document.querySelectorAll('[data-challenge-count]')].map((el) => ({ v: el.getAttribute('data-challenge-count'), ...pick(el) })), + sup: pick(document.querySelector('[data-challenge-cite] sup')), + } + }) + const ratio = (c) => contrast(c.fg, colors.bg, colors.base) + check(`${theme}:「出具会降档」对比度 ≥ 4.5、加粗`, ratio(colors.degrade) >= 4.5 && colors.degrade.weight >= 600, + `${ratio(colors.degrade).toFixed(2)} · ${colors.degrade.weight}`) + const tagRatios = colors.tags.map((t) => [t.v, ratio(t).toFixed(2)]) + check(`${theme}:四种判断标签对比度都 ≥ 4.5`, colors.tags.length === 4 && colors.tags.every((t) => ratio(t) >= 4.5), JSON.stringify(tagRatios)) + check(`${theme}:概况里的句数对比度都 ≥ 4.5`, colors.counts.every((t) => ratio(t) >= 4.5), + JSON.stringify(colors.counts.map((t) => [t.v, ratio(t).toFixed(2)]))) + check(`${theme}:数字后面的查询编号对比度 ≥ 4.5`, ratio(colors.sup) >= 4.5, ratio(colors.sup).toFixed(2)) + await shot(page, `narrow-${theme}`) + 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() + await toggle(page, 'u3').click() + await detail(page, 'u3').locator('[data-challenge-queries]').waitFor() + const o = await overflow(page, '[data-fixture="challenge-approval-dense"]') + check('360px 右栏:展开后不撑宽右栏', o.page <= 0 && o.box <= 0, JSON.stringify(o)) + await shot(page, 'dense-dark') + await ctx.close() + } + for (const theme of ['dark', 'light']) { + const { ctx, page, errors } = await open('challenge-inspector', { width: 390, height: 844, theme }) + await page.locator('[data-field="report"]').waitFor() + await page.locator('[data-field="model"] input').fill('writer-large') + await page.waitForSelector('[data-verify-by-model]') + const o = await overflow(page, '[data-challenge-inspector]') + check(`390px(${theme}):检查器不横向溢出`, o.page <= 0 && o.box <= 0, JSON.stringify(o)) + await shot(page, `inspector-narrow-${theme}`) + check(`390px 检查器(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) + await ctx.close() + } +}) + +function CSS_ESC(id) { + return String(id ?? '').replace(/([^a-zA-Z0-9_-])/g, '\\$1') +} + +await browser.close() +console.log(failed ? `\n✗ 反方审稿:${failed} 项未通过` : '\n✓ 反方审稿:全部通过') +process.exit(failed ? 1 : 0)