From f8adbaa463f3dce655d5e102c3f64efa5663d78c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:10:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B8=E6=9F=A5=E7=B1=BB=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E7=9A=84=E9=80=9A=E7=94=A8=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E5=90=88=E6=88=90=E4=B8=80=E4=BB=BD=EF=BC=9A=E9=80=89?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=8A=82=E7=82=B9=E3=80=81=E4=B8=89=E6=80=81?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E3=80=81=E6=A0=B8=E6=9F=A5=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=8F=AA=E7=95=99=E5=9C=A8=20verifyFields.ts?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 期 3 两个工作包各写了一份:双盲复算在 DualCheckFields.tsx(选口径卡、启用三态、盲算模型),反方审稿在 verifyFields.tsx(选报告、启用三态、反方模型),说法和细节各不相同、修一处漏一处。现在双盲复算改用 verifyFields 的 NodePick、TriState、ProviderPick、ModelInput,只给自己的文案;它特有的选指标、口径说明、相对误差留在 DualCheckFields.tsx。写进配置的值不变(留空写回 undefined,三态只认真布尔)。 - NodePick 加可选的分组(上游的一组、画布上其余同类的一组,选了不在上游的照样能选、下面提醒连到上游)、下拉项和缺省名的写法;写着的卡被删了、写的不是口径卡(助手改图、导入 JSON 会写出这种配置)时双盲复算也照实列出、说一句,不再只认「不在画布上」 - TriState 可选在「跟随设置」后面写出设置里现在是开是关(双盲复算用,每次打开检查器重取设置);设置的取法 useVerifyDefaults 挪到这里共用,用不上的控件不发请求 - 画布节点、连线的类型收窄成两边都给得出的 CanvasNode、CanvasEdge,节点卡片摘要和检查器共用 upstreamOf、resolvePick - check-verify 跟着改:提示、三态、按模型名找接入的标记换成共用控件的;字段叫法从 engine/labels.py 读(期 4 从 schema.py 挪过去);补写着的卡不在画布上、不是口径卡两种情形 --- frontend/src/canvas/DualCheckFields.tsx | 217 ++++++------------------ frontend/src/canvas/verifyFields.tsx | 191 ++++++++++++++------- frontend/src/lib/terms.ts | 12 +- scripts/check-verify.mjs | 69 +++++--- 4 files changed, 237 insertions(+), 252 deletions(-) diff --git a/frontend/src/canvas/DualCheckFields.tsx b/frontend/src/canvas/DualCheckFields.tsx index 0b0870a5..d55ee4b2 100644 --- a/frontend/src/canvas/DualCheckFields.tsx +++ b/frontend/src/canvas/DualCheckFields.tsx @@ -1,62 +1,41 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import type { ReactNode } from 'react' import { X } from 'lucide-react' import clsx from 'clsx' -import { api } from '../api/client' import { useStudio } from '../store/studio' -import { modelOptions, providerOfModel, useCatalog } from '../store/catalog' -import { useRadioGroup } from '../components/ui' import { DUAL_CHECK_TEXT } from '../lib/terms' -import type { VerifySettings } from '../types' import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs' +import { + ModelInput, NodePick, ProviderPick, TriState, resolvePick, upstreamOf, useVerifyDefaults, + type CanvasEdge, type CanvasNode, type NodePickText, type TriStateText, type VerifyModelText, +} from './verifyFields' /* * 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。 * - * 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。 - * 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。 - * 后端认的键和缺省见 types.ts 的 DualCheckConfig + * 选口径卡、盲算模型、启用三态用核查类节点共用的控件(verifyFields.tsx,反方审稿也用它们),这里只给文案;双盲复算 + * 特有的选指标、口径说明、相对误差在这里。口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读, + * 不往检查器的 props 里加字段。每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随 + * 默认」,配置里不留空值。后端认的键和缺省见 types.ts 的 DualCheckConfig */ const T = DUAL_CHECK_TEXT -type CanvasNode = NodeSummaryContext['nodes'][number] -type CanvasEdge = NodeSummaryContext['edges'][number] - const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : []) -/** 某个节点的全部上游(沿连线往回走),不含它自己 */ -function upstreamOf(id: string, edges: readonly CanvasEdge[]): 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(id) ?? [])] - while (stack.length) { - const cur = stack.pop()! - if (seen.has(cur) || cur === id) continue - seen.add(cur) - stack.push(...(parents.get(cur) ?? [])) - } - return seen -} - -/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */ -function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { - const up = upstreamOf(id, edges) - const cards = nodes.filter((n) => n.data.nodeType === 'metrics') - return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) } -} - /** - * 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它, - * 好几张时说不准,node 为 null、由调用方照实说 + * 实际复算哪张卡:写了 card 就是它(不在画布上、不是口径卡时 node 为 null;不在上游也照样是它,指标照列,选卡的地方 + * 另外提醒);没写取上游的口径卡(schema.dual_check_card 的认法),上游只有一张时就是它,好几张时说不准,node 为 null、 + * 由调用方照实说 */ function targetOf(config: Record, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) { - const { upstream } = cardsOf(id, nodes, edges) + const up = upstreamOf(id, edges) + const upstream = nodes.filter((n) => up.has(n.id) && n.data.nodeType === 'metrics') const card = typeof config.card === 'string' && config.card ? config.card : '' - if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream } - return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream } + const pick = resolvePick(id, card, 'metrics', 'upstream', nodes, edges) + const node = pick.kind === 'target' || pick.kind === 'notUpstream' ? pick.node : null + return { node, card, upstream } } /** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */ @@ -92,33 +71,7 @@ export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContex return config.enabled === false ? `${text} · ${S.off}` : text } -// ---------------------------------------------------------------- 设置里的默认值 - -/** - * 设置里核查一组的值:相对误差、启用的「跟随设置」要写出现在是多少。每次打开检查器都重取一次——去设置页关掉双盲 - * 复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的画,免得 - * 闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑 - */ -let verifyPending: Promise | null = null -let verifyValue: VerifySettings | null | undefined -function fetchVerify(): Promise { - verifyPending ??= api.settings.get().then( - (s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null), - () => verifyValue ?? null, - ).finally(() => { verifyPending = null }) - return verifyPending -} -function useVerifyDefaults(): VerifySettings | null { - const [value, setValue] = useState(verifyValue ?? null) - useEffect(() => { - let alive = true - void fetchVerify().then((v) => { if (alive) setValue(v) }) - return () => { alive = false } - }, []) - return value -} - -/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */ +/** 字段下面的一行提醒(写了说明的指标不在复算范围里之类),提醒色 */ function Warn({ children, attr }: { children: ReactNode; attr: string }) { return (
{children}
@@ -127,33 +80,23 @@ function Warn({ children, attr }: { children: ReactNode; attr: string }) { // ---------------------------------------------------------------- 各字段 -/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */ -function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) { - const nodes = useStudio((s) => s.nodes) - const edges = useStudio((s) => s.edges) - const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges]) - const current = typeof value === 'string' ? value : '' - const known = [...upstream, ...other].some((n) => n.id === current) - const option = (n: CanvasNode) => ( - - ) - return ( - <> - - {/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */} - {!current && upstream.length > 1 && {T.cardChoose(upstream.length)}} - {current && known && !upstream.some((n) => n.id === current) && {T.cardNotUpstream}} - {current && !known && {T.cardMissing(current)}} - - ) +/** + * 要复算的口径卡(共用的 NodePick):上游的一组、其余的一组(选了不在上游的,提醒把它连到上游);留空取上游的口径卡, + * 上游有好几张又没选时写明要选(服务端校验会挡住发起运行);写着的卡不在画布上、不是口径卡时照实列出来、说一句 + */ +const CARD_TEXT: NodePickText = { + auto: (only, count) => T.cardDefault(only, count), + many: (n) => T.cardChoose(n), + missing: (id) => T.cardGone(id), + notUpstream: (label) => label, + wrongType: (label) => T.cardWrongType(label), + missingHint: T.cardMissingHint, + notUpstreamHint: T.cardNotUpstream, + wrongTypeHint: (type) => T.cardWrongTypeHint(type), + groups: { upstream: T.cardUpstream, other: T.cardOther }, } +/** 一张口径卡在下拉里的叫法:节点名,口径名不同时附在后面 */ +const cardOption = (n: CanvasNode) => T.cardOption(String(n.data.label || n.id), n.data.config?.caliber) /** * 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回 @@ -280,52 +223,12 @@ function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: } /** - * 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫 - * 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼) + * 盲算模型(共用的 ProviderPick、ModelInput):留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫「按模型名找 + * 接入」,并写明实际用哪个接入(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)。取值链的说明在字段的帮助里 */ -function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) { - const providers = useCatalog((s) => s.providers) - const provider = typeof value === 'string' ? value : '' - const known = providers.some((p) => p.name === provider) - const byModel = !provider && typeof config.model === 'string' && !!config.model.trim() - return ( - - ) -} - -/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */ -function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) { - const providers = useCatalog((s) => s.providers) - const provider = typeof config.provider === 'string' ? config.provider : '' - const pv = provider ? providers.find((p) => p.name === provider) : undefined - const model = typeof value === 'string' ? value : '' - const byModel = !provider && !!model.trim() - const owner = byModel ? providerOfModel(providers, model.trim()) : undefined - const enabled = providers.filter((p) => p.enabled) - const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0] - const hint = byModel && providers.length > 0 - ? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : '' - const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))] - return ( - <> - onChange(e.target.value.trim() ? e.target.value : undefined)} /> - {models.map((m) => - {hint && ( -
- {hint} -
- )} - - ) +const MODEL_TEXT: VerifyModelText = { + providerFollow: T.providerFollow, providerFromModel: T.providerFromModel, providerMissing: T.providerGone, + providerDisabled: T.providerOff, modelFollow: T.modelFollow, modelDefault: T.modelOfProvider, byModel: T.byModel, } /** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */ @@ -344,39 +247,25 @@ function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRend ) } -const ENABLED = ['follow', 'on', 'off'] as const -type Enabled = typeof ENABLED[number] - -/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */ -function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) { - const settings = useVerifyDefaults() - const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow' - const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined) - const radio = useRadioGroup(ENABLED, current, pick) - return ( -
- {ENABLED.map((v) => ( - - ))} -
- ) +/** + * 启用(共用的 TriState):跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里 + * 留 null;「跟随设置」后面写出设置里现在是开是关(每次打开检查器都重取)。各态的说明在字段的帮助里 + */ +const ENABLED_TEXT: TriStateText = { + follow: T.enabled.follow, on: T.enabled.on, off: T.enabled.off, followNow: (on) => T.enabledFollow(on), } /** nodeDefs 里 dual_check 各字段的 render */ export const dualCheckFields = { - card: (p: FieldRenderProps) => , + card: (p: FieldRenderProps) => ( + + ), metrics: (p: FieldRenderProps) => , definitions: (p: FieldRenderProps) => , - provider: (p: FieldRenderProps) => , - model: (p: FieldRenderProps) => , + provider: (p: FieldRenderProps) => , + model: (p: FieldRenderProps) => , tolerance: (p: FieldRenderProps) => , - enabled: (p: FieldRenderProps) => , + enabled: (p: FieldRenderProps) => ( + v.dual_check?.enabled} /> + ), } diff --git a/frontend/src/canvas/verifyFields.tsx b/frontend/src/canvas/verifyFields.tsx index caac6ab2..c8416b94 100644 --- a/frontend/src/canvas/verifyFields.tsx +++ b/frontend/src/canvas/verifyFields.tsx @@ -1,19 +1,21 @@ -import { useMemo } from 'react' -import type { Edge } from '@xyflow/react' +import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' +import { api } from '../api/client' 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 { useStudio } from '../store/studio' +import type { NodeType, VerifySettings } from '../types' import { fieldOfIssue } from './issues' import type { FieldDef, FieldRenderProps } from './nodeDefs' /** - * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,nodeDefs 用下面的工厂函数拼进字段表: - * - nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; - * - triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); - * - verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择。 + * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,两个节点共用这一份(期 3 两个工作包各写过一份,期 4 合并): + * - NodePick / nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; + * - TriState / triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); + * - ProviderPick、ModelInput / verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择; + * - useVerifyDefaults:设置里「核查」一组的当前值(「跟随设置」后面写出现在是多少)。 + * 双盲复算特有的(选指标、口径说明、相对误差)留在 DualCheckFields.tsx。 * * 文案一律由调用方传进来(各节点自己那一块的 *_TEXT),控件里不写死任何一句话:两个节点说法不同(反方模型 / 盲算模型), * 控件是同一个。读画布节点用 studio store,不往 FieldRenderProps 里加字段。 @@ -22,6 +24,10 @@ import type { FieldDef, FieldRenderProps } from './nodeDefs' * 再画一句就是同一件事说两遍(同结论句裁判「校验也会报,报了就不重复说」)。校验没回来、校验失败时照画。 */ +/** 画布上的节点、连线只用得到这几项:检查器给的是 FlowNode / Edge,节点卡片的摘要给的是 NodeSummaryContext 里的 */ +export interface CanvasNode { id: string; data: { nodeType: NodeType; label?: string; config?: Record } } +export interface CanvasEdge { source: string; target: string } + /** * 画布校验在这个节点的这一项上报了问题没有。认法和 Inspector 把问题挂到字段下面时一样(fieldOfIssue), * 两边说的「这一项有问题」不会对不上 @@ -34,13 +40,13 @@ function useReported(nodeId: string, key: string): boolean { } /** 节点在下拉里的叫法:名字和 id 不同时把 id 带上(同 NodeRefsInput) */ -function nodeLabel(n: FlowNode): string { +function nodeLabel(n: CanvasNode): string { const label = String(n.data?.label || n.id) return label === n.id ? label : `${label}(${n.id})` } -/** 本节点的全部上游(沿连线往回找)。只认上游:下游的节点运行到这里时还没有产出 */ -function upstreamOf(nodeId: string, edges: Edge[]): Set { +/** 某个节点的全部上游(沿连线往回找),不含它自己。只认上游:下游的节点运行到这里时还没有产出 */ +export function upstreamOf(nodeId: string, edges: readonly CanvasEdge[]): Set { const parents = new Map() for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) const seen = new Set() @@ -66,17 +72,17 @@ export type PickRule = 'upstream' | 'direct' * 配置里那一项按服务端的认法落到哪个节点上:target 是定下来的那一个;其余几种是定不下来的原因——写了的节点不存在、 * 不是这一类、不在上游(先后同服务端),留空时上游一个都没有、不止一个 */ -export type PickState = - | { kind: 'target'; node: FlowNode } +export type PickState = + | { kind: 'target'; node: N } | { kind: 'missing' } - | { kind: 'wrongType'; node: FlowNode } - | { kind: 'notUpstream'; node: FlowNode } + | { kind: 'wrongType'; node: N } + | { kind: 'notUpstream'; node: N } | { kind: 'none' } | { kind: 'many'; count: number } -export function resolvePick( - nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[], -): PickState { +export function resolvePick( + nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[], +): PickState { const up = upstreamOf(nodeId, edges) if (typeof chosen === 'string' && chosen) { const node = nodes.find((n) => n.id === chosen) @@ -95,20 +101,48 @@ export function resolvePick( } /** 这个节点实际要核查的是哪一个;定不下来(服务端这时报错、不核查)时返回 undefined */ -export function targetNode( - nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: FlowNode[], edges: Edge[], -): FlowNode | undefined { +export function targetNode( + nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[], +): N | undefined { const pick = resolvePick(nodeId, chosen, type, rule, nodes, edges) return pick.kind === 'target' ? pick.node : undefined } +// ---------------------------------------------------------------- 设置里的核查一组 + +/** + * 设置里核查一组的值:「跟随设置」后面写出现在是开是关、相对误差的占位写出现在是多少。每次打开检查器都重取一次——去 + * 设置页关掉双盲复算、改了误差再回到画布,这里得是新值(不同于检查器的运行默认值,核查开关是常改的)。先用上次取到的 + * 画,免得闪一下;同一次打开里的几个字段共用一个请求;取不到就沿用上次的(没有就不写),不挡编辑 + */ +let verifyPending: Promise | null = null +let verifyValue: VerifySettings | null | undefined +function fetchVerify(): Promise { + verifyPending ??= api.settings.get().then( + (s) => (verifyValue = s?.verify && typeof s.verify === 'object' ? s.verify as VerifySettings : null), + () => verifyValue ?? null, + ).finally(() => { verifyPending = null }) + return verifyPending +} +export function useVerifyDefaults(active = true): VerifySettings | null { + const [value, setValue] = useState(verifyValue ?? null) + useEffect(() => { + // 用不上设置的控件(反方审稿的启用不写当前值)不取,打开检查器不多发一个请求 + if (!active) return + let alive = true + void fetchVerify().then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, [active]) + return value +} + // ---------------------------------------------------------------- 选画布上的节点 export interface NodePickText { - /** 留空那一项:按缺省能认出是哪一个时给出它的名字 */ - auto: (only: string | null) => string - /** 上游一个都没有 */ - none: string + /** 留空那一项:按缺省能认出是哪一个时给出它的名字;count 是上游这一类节点有几个 */ + auto: (only: string | null, count: number) => string + /** 上游一个都没有时下面那句提示;不给就不提示(留空那一项已经说了) */ + none?: string /** 上游不止一个、又没有选 */ many: (n: number) => string /** 选中的节点已不在画布上(下拉里那一项的叫法) */ @@ -121,45 +155,69 @@ export interface NodePickText { notUpstreamHint: string /** type 是选中节点的类型名(「输入」) */ wrongTypeHint: (type: string) => string + /** + * 下拉里把上游的和画布上其余同类节点分两组列时,两组的标题(双盲复算的口径卡:选了不在上游的,提示把它连到上游)。 + * 不给时只列上游的 + */ + groups?: { upstream: string; other: string } } -function NodePick({ p, fieldKey, nodeType, rule, text }: { - p: FieldRenderProps; fieldKey: string; nodeType: NodeType; rule: PickRule; text: NodePickText -}) { +export interface NodePickOptions { + fieldKey: string + nodeType: NodeType + rule: PickRule + text: NodePickText + /** 下拉里一个节点的叫法,缺省是「名字(id)」 */ + optionLabel?: (n: CanvasNode) => string + /** 留空那一项里,按缺省认出的那一个的名字,缺省是节点名(没起名的用 id) */ + nameOf?: (n: CanvasNode) => string +} + +const nodeName = (n: CanvasNode): string => String(n.data?.label || n.id) + +export function NodePick({ p, fieldKey, nodeType, rule, text, optionLabel = nodeLabel, nameOf = nodeName }: NodePickOptions & { p: FieldRenderProps }) { 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 { upstream, other } = useMemo(() => { const up = upstreamOf(p.nodeId, edges) - return nodes.filter((n) => up.has(n.id) && n.data?.nodeType === nodeType) + const same = nodes.filter((n) => n.data?.nodeType === nodeType) + return { upstream: same.filter((n) => up.has(n.id)), other: same.filter((n) => !up.has(n.id) && n.id !== p.nodeId) } }, [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 stale = pick.kind === 'missing' || pick.kind === 'wrongType' || (pick.kind === 'notUpstream' && !text.groups) ? 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 === '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 + const only = !value && pick.kind === 'target' ? nameOf(pick.node) : null + const option = (n: CanvasNode) => return ( <> @@ -177,10 +235,12 @@ function NodePick({ p, fieldKey, nodeType, rule, text }: { */ export function nodePickField(def: { key: string; label: string; nodeType: NodeType; rule?: PickRule; text: NodePickText; help?: string; advanced?: boolean + optionLabel?: (n: CanvasNode) => string }): FieldDef { return { key: def.key, label: def.label, type: 'select', help: def.help, advanced: def.advanced, - render: (p) => , + render: (p) => , } } @@ -190,43 +250,56 @@ export interface TriStateText { follow: string on: string off: string - /** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样 */ - hint: { follow: string; on: string; off: string } + /** 「跟随设置」那一项附上设置里现在是开是关(设置还没取到时 on 为 null / undefined)。不给就只写 follow */ + followNow?: (on: boolean | null | undefined) => 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 }) { +export function TriState({ p, label, text, setting }: { + p: FieldRenderProps; label: string; text: TriStateText + /** 从设置的核查一组里取这一项现在的开关:给了才在「跟随设置」后面写出来 */ + setting?: (v: VerifySettings) => boolean | null | undefined +}) { // 只认真正的布尔:手写的 "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 settings = useVerifyDefaults(!!setting) + const now = setting && settings ? setting(settings) : undefined const hintId = `${p.id}-hint` return ( <> {/* 字段标签的 for 指不到一组按钮,组名另写在 aria-label 上 */}
+ aria-describedby={[text.hint ? hintId : '', p.describedBy].filter(Boolean).join(' ') || undefined} + className="inline-flex max-w-full flex-wrap gap-0.5 rounded-md border p-0.5"> {TRI.map((t) => ( ))}
-
{text.hint[state]}
+ {text.hint &&
{text.hint[state]}
} ) } /** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 null)/ true / false */ -export function triStateField(def: { key: string; label: string; text: TriStateText; advanced?: boolean }): FieldDef { +export function triStateField(def: { + key: string; label: string; text: TriStateText; advanced?: boolean; help?: string + setting?: (v: VerifySettings) => boolean | null | undefined +}): FieldDef { return { - key: def.key, label: def.label, type: 'select', advanced: def.advanced, - render: (p) => , + key: def.key, label: def.label, type: 'select', advanced: def.advanced, help: def.help, + render: (p) => , } } @@ -243,17 +316,17 @@ export interface VerifyModelText { /** 模型框的占位:接入也没选 / 选了接入 */ modelFollow: string modelDefault: string - /** 取值链的说明,常显在模型框下面 */ - help: string + /** 取值链的说明,常显在模型框下面。不给就不写(说明在字段的帮助里) */ + help?: string byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => string - /** 和被核查的模型是同一个 */ - same: (model: string) => string + /** 和被核查的模型是同一个(给了 subject 时用) */ + same?: (model: string) => string } /** 被核查的那个模型:节点上明确写着的模型名;说不准(没写、跟随默认)时为 null,不提醒 */ -export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null +export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: readonly CanvasNode[]; edges: readonly CanvasEdge[] }) => string | null -function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) { +export 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() : '' @@ -272,7 +345,7 @@ function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText ) } -function ModelInput({ p, fieldKey, text, subject }: { +export function ModelInput({ p, fieldKey, text, subject }: { p: FieldRenderProps; fieldKey: string; text: VerifyModelText; subject?: SubjectModel }) { const providers = useCatalog((s) => s.providers) @@ -292,7 +365,7 @@ function ModelInput({ p, fieldKey, text, subject }: { 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 + const same = !!name && !!against && name === against && !reported && !!text.same return ( <> - {text.same(name)} + {text.same!(name)} )} -
{text.help}
+ {text.help &&
{text.help}
} ) } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index b6e833af..c5115b80 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3350,9 +3350,8 @@ export const DUAL_CHECK_TEXT = { // ---- 检查器(nodeDefs 的 dual_check、canvas/DualCheckFields.tsx) /** - * 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(schema.py 的 DUAL_CHECK_FIELD_LABEL,check-verify 在 - * 服务端合入后逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」 - * 「裁判模型」的写法 + * 字段名,键就是配置键。服务端校验和报错引用的是同一套叫法(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL,check-verify + * 逐项比对):问题挂在字段下面、出现在问题面板里时,人才找得到是哪一项。模型两项照「裁判模型 · 接入」「裁判模型」的写法 */ field: { card: '口径卡', @@ -3388,7 +3387,10 @@ export const DUAL_CHECK_TEXT = { cardOption: (label: string, caliber?: string | null) => (caliber && caliber !== label ? `${label}(${caliber})` : label), cardGone: (id: string) => `${id}(不在画布上)`, cardNotUpstream: '所选口径卡不在本节点上游,运行到本节点时还没有它的结果。请把它连接到本节点之前', - cardMissing: (id: string) => `口径卡「${id}」不在画布上,请重新选择`, + cardMissingHint: '所选口径卡已不在画布上,请重新选择', + /** 选中的节点在画布上,但不是口径卡(助手改图、导入 JSON 会写出这种配置):下拉里那一项、下面那句提示 */ + cardWrongType: (label: string) => `${label}(不是口径卡)`, + cardWrongTypeHint: (type: string) => `所选节点是「${type}」节点,不是口径卡,请重新选择`, metricsAll: '全部指标', metricsDefault: '未指定:复算全部输入来自查询的指标', metricsPicked: (n: number, total: number) => `已选 ${n} / ${total} 个指标`, @@ -3422,7 +3424,7 @@ export const DUAL_CHECK_TEXT = { }, tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`), toleranceInvalid: '请填写不小于 0 的数', - /** 选项以服务端报错里的短说法(schema.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */ + /** 选项以服务端报错里的短说法(engine/labels.py 的 DUAL_CHECK_ON_REJECT_LABEL)开头,后面可以带一句用途 */ onReject: { degrade: '计入缺口、出具降档(默认)', fail: '判为失败,运行终止' } as Record, onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, enabled: { follow: '跟随设置', on: '开启', off: '关闭' }, diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 931bead6..67c22705 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -24,7 +24,8 @@ // // 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边 // 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉, -// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py,terms.ts 借 vite 转译后直接 import。 +// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite +// 转译后直接 import。 // // node scripts/check-verify.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-verify.mjs @@ -126,26 +127,23 @@ await section('核查设置的叫法和服务端一字不差', async () => { }) await section('双盲复算检查器的叫法和服务端校验报错一致', async () => { - // 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 schema.py 的 DUAL_CHECK_FIELD_LABEL:检查器上得是同一个名字, - // 人才找得到是哪一项。处置的取值:报错里用短说法,下拉的选项以它开头、后面可以带一句用途(labels.py 的约定) + // 校验问题挂在字段下方、出现在问题面板里时,报错引用的是 labels.py 的 DUAL_CHECK_FIELD_LABEL(期 4 从 schema.py 挪过去, + // field_label 按节点类型也说这一套):检查器上得是同一个名字,人才找得到是哪一项。处置的取值:报错里用短说法,下拉的 + // 选项以它开头、后面可以带一句用途(labels.py 的约定) const { DUAL_CHECK_TEXT: D } = await import(await load('/src/lib/terms.ts')) const KEYS = ['card', 'metrics', 'definitions', 'provider', 'model', 'tolerance', 'on_reject', 'enabled'] check('检查器的字段名按配置键放在 DUAL_CHECK_TEXT.field', KEYS.every((k) => typeof D.field?.[k] === 'string' && D.field[k]), KEYS.filter((k) => !D.field?.[k]).join('、')) check('……模型的两项照「裁判模型 · 接入」「裁判模型」的叫法', D.field.provider === '盲算模型 · 接入' && D.field.model === '盲算模型', `${D.field.provider} / ${D.field.model}`) - const py = readFileSync(`${root}backend/app/engine/schema.py`, 'utf8') - const block = py.match(/^DUAL_CHECK_FIELD_LABEL = \{([\s\S]*?)^\}/m)?.[1] - if (block == null) { - // 双盲复算的服务端在另一个工作包里,合入之前 schema.py 还没有这一组:合入后这一节就逐项比对 - check('schema.py 还没有 DUAL_CHECK_FIELD_LABEL(双盲复算服务端合入后逐项比对)', true) - return - } - const labels = Object.fromEntries([...block.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) + const py = readFileSync(`${root}backend/app/engine/labels.py`, 'utf8') + const block = py.match(/^DUAL_CHECK_FIELD_LABEL: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] + check('从 labels.py 读出了 DUAL_CHECK_FIELD_LABEL', block != null) + const labels = Object.fromEntries([...(block ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) const wrong = KEYS.filter((k) => labels[k] !== D.field[k]) check('每个字段的叫法和 DUAL_CHECK_FIELD_LABEL 一字不差', !wrong.length && Object.keys(labels).length === KEYS.length, wrong.map((k) => `${k}:${D.field[k]} ≠ ${labels[k]}`).join(';')) - const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL = \{([^}]*)\}/m)?.[1] ?? '' + const opts = py.match(/^DUAL_CHECK_ON_REJECT_LABEL: dict\[str, str\] = \{([^}]*)\}/m)?.[1] ?? '' const short = Object.fromEntries([...opts.matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])) check('「未采用原流程时」的选项以服务端的短说法开头', Object.keys(short).length === 2 && Object.entries(short).every(([k, v]) => D.onReject?.[k]?.startsWith(v)), JSON.stringify(short)) @@ -931,11 +929,32 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 check('……标签指向这个下拉', await page.locator('[data-field="card"] label').getAttribute('for') === await card.getAttribute('id')) await card.selectOption('card2') check('选另一张:写进 card', (await config(page)).card === 'card2', JSON.stringify(await config(page))) - check('……不在上游的照实提醒', (await text(page.locator('[data-dual-card-warn]'))).includes('不在本节点上游')) + check('……不在上游的照实提醒', (await text(page.locator('[data-node-pick-hint="notUpstream"]'))).includes('不在本节点上游')) const swapped = await summaryBecomes(page, '复算「月报口径卡」 · 全部 1 个指标') check('……摘要换成这张卡', swapped.ok, swapped.seen) await card.selectOption('') check('选回默认:card 去掉,不留空串', !('card' in (await config(page))) || (await config(page)).card === undefined, JSON.stringify(await config(page))) + // 选口径卡用的是和反方审稿共用的控件(verifyFields.tsx 的 NodePick):写着的卡被删了、写的不是口径卡(助手改图、导入 + // JSON 会写出这种配置)都照实列在下拉里(选不回去)、下面说一句,不让它看着像「默认」 + const setCard = (value) => page.evaluate((v) => { + const s = window.__studio.getState() + const n = s.nodes.find((x) => x.id === 'dc') + s.updateNode('dc', { config: { ...n.data.config, card: v } }) + }, value) + const selected = () => card.evaluate((el) => el.options[el.selectedIndex]?.textContent) + await setCard('ghost') + await page.waitForTimeout(100) + check('写着的卡已不在画布上:下拉照实标出,下面说一句', await selected() === 'ghost(不在画布上)' + && (await text(page.locator('[data-node-pick-hint="missing"]'))) === '所选口径卡已不在画布上,请重新选择', await selected()) + await setCard('fetch') + await page.waitForTimeout(100) + const wrongType = page.locator('[data-node-pick-hint="wrongType"]') + check('写的不是口径卡:下拉标「不是口径卡」,提示说清它是哪类节点,不说成不在画布上', await selected() === '取数(不是口径卡)' + && await wrongType.count() === 1 && (await text(wrongType)).startsWith('所选节点是「') && (await text(wrongType)).endsWith('」节点,不是口径卡,请重新选择') + && await page.locator('[data-node-pick-hint="missing"]').count() === 0, `${await selected()} · ${await wrongType.count() ? await text(wrongType) : '没有提示'}`) + await card.selectOption('') + check('……选回默认:提示收起、card 去掉', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === undefined, + JSON.stringify(await config(page))) // 指标:逐个勾;全部勾着是缺省,不写;最后一个不让取消 const opts = page.locator('[data-dual-metric-option]') @@ -973,16 +992,18 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 // 启用:跟随设置 / 开启 / 关闭 const enabled = page.locator('[data-field="enabled"] [role="radiogroup"]') + check('启用用的是和反方审稿共用的三态控件,组名是字段名', (await enabled.getAttribute('data-tri-state')) === 'follow' + && (await enabled.getAttribute('aria-label')) === D.field.enabled) check('启用:三态,跟随设置写出设置里眼下是开着的', (await enabled.locator('[role="radio"]').allInnerTexts()).join('|') === '跟随设置(当前开启)|开启|关闭' - && await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-value') === 'follow') - await enabled.locator('[data-value="off"]').click() + && await enabled.locator('[role="radio"][aria-checked="true"]').getAttribute('data-tri') === 'follow') + await enabled.locator('[data-tri="off"]').click() const off = await summaryBecomes(page, { suffix: ' · 已关闭' }) check('关闭:enabled 为 false,摘要标「已关闭」', (await config(page)).enabled === false && off.ok, off.seen) - await enabled.locator('[data-value="off"]').focus() + await enabled.locator('[data-tri="off"]').focus() await page.keyboard.press('ArrowLeft') check('键盘方向键换选项:开启是 true', (await config(page)).enabled === true - && await page.evaluate(() => document.activeElement?.getAttribute('data-value')) === 'on') - await enabled.locator('[data-value="follow"]').click() + && await page.evaluate(() => document.activeElement?.getAttribute('data-tri')) === 'on') + await enabled.locator('[data-tri="follow"]').click() check('跟随设置:enabled 去掉,不留 null', (await config(page)).enabled === undefined, JSON.stringify(await config(page))) // 未采用原流程时 @@ -1007,7 +1028,7 @@ await section('检查器:每一项都写进节点配置,节点卡片的摘 check('模型:写进 model', (await config(page)).model === 'model-b') await provider.selectOption('') check('只填了模型:接入留空那一项改叫「按模型名找接入」,写明按模型名用哪个接入', (await provider.locator('option').first().innerText()) === '按模型名找接入' - && (await text(page.locator('[data-dual-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined) + && (await text(page.locator('[data-verify-by-model]'))).includes('将按模型名使用接入「云端接入」') && (await config(page)).provider === undefined) const tol = page.locator('[data-field="tolerance"] input') check('允许的相对误差:占位写出设置里的值', await tol.getAttribute('placeholder') === '跟随设置(0.000001)', await tol.getAttribute('placeholder')) await tol.fill('0.001') @@ -1035,20 +1056,20 @@ await section('检查器:上游有几张口径卡时必须选;「跟随设 await page.waitForFunction(() => document.querySelector('[data-field="card"] select option')?.textContent?.includes('2 张')) check('上游两张:留空那一项写明要选,不说「建议」', (await card.locator('option').first().innerText()) === '请选择要复算的口径卡(上游有 2 张)' && !(await text(page.locator('[data-field="card"]'))).includes('建议'), await card.locator('option').first().innerText()) - check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-dual-card-warn]'))) === '上游有 2 张口径卡,请选择要复算的那一张') + check('……下面用提醒色写明必须选一张', (await text(page.locator('[data-node-pick-hint="many"]'))) === '上游有 2 张口径卡,请选择要复算的那一张') const many = await summaryBecomes(page, '复算上游的口径卡 · 上游有 2 张,请指定') check('……节点卡片的摘要也这么说', many.ok, many.seen) await card.selectOption('card') - check('……选了一张:提醒收起', await page.locator('[data-dual-card-warn]').count() === 0 && (await config(page)).card === 'card') + check('……选了一张:提醒收起', await page.locator('[data-node-pick-hint]').count() === 0 && (await config(page)).card === 'card') // 去设置里关掉双盲复算、改了相对误差,再回到画布打开这个节点:「跟随设置」写的是新的值 - const enabled = page.locator('[data-field="enabled"] [role="radio"][data-value="follow"]') + const enabled = page.locator('[data-field="enabled"] [role="radio"][data-tri="follow"]') check('打开时:跟随设置(当前开启)', (await text(enabled)) === '跟随设置(当前开启)', await text(enabled)) verifyNow = { ...fx.verify, dual_check: { ...fx.verify.dual_check, enabled: false, tolerance: 0.01 } } await page.evaluate(() => window.__studio.setState({ selectedId: null })) await page.waitForSelector('[data-field="enabled"]', { state: 'detached' }) await page.evaluate(() => window.__studio.setState({ selectedId: 'dc' })) - await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-value="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 }) + await page.waitForFunction(() => document.querySelector('[data-field="enabled"] [data-tri="follow"]')?.textContent === '跟随设置(当前关闭)', null, { timeout: 3000 }) .catch(() => {}) check('再打开:重取设置,写当前关闭', (await text(enabled)) === '跟随设置(当前关闭)', await text(enabled)) await page.locator('button', { hasText: '高级选项' }).click() @@ -1071,7 +1092,7 @@ await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色 check(`${width}px(${theme}):检查器不横向溢出`, w.page <= 0 && w.panel <= 0 && w.body <= 0, JSON.stringify(w)) const c = await page.evaluate(() => { const base = getComputedStyle(document.querySelector('[data-dual-inspector]')).backgroundColor - const el = document.querySelector('[data-dual-by-model]') + const el = document.querySelector('[data-verify-by-model]') const radio = document.querySelector('[data-field="enabled"] [role="radio"][aria-checked="true"]') return { base, hint: el && getComputedStyle(el).color, radio: radio && getComputedStyle(radio).color, radioBg: radio && getComputedStyle(radio).backgroundColor }