mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:41:57 +00:00
核查类节点检查器的通用控件合成一份:选画布节点、三态开关、核查模型选择只留在 verifyFields.tsx
期 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 挪过去);补写着的卡不在画布上、不是口径卡两种情形
This commit is contained in:
@@ -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<string, any> => !!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<string> {
|
||||
const parents = new Map<string, string[]>()
|
||||
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
|
||||
const seen = new Set<string>()
|
||||
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<string, any>, 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<VerifySettings | null> | null = null
|
||||
let verifyValue: VerifySettings | null | undefined
|
||||
function fetchVerify(): Promise<VerifySettings | null> {
|
||||
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<VerifySettings | null>(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 (
|
||||
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} {...{ [attr]: '' }}>{children}</div>
|
||||
@@ -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) => (
|
||||
<option key={n.id} value={n.id}>{T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)}</option>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<select id={id} className="field" value={current} aria-invalid={invalid || undefined} aria-describedby={describedBy}
|
||||
data-dual-field="card" onChange={(e) => onChange(e.target.value || undefined)}>
|
||||
<option value="">{T.cardDefault(upstream.length === 1 ? cardName(upstream[0]) : null, upstream.length)}</option>
|
||||
{upstream.length > 0 && <optgroup label={T.cardUpstream}>{upstream.map(option)}</optgroup>}
|
||||
{other.length > 0 && <optgroup label={T.cardOther}>{other.map(option)}</optgroup>}
|
||||
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
|
||||
{current && !known && <option value={current}>{T.cardGone(current)}</option>}
|
||||
</select>
|
||||
{/* 上游有好几张又没选:服务端校验会挡住发起运行,用提醒色写明要选 */}
|
||||
{!current && upstream.length > 1 && <Warn attr="data-dual-card-warn">{T.cardChoose(upstream.length)}</Warn>}
|
||||
{current && known && !upstream.some((n) => n.id === current) && <Warn attr="data-dual-card-warn">{T.cardNotUpstream}</Warn>}
|
||||
{current && !known && <Warn attr="data-dual-card-warn">{T.cardMissing(current)}</Warn>}
|
||||
</>
|
||||
)
|
||||
/**
|
||||
* 要复算的口径卡(共用的 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 (
|
||||
<select id={id} className="field" value={provider} aria-invalid={invalid || undefined} aria-describedby={describedBy}
|
||||
data-dual-field="provider" onChange={(e) => onChange(e.target.value || undefined)}>
|
||||
<option value="">{byModel ? T.providerFromModel : T.providerFollow}</option>
|
||||
{provider && !known && providers.length > 0 && <option value={provider}>{T.providerGone(provider)}</option>}
|
||||
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : T.providerOff}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */
|
||||
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 (
|
||||
<>
|
||||
<input id={id} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
|
||||
placeholder={provider ? T.modelOfProvider : T.modelFollow} aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy} data-dual-field="model"
|
||||
onChange={(e) => onChange(e.target.value.trim() ? e.target.value : undefined)} />
|
||||
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
|
||||
{hint && (
|
||||
<div className="mt-1 text-2xs leading-snug" data-dual-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
|
||||
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
|
||||
<span className={owner?.enabled ? 'text-faint' : undefined}>{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
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 (
|
||||
<div id={id} role="radiogroup" aria-label={T.field.enabled} aria-describedby={describedBy}
|
||||
className="flex flex-wrap gap-0.5 rounded border p-0.5" data-dual-field="enabled">
|
||||
{ENABLED.map((v) => (
|
||||
<button key={v} type="button" {...radio(v)} data-value={v}
|
||||
// 按字数分宽度、不折行:「跟随设置(当前开启)」比「开启」长得多,平分会把它挤成两行;太窄时整段换到下一行
|
||||
className={clsx('flex-auto whitespace-nowrap rounded px-2 py-1 text-2xs transition-colors',
|
||||
current === v ? 'font-medium text-fg' : 'text-dim hover:bg-hover')}
|
||||
style={current === v ? { background: 'var(--accent-soft)', boxShadow: 'inset 0 0 0 1px var(--accent)' } : undefined}
|
||||
onClick={() => pick(v)}>
|
||||
{v === 'follow' ? T.enabledFollow(settings?.dual_check?.enabled) : T.enabled[v]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
/**
|
||||
* 启用(共用的 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) => <CardField {...p} />,
|
||||
card: (p: FieldRenderProps) => (
|
||||
<NodePick p={p} fieldKey="card" nodeType="metrics" rule="upstream" text={CARD_TEXT} optionLabel={cardOption} nameOf={cardName} />
|
||||
),
|
||||
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
|
||||
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
|
||||
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
|
||||
model: (p: FieldRenderProps) => <ModelField {...p} />,
|
||||
provider: (p: FieldRenderProps) => <ProviderPick p={p} text={MODEL_TEXT} />,
|
||||
model: (p: FieldRenderProps) => <ModelInput p={p} fieldKey="model" text={MODEL_TEXT} />,
|
||||
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
|
||||
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
|
||||
enabled: (p: FieldRenderProps) => (
|
||||
<TriState p={p} label={T.field.enabled} text={ENABLED_TEXT} setting={(v) => v.dual_check?.enabled} />
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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<string, any> } }
|
||||
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<string> {
|
||||
/** 某个节点的全部上游(沿连线往回找),不含它自己。只认上游:下游的节点运行到这里时还没有产出 */
|
||||
export function upstreamOf(nodeId: string, edges: readonly CanvasEdge[]): Set<string> {
|
||||
const parents = new Map<string, string[]>()
|
||||
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
|
||||
const seen = new Set<string>()
|
||||
@@ -66,17 +72,17 @@ export type PickRule = 'upstream' | 'direct'
|
||||
* 配置里那一项按服务端的认法落到哪个节点上:target 是定下来的那一个;其余几种是定不下来的原因——写了的节点不存在、
|
||||
* 不是这一类、不在上游(先后同服务端),留空时上游一个都没有、不止一个
|
||||
*/
|
||||
export type PickState =
|
||||
| { kind: 'target'; node: FlowNode }
|
||||
export type PickState<N extends CanvasNode = CanvasNode> =
|
||||
| { 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<N extends CanvasNode>(
|
||||
nodeId: string, chosen: unknown, type: NodeType, rule: PickRule, nodes: readonly N[], edges: readonly CanvasEdge[],
|
||||
): PickState<N> {
|
||||
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<N extends CanvasNode>(
|
||||
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<VerifySettings | null> | null = null
|
||||
let verifyValue: VerifySettings | null | undefined
|
||||
function fetchVerify(): Promise<VerifySettings | null> {
|
||||
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<VerifySettings | null>(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) => <option key={n.id} value={n.id}>{optionLabel(n)}</option>
|
||||
return (
|
||||
<>
|
||||
<select id={p.id} className="field" value={value} disabled={p.disabled} aria-invalid={p.invalid || undefined}
|
||||
aria-describedby={[p.describedBy, hint ? hintId : ''].filter(Boolean).join(' ') || undefined}
|
||||
style={p.invalid || stale ? { borderColor: p.invalid ? 'var(--err)' : 'var(--warn)' } : undefined}
|
||||
onChange={(e) => p.onChange(e.target.value || undefined)}>
|
||||
<option value="">{text.auto(only)}</option>
|
||||
{options.map((n) => <option key={n.id} value={n.id}>{nodeLabel(n)}</option>)}
|
||||
<option value="">{text.auto(only, upstream.length)}</option>
|
||||
{text.groups ? (
|
||||
<>
|
||||
{upstream.length > 0 && <optgroup label={text.groups.upstream}>{upstream.map(option)}</optgroup>}
|
||||
{other.length > 0 && <optgroup label={text.groups.other}>{other.map(option)}</optgroup>}
|
||||
</>
|
||||
) : upstream.map(option)}
|
||||
{stale && (
|
||||
<option value={value} disabled>
|
||||
{stale.kind === 'missing' ? text.missing(value)
|
||||
: stale.kind === 'wrongType' ? text.wrongType(nodeLabel(stale.node)) : text.notUpstream(nodeLabel(stale.node))}
|
||||
: stale.kind === 'wrongType' ? text.wrongType(optionLabel(stale.node)) : text.notUpstream(optionLabel(stale.node))}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
@@ -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) => <NodePick p={p} fieldKey={def.key} nodeType={def.nodeType} rule={def.rule ?? 'upstream'} text={def.text} />,
|
||||
render: (p) => <NodePick p={p} fieldKey={def.key} nodeType={def.nodeType} rule={def.rule ?? 'upstream'} text={def.text}
|
||||
optionLabel={def.optionLabel} />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 上 */}
|
||||
<div id={p.id} role="radiogroup" aria-label={label} data-tri-state={state}
|
||||
aria-describedby={[hintId, p.describedBy].filter(Boolean).join(' ')}
|
||||
className="inline-flex max-w-full flex-wrap rounded-md border p-px">
|
||||
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) => (
|
||||
<button key={t} type="button" {...radio(t)} disabled={p.disabled} data-tri={t} onClick={() => set(t)}
|
||||
className={clsx('rounded px-2 py-0.5 text-2xs', state === t ? 'bg-accent-soft text-fg' : 'text-faint hover:text-dim')}>
|
||||
{text[t]}
|
||||
// 不折行:「跟随设置(当前开启)」比「开启」长得多,太窄时整段换到下一行
|
||||
className={clsx('whitespace-nowrap rounded px-2 py-0.5 text-2xs transition-colors',
|
||||
state === t ? 'bg-accent-soft font-medium text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{t === 'follow' && text.followNow ? text.followNow(now) : text[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div id={hintId} className="mt-1 text-2xs leading-snug text-faint" data-tri-hint={state}>{text.hint[state]}</div>
|
||||
{text.hint && <div id={hintId} className="mt-1 text-2xs leading-snug text-faint" data-tri-hint={state}>{text.hint[state]}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 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) => <TriState p={p} label={def.label} text={def.text} />,
|
||||
key: def.key, label: def.label, type: 'select', advanced: def.advanced, help: def.help,
|
||||
render: (p) => <TriState p={p} label={def.label} text={def.text} setting={def.setting} />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, any>, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null
|
||||
export type SubjectModel = (nodeId: string, config: Record<string, any>, 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 (
|
||||
<>
|
||||
<input id={p.id} className="field mono text-xs" list={`${p.id}-models`} value={model} spellCheck={false}
|
||||
@@ -309,10 +382,10 @@ function ModelInput({ p, fieldKey, text, subject }: {
|
||||
)}
|
||||
{same && (
|
||||
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} data-verify-same-model="">
|
||||
{text.same(name)}
|
||||
{text.same!(name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-2xs leading-snug text-faint">{text.help}</div>
|
||||
{text.help && <div className="mt-1 text-2xs leading-snug text-faint">{text.help}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>,
|
||||
onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`,
|
||||
enabled: { follow: '跟随设置', on: '开启', off: '关闭' },
|
||||
|
||||
+45
-24
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user