双盲复算前端(三):检查器字段、节点卡片摘要,以及检查器与节点卡片的钩子

- nodeDefs:照约定一字不差地加 FieldDef.render 与 FieldRenderProps;NodeDef 加可选的 summary(ctx) 钩子
  (ctx:id、config、画布上的 nodes 与 edges,只返回字符串);dual_check 换成真字段:要复算的口径卡、复算的指标、
  口径说明、启用、审核人不采用原流程的结果时(基本),盲算模型的接入与模型、允许的相对误差(高级)
- Inspector:照约定一字不差地在 FieldInput 的 switch 前交给 field.render
- canvas/DualCheckFields.tsx(新):口径卡下拉分上游、其余两组,不在上游或不在画布上照实提醒;指标逐个勾,
  全勾回到缺省不写、最后一个不让取消;口径说明逐个指标写,被取消的指标上已写的说明列出来可删;盲算模型照「结论句
  裁判」的写法(只填模型时写明按模型名用哪个接入);相对误差的占位写出设置里的值;启用三态,跟随设置不留 null。
  每个控件只写自己的键,留空写回 undefined
- NodeCard:用只返回字符串的 studio 选择器调摘要钩子,有就用、没有照旧 summarize;摘要写「复算「卡名」 · N 个指标」,
  关闭时标出;去掉 dual_check 的占位 case
- 预览用例加检查器(小画布用真的节点卡片 + 360px 检查器),check-verify 加检查器写配置、摘要跟着变、窄屏和对比度
- 去掉 nodeDefs 里已经不成立的「下面两种还是占位」注释
This commit is contained in:
ThearchyHelios
2026-10-05 14:23:17 +08:00
parent cac34c90dd
commit 7a9dc93dde
8 changed files with 887 additions and 11 deletions
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useState } 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'
/*
* 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。
*
* 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 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、由调用方照实说
*/
function targetOf(config: Record<string, any>, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const { upstream } = cardsOf(id, nodes, edges)
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 }
}
/** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */
const cardName = (n: CanvasNode): string =>
String(n.data.label || n.data.config?.caliber || n.id)
/** 口径卡里定义的指标;引用别的工作流发布的口径(caliber_from)时指标运行时才知道,返回 null */
function metricsOf(n: CanvasNode): { id: string; name: string }[] | null {
const cfg = n.data.config ?? {}
if (isRecord(cfg.caliber_from)) return null
return (Array.isArray(cfg.metrics) ? cfg.metrics : [])
.filter((m: any) => isRecord(m) && typeof m.id === 'string' && m.id)
.map((m: any) => ({ id: String(m.id), name: typeof m.name === 'string' ? m.name : '' }))
}
/**
* 节点卡片的摘要:复算哪张口径卡、几个指标(NodeDef.summary)。NodeCard 在 studio store 的选择器里调它,
* 每条事件都会跑一遍:只做几次数组查找,返回字符串
*/
export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContext): string {
const S = T.summary
const { node, card, upstream } = targetOf(config, id, nodes, edges)
const picked = ids(config.metrics)
let text: string
if (node) {
const all = metricsOf(node)
text = S.card(cardName(node), picked.length ? picked.length : null, all ? all.length : null)
} else if (card) {
text = S.card(card, picked.length ? picked.length : null, null)
} else {
text = upstream.length > 1 ? S.many(upstream.length) : S.noCard
}
return config.enabled === false ? `${text} · ${S.off}` : text
}
// ---------------------------------------------------------------- 设置里的默认值
/**
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出眼下是多少。整个会话取一次(同检查器的运行默认值),
* 取不到就不写,不挡编辑
*/
let verifyPending: Promise<VerifySettings | null> | null = null
let verifyValue: VerifySettings | null | undefined
function useVerifyDefaults(): VerifySettings | null {
const [value, setValue] = useState<VerifySettings | null>(verifyValue ?? null)
useEffect(() => {
if (verifyValue !== undefined) return
verifyPending ??= api.settings.get().then(
(s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null),
() => { verifyPending = null; return null }, // 这次没取到,下次打开再试
)
let alive = true
void verifyPending.then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [])
return verifyValue ?? 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>
)
}
// ---------------------------------------------------------------- 各字段
/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */
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 && 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>}
</>
)
}
/**
* 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回
* undefined,配置里不留一份和缺省等价的清单。最后一个不让取消:一个都不勾和缺省分不清
*/
function MetricsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const picked = ids(value)
const metrics = target.node ? metricsOf(target.node) : undefined
if (!target.node || metrics === undefined) {
return (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-metrics="none">
{target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard}
</div>
)
}
if (metrics === null || !metrics.length) {
return (
<div id={id} aria-describedby={describedBy} data-dual-metrics={metrics === null ? 'pinned' : 'empty'}>
<div className="text-2xs leading-snug text-faint">{metrics === null ? T.metricsPinned : T.metricsNone}</div>
{picked.length > 0 && (
<button type="button" className="btn btn-xs mt-1" onClick={() => onChange(undefined)}>{T.metricsClear}</button>
)}
</div>
)
}
const known = new Set(metrics.map((m) => m.id))
const valid = picked.filter((p) => known.has(p))
const stale = picked.filter((p) => !known.has(p))
const all = !picked.length
const checked = new Set(all ? metrics.map((m) => m.id) : valid)
const write = (next: Set<string>) => {
const ordered = metrics.map((m) => m.id).filter((m) => next.has(m))
onChange(ordered.length === metrics.length ? undefined : ordered)
}
return (
<div id={id} role="group" aria-label={T.field.metrics} aria-describedby={describedBy} data-dual-metrics="list">
<div className="space-y-0.5 rounded border px-2 py-1.5">
{metrics.map((m) => {
const on = checked.has(m.id)
const last = on && checked.size === 1
return (
<label key={m.id} className={clsx('flex items-center gap-2 py-0.5 text-xs', last ? 'cursor-not-allowed' : 'cursor-pointer')}
title={last ? T.metricsLast : undefined} data-dual-metric-option={m.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={on} disabled={last}
onChange={(e) => {
const next = new Set(checked)
if (e.target.checked) next.add(m.id)
else next.delete(m.id)
write(next)
}} />
<span className="min-w-0 flex-1 truncate">{T.metricOption(m.name, m.id)}</span>
</label>
)
})}
</div>
<div className="mt-1 text-2xs leading-snug text-faint" data-dual-metrics-state={all ? 'default' : 'picked'}>
{all ? T.metricsDefault : T.metricsPicked(checked.size, metrics.length)}
</div>
{stale.length > 0 && (
<Warn attr="data-dual-metrics-stale">
{T.metricsStale(stale)}
<button type="button" className="btn btn-xs ml-1 align-baseline" onClick={() => onChange(valid.length ? valid : undefined)}>
{T.metricsClear}
</button>
</Warn>
)}
</div>
)
}
/**
* 口径说明:要复算的每个指标一段,给盲算模型看的业务定义(它看不到原流程的 SQL)。存成 {指标 id: 说明},
* 清空的那一项去掉,全清空写回 undefined。已经写了、指标却不在复算范围里的照样列出来,好让人看见并删掉
*/
function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const defs: Record<string, string> = isRecord(value)
? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
const metrics = target.node ? metricsOf(target.node) : undefined
const picked = ids(config.metrics)
const rows = metrics ? (picked.length ? metrics.filter((m) => picked.includes(m.id)) : metrics) : []
const gone = Object.keys(defs).filter((k) => !rows.some((r) => r.id === k))
const set = (metric: string, text: string | null) => {
const next = { ...defs }
if (text != null && text.trim()) next[metric] = text
else delete next[metric]
onChange(Object.keys(next).length ? next : undefined)
}
if (!rows.length && !gone.length) {
return (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-definitions="none">
{metrics === null ? T.definitionsPinned : T.definitionsNone}
</div>
)
}
return (
<div id={id} role="group" aria-label={T.field.definitions} aria-describedby={describedBy} className="space-y-1.5"
data-dual-definitions="list">
{rows.map((m) => (
<div key={m.id} data-dual-definition={m.id}>
<label className="mb-0.5 block truncate text-2xs text-dim" htmlFor={`${id}-${m.id}`}>{T.metricOption(m.name, m.id)}</label>
<textarea id={`${id}-${m.id}`} className="field text-xs" rows={2} value={defs[m.id] ?? ''}
placeholder={T.definitionPlaceholder(m.name || m.id)} onChange={(e) => set(m.id, e.target.value)} />
</div>
))}
{gone.map((k) => (
<Warn key={k} attr="data-dual-definition-gone">
<span className="flex items-start gap-1">
<span className="min-w-0 flex-1">{T.definitionGone(k)}</span>
<button type="button" className="shrink-0 rounded p-0.5 hover:bg-hover" aria-label={`${T.definitionRemove}:${k}`}
title={T.definitionRemove} onClick={() => set(k, null)}>
<X size={11} aria-hidden />
</button>
</span>
</Warn>
))}
</div>
)
}
/**
* 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫
* 「按模型名找接入」(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>
)}
</>
)
}
/** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */
function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const bad = typeof value === 'number' && (!Number.isFinite(value) || value < 0)
return (
<>
<input id={id} className="field tnum" type="number" min={0} step="any" value={value ?? ''}
placeholder={T.tolerancePlaceholder(settings?.dual_check?.tolerance)}
aria-invalid={invalid || bad || undefined} aria-describedby={[describedBy, bad ? `${id}-bad` : ''].filter(Boolean).join(' ') || undefined}
style={bad ? { borderColor: 'var(--err)' } : undefined} data-dual-field="tolerance"
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
{bad && <div id={`${id}-bad`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>{T.toleranceInvalid}</div>}
</>
)
}
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>
)
}
/** nodeDefs 里 dual_check 各字段的 render */
export const dualCheckFields = {
card: (p: FieldRenderProps) => <CardField {...p} />,
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
model: (p: FieldRenderProps) => <ModelField {...p} />,
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
}
+3
View File
@@ -618,6 +618,9 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
const defaults = useRunDefaults(field.key === 'scope' || field.type === 'collection')
const aria = { 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy }
if (field.render) {
return <>{field.render({ id, nodeId, value, config, invalid, describedBy, disabled, onChange })}</>
}
switch (field.type) {
case 'text':
if (syntax !== 'plain') {
+4 -1
View File
@@ -766,10 +766,13 @@ function NodeCardImpl({ id, data, selected, isConnectable }: NodeProps<FlowNode>
const reportStamp = useReportStamp(id, type === 'report')
// 只有排队中的卡片要知道自己有几路上游,别的卡片不必为改图重跑这一趟
const fanIn = useStudio((s) => (state === 'queued' ? fanInOf(s.nodes, s.edges)[id] ?? 0 : 0))
// 节点定义给了摘要钩子(摘要要提到别的节点:双盲复算复算的是哪张口径卡)就用它,没给照旧走 summarize。
// 选择器只返回字符串:别的节点改了、这一句没变,卡片就不重渲染
const hookedSummary = useStudio((s) => def?.summary?.({ id, config: data.config, nodes: s.nodes, edges: s.edges }) ?? '')
const Icon = def?.icon ?? Sparkles
const handles = sourceHandles(type, data.config)
const summary = summarize(type, data.config)
const summary = hookedSummary || summarize(type, data.config)
const meta = statusMeta(state)
// 循环容器在两轮之间也是 running,但在跑的是循环体:不转圈、不挂光弧。
// 回放时取游标那一刻的(航迹上的 looping 是最后的值)
+68 -3
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import {
Bot, Braces, Brain, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText,
Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench,
@@ -6,6 +7,7 @@ import {
APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL,
} from '../lib/terms'
import type { NodeType } from '../types'
import { dualCheckFields, dualCheckSummary } from './DualCheckFields'
export type FieldType =
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
@@ -51,6 +53,23 @@ export interface FieldDef {
*/
unknownLabel?: (value: string) => string
advanced?: boolean
/**
* 现有字段类型画不了的配置(选画布上某一类节点、三态开关、接入加模型)交给它画。
* 给了 render 就不再按 type 渲染;type 仍用于默认值和校验
*/
render?: (p: FieldRenderProps) => ReactNode
}
/** FieldDef.render 拿到的:当前值、写回、所在节点和配置,以及无障碍属性 */
export interface FieldRenderProps {
id: string
nodeId: string
value: any
config: Record<string, any>
invalid: boolean
describedBy?: string
disabled?: boolean
onChange: (v: any) => void
}
/**
@@ -91,6 +110,19 @@ export interface NodeDef {
sources: HandleDef[] | null
fields: FieldDef[]
defaults: Record<string, any>
/**
* 卡片正文那一行摘要要提到画布上别的节点时给它(双盲复算复算的是哪张口径卡、几个指标),没给的照旧走
* NodeCard 的 summarize。NodeCard 在 studio store 的选择器里调它、每条事件都会跑:要快、没有副作用、只返回字符串
*/
summary?: (ctx: NodeSummaryContext) => string
}
/** NodeDef.summary 拿到的:这个节点的 id 和配置,以及画布上的全部节点和连线 */
export interface NodeSummaryContext {
id: string
config: Record<string, any>
nodes: readonly { id: string; data: { nodeType: NodeType; label?: string; config?: Record<string, any> } }[]
edges: readonly { source: string; target: string }[]
}
const MODEL_FIELDS: FieldDef[] = [
@@ -658,14 +690,47 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
metrics: [{ id: 'total', name: '', unit: '', expression: '' }],
},
},
// 下面两种还是占位:服务端的执行器原样跳过(engine/nodes/dual_check.py、challenge.py),没有可配的字段。
// 说明里写明尚未生效,检查器顶上就是这一句;各自的工作包补上字段时把这句去掉
dual_check: {
type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows,
description: DUAL_CHECK_TEXT.description,
hasTarget: true, sources: [{ id: 'out', label: '' }],
fields: [],
// 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点,
// 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看
fields: [
{ key: 'card', label: DUAL_CHECK_TEXT.field.card, type: 'select', help: DUAL_CHECK_TEXT.help.card, render: dualCheckFields.card },
{
key: 'metrics', label: DUAL_CHECK_TEXT.field.metrics, type: 'json', help: DUAL_CHECK_TEXT.help.metrics,
render: dualCheckFields.metrics,
},
{
key: 'definitions', label: DUAL_CHECK_TEXT.field.definitions, type: 'json', help: DUAL_CHECK_TEXT.help.definitions,
render: dualCheckFields.definitions,
},
{
key: 'enabled', label: DUAL_CHECK_TEXT.field.enabled, type: 'select', help: DUAL_CHECK_TEXT.help.enabled,
render: dualCheckFields.enabled,
},
{
// 没写和写 degrade 是一回事:选项从 degrade 排起,没写时下拉框落在它上面,正好是缺省
key: 'on_reject', label: DUAL_CHECK_TEXT.field.onReject, type: 'select', help: DUAL_CHECK_TEXT.help.onReject,
options: [
{ value: 'degrade', label: DUAL_CHECK_TEXT.onReject.degrade },
{ value: 'fail', label: DUAL_CHECK_TEXT.onReject.fail },
],
unknownLabel: DUAL_CHECK_TEXT.onRejectUnknown,
},
{ key: 'provider', label: DUAL_CHECK_TEXT.field.provider, type: 'select', advanced: true, render: dualCheckFields.provider },
{
key: 'model', label: DUAL_CHECK_TEXT.field.model, type: 'text', advanced: true, help: DUAL_CHECK_TEXT.help.model,
render: dualCheckFields.model,
},
{
key: 'tolerance', label: DUAL_CHECK_TEXT.field.tolerance, type: 'number', min: 0, advanced: true,
help: DUAL_CHECK_TEXT.help.tolerance, render: dualCheckFields.tolerance,
},
],
defaults: {},
summary: dualCheckSummary,
},
challenge: {
type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert,
+223 -3
View File
@@ -24,7 +24,9 @@
"original_rendered": "45,678.5元",
"blind_rendered": "51,388.3元",
"delta_pct": 12.5,
"diff": ["原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"],
"diff": [
"原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
],
"fallback": false
},
{
@@ -79,7 +81,10 @@
"diff": {
"equivalent": false,
"items": [
{ "aspect": "time", "text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选" }
{
"aspect": "time",
"text": "原流程按支付时间 paid_at 筛选时间范围,盲算按下单时间 created_at 筛选"
}
],
"fallback": false,
"text_diff": [
@@ -140,7 +145,10 @@
"diff": {
"equivalent": false,
"items": [
{ "aspect": "other", "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" }
{
"aspect": "other",
"text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
}
],
"fallback": true,
"text_diff": [
@@ -150,5 +158,217 @@
}
}
]
},
"graph": {
"nodes": [
{
"id": "start",
"type": "input",
"position": {
"x": 0,
"y": 120
},
"data": {
"label": "输入",
"config": {
"fields": [
{
"name": "week",
"required": true
}
]
}
}
},
{
"id": "fetch",
"type": "agent",
"position": {
"x": 280,
"y": 120
},
"data": {
"label": "取数",
"config": {
"prompt": "查询本周各门店的订单",
"tools": [
"db_query__shop"
],
"assign_to": "kpi"
}
}
},
{
"id": "card",
"type": "metrics",
"position": {
"x": 560,
"y": 40
},
"data": {
"label": "周报口径卡",
"config": {
"caliber": "周报口径",
"caliber_version": "v1",
"metrics": [
{
"id": "gmv",
"name": "销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
},
{
"id": "orders",
"name": "订单数",
"unit": "单",
"expression": "vars.kpi.orders"
},
{
"id": "aov",
"name": "客单价",
"unit": "元",
"expression": "vars.kpi.gmv / vars.kpi.orders"
}
]
}
}
},
{
"id": "card2",
"type": "metrics",
"position": {
"x": 560,
"y": 260
},
"data": {
"label": "月报口径卡",
"config": {
"caliber": "月报口径",
"caliber_version": "v2",
"metrics": [
{
"id": "m_gmv",
"name": "月销售额",
"unit": "元",
"expression": "vars.kpi.gmv"
}
]
}
}
},
{
"id": "dc",
"type": "dual_check",
"position": {
"x": 840,
"y": 40
},
"data": {
"label": "复核周报口径",
"config": {}
}
},
{
"id": "done",
"type": "output",
"position": {
"x": 1120,
"y": 40
},
"data": {
"label": "成果",
"config": {
"fields": [
{
"name": "结果",
"value": "{{ last_message }}"
}
]
}
}
}
],
"edges": [
{
"id": "e1",
"source": "start",
"target": "fetch"
},
{
"id": "e2",
"source": "fetch",
"target": "card"
},
{
"id": "e3",
"source": "fetch",
"target": "card2"
},
{
"id": "e4",
"source": "card",
"target": "dc"
},
{
"id": "e5",
"source": "dc",
"target": "done"
}
]
},
"providers": [
{
"id": "prov-a",
"name": "云端接入",
"kind": "anthropic",
"base_url": null,
"default_model": "model-a",
"models": [
{
"id": "model-a"
},
{
"id": "model-b"
}
],
"enabled": true,
"extra": {}
},
{
"id": "prov-b",
"name": "备用接入",
"kind": "openai",
"base_url": null,
"default_model": "model-c",
"models": [
{
"id": "model-c"
}
],
"enabled": false,
"extra": {}
}
],
"verify": {
"provider": null,
"model": null,
"dual_check": {
"enabled": true,
"tolerance": 1e-06,
"max_queries": 6,
"timeout_s": 120
},
"challenge": {
"enabled": true,
"max_claims": 5,
"max_queries": 4,
"timeout_s": 90
},
"inquiry": {
"enabled": true,
"max_queries": 6,
"timeout_s": 120
},
"daily_max_usd": 2.0
}
}
+41 -2
View File
@@ -1,6 +1,12 @@
import { useState } from 'react'
import type { JSX } from 'react'
import { ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval } from '../../types'
import { Inspector } from '../../canvas/Inspector'
import { NodeCard } from '../../canvas/NodeCard'
import { toFlow, useStudio } from '../../store/studio'
import { useCatalog } from '../../store/catalog'
import type { Approval, GraphSpec, Provider } from '../../types'
import data from './dual_check.json'
/**
@@ -8,8 +14,10 @@ import data from './dual_check.json'
* - ?syn=dual-check-approval:正式运行里两路对不上的审批卡,宽栏(两条 SQL 左右并排)
* - ?syn=dual-check-approval-rail:同一张卡摆在 360px 的画布右栏里(两条 SQL 上下排)
* - ?syn=dual-check-approval-exploratory:探索运行,后果里不提出具
* - ?syn=dual-check-inspector:一张小画布(真的节点卡片)加 360px 的检查器,选中双盲复算节点;检查器改的是
* studio store 里的节点配置,卡片上的摘要跟着变
*
* 完整对照的工件(GET /api/artifacts/{id})和审批回复接口由检查脚本用 page.route 伪造,直接打开时卡上照实说
* 完整对照的工件(GET /api/artifacts/{id})、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说
* 无法加载完整对照。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流
*/
const approval = (patch: Partial<Approval> = {}): Approval => ({
@@ -26,6 +34,36 @@ function Card({ value }: { value: Approval }) {
)
}
const nodeTypes = { card: NodeCard }
/**
* 检查器用例:第一次渲染前把夹具里的图放进 studio store、选中双盲复算节点,模型接入直接写进目录 store(不走接口)。
* 用例都在一个预览页里预先加载,store 只能在这里(用到时)改,不能在模块顶层改
*/
function InspectorCase() {
useState(() => {
const { nodes, edges } = toFlow(data.graph as unknown as GraphSpec)
useStudio.setState({ nodes, edges, selectedId: 'dc' })
useCatalog.setState({ providers: data.providers as unknown as Provider[] })
return true
})
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
return (
<div className="flex h-full min-h-0 max-w-full" data-fixture="dual-check-inspector">
<div className="min-w-0 flex-1 max-sm:hidden" data-dual-canvas="">
<ReactFlowProvider>
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView nodesDraggable={false} nodesConnectable={false}
proOptions={{ hideAttribution: true }} />
</ReactFlowProvider>
</div>
<div className="h-full w-[360px] max-w-full shrink-0 overflow-hidden border-l bg-panel" data-dual-inspector="">
<Inspector />
</div>
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'dual-check-approval': () => (
<div className="mx-auto max-w-3xl p-4 text-sm" data-fixture="dual-check-approval"><Card value={approval()} /></div>
@@ -40,4 +78,5 @@ export const cases: Record<string, () => JSX.Element> = {
<Card value={approval({ run_class: 'exploratory' })} />
</div>
),
'dual-check-inspector': () => <InspectorCase />,
}
+8 -2
View File
@@ -3385,10 +3385,14 @@ export const DUAL_CHECK_TEXT = {
metricsNone: '这张口径卡还没有定义指标',
metricsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时按引用的版本确定:将复算其中输入来自查询的全部指标',
metricsStale: (ids: string[]) => `已选的指标不在这张口径卡中:${ids.join('、')}`,
metricsClear: '清除已选',
metricsClear: '清除不在卡中的指标',
metricsLast: '至少保留一个指标;要复算全部指标时全部勾选即可',
metricOption: (name: string | null | undefined, id: string) => (name && name !== id ? `${name}(${id})` : id),
definitionPlaceholder: (name: string) => `「${name}」的口径说明`,
definitionsNone: '选择要复算的指标后,可以为每个指标写一段口径说明',
definitionsNone: '选择要复算的口径卡后,可以为每个指标写一段口径说明',
definitionsPinned: '这张口径卡引用了其他工作流发布的口径,指标在运行时才确定,无法在这里逐个填写口径说明',
definitionGone: (id: string) => `指标「${id}」已不在复算范围内,这段说明不会交给盲算模型`,
definitionRemove: '删除这段说明',
providerFollow: '跟随设置',
providerFromModel: '按模型名找接入',
providerGone: (name: string) => `${name}(不存在)`,
@@ -3408,6 +3412,7 @@ export const DUAL_CHECK_TEXT = {
tolerancePlaceholder: (v: number | null | undefined) => (v == null ? '跟随设置' : `跟随设置(${v})`),
toleranceInvalid: '请填写不小于 0 的数',
onReject: { degrade: '记为出具缺口,出具降档(默认)', fail: '判为失败,运行终止' } as Record<string, string>,
onRejectUnknown: (v: string) => `${v}:无法识别的值,请从上方选项中选择`,
enabled: { follow: '跟随设置', on: '开启', off: '关闭' },
/** 「跟随设置」那一项附上设置里眼下是开是关(设置还没取回来就不附) */
enabledFollow: (on: boolean | null | undefined) => (on == null ? '跟随设置' : `跟随设置(当前${on ? '开启' : '关闭'})`),
@@ -3417,6 +3422,7 @@ export const DUAL_CHECK_TEXT = {
card: (name: string, picked: number | null, total: number | null) => `复算「${name}」 · ${
picked != null ? `${picked} 个指标` : total != null ? `全部 ${total} 个指标` : '全部指标'}`,
noCard: '复算上游的口径卡 · 尚未连接口径卡',
many: (n: number) => `复算上游的口径卡 · 上游有 ${n} 张,请指定`,
off: '已关闭',
},