diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 87708f18..9d0bc736 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -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') { diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 60f46f14..ac27d754 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -98,7 +98,7 @@ function summarize(type: NodeType, config: Record): string { case 'dual_check': return DUAL_CHECK_TEXT.pending case 'challenge': - return CHALLENGE_TEXT.pending + return CHALLENGE_TEXT.summary(config) default: return '' } diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index dc6e7a54..0d155d1d 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -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 { nodePickField, triStateField, upstreamModel, verifyModelFields } from './verifyFields' 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 + invalid: boolean + describedBy?: string + disabled?: boolean + onChange: (v: any) => void } /** @@ -669,9 +688,41 @@ export const NODE_DEFS: Record = { }, challenge: { type: 'challenge', label: NODE_TYPE_LABEL.challenge, category: '把关', icon: ShieldAlert, - description: `${CHALLENGE_TEXT.description}。${CHALLENGE_TEXT.pending}`, + description: CHALLENGE_TEXT.description, hasTarget: true, sources: [{ id: 'out', label: '' }], - fields: [], + // 上限、模型、开关都可以不写:服务端按「设置 → 核查」里反方审稿那一组取(verify_settings)。没写的项不往配置里落默认值, + // 设置改了,没单独配过的节点跟着变 + fields: [ + nodePickField({ key: 'report', label: CHALLENGE_TEXT.field.report, nodeType: 'report', text: CHALLENGE_TEXT.reportPick }), + { + key: 'max_claims', label: CHALLENGE_TEXT.field.max_claims, type: 'number', min: 1, + placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.max_claims, + }, + // 和撰写报告的模型相同时提醒:同一个模型审自己写的结论,较难发现错误 + ...verifyModelFields({ + labels: { provider: CHALLENGE_TEXT.field.provider, model: CHALLENGE_TEXT.field.model }, + text: CHALLENGE_TEXT.model, subject: upstreamModel('report', 'report'), + }), + { + // 没写等于 degrade:降档放第一项,没写时下拉框正好落在它上面 + key: 'on_refuted', label: CHALLENGE_TEXT.field.on_refuted, type: 'select', + options: [ + { value: 'degrade', label: CHALLENGE_TEXT.onRefuted.degrade }, + { value: 'flag', label: CHALLENGE_TEXT.onRefuted.flag }, + { value: 'fail', label: CHALLENGE_TEXT.onRefuted.fail }, + ], + help: CHALLENGE_TEXT.help.on_refuted, unknownLabel: CHALLENGE_TEXT.unknownValue, + }, + triStateField({ key: 'enabled', label: CHALLENGE_TEXT.field.enabled, text: CHALLENGE_TEXT.enabled }), + { + key: 'max_queries', label: CHALLENGE_TEXT.field.max_queries, type: 'number', min: 1, advanced: true, + placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.max_queries, + }, + { + key: 'timeout_s', label: CHALLENGE_TEXT.field.timeout_s, type: 'number', min: 1, advanced: true, + placeholder: CHALLENGE_TEXT.followSettings, help: CHALLENGE_TEXT.help.timeout_s, + }, + ], defaults: {}, }, } diff --git a/frontend/src/canvas/verifyFields.tsx b/frontend/src/canvas/verifyFields.tsx new file mode 100644 index 00000000..4f437bda --- /dev/null +++ b/frontend/src/canvas/verifyFields.tsx @@ -0,0 +1,276 @@ +import { useMemo } from 'react' +import type { Edge } from '@xyflow/react' +import clsx from 'clsx' +import { useRadioGroup } from '../components/ui' +import { modelOptions, providerOfModel, useCatalog } from '../store/catalog' +import { useStudio, type FlowNode } from '../store/studio' +import type { NodeType } from '../types' +import type { FieldDef, FieldRenderProps } from './nodeDefs' + +/** + * 核查类节点(反方审稿、双盲复算)检查器里的几样通用控件,nodeDefs 用下面的工厂函数拼进字段表: + * - nodePickField:从画布上挑本节点上游的某一类节点(要审的报告、要复算的口径卡),留空按缺省取上游的那一个; + * - triStateField:跟随设置 / 开启 / 关闭三态(配置里没有这一项、true、false); + * - verifyModelFields:核查模型的接入加模型,写法同结论句裁判的模型选择。 + * + * 文案一律由调用方传进来(各节点自己那一块的 *_TEXT),控件里不写死任何一句话:两个节点说法不同(反方模型 / 盲算模型), + * 控件是同一个。读画布节点用 studio store,不往 FieldRenderProps 里加字段。 + */ + +/** 节点在下拉里的叫法:名字和 id 不同时把 id 带上(同 NodeRefsInput) */ +function nodeLabel(n: FlowNode): string { + const label = String(n.data?.label || n.id) + return label === n.id ? label : `${label}(${n.id})` +} + +/** 本节点的全部上游(沿连线往回找)。只认上游:下游的节点运行到这里时还没有产出 */ +function upstreamOf(nodeId: string, edges: Edge[]): Set { + const parents = new Map() + for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source]) + const seen = new Set() + const stack = [...(parents.get(nodeId) ?? [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id) || id === nodeId) continue + seen.add(id) + stack.push(...(parents.get(id) ?? [])) + } + return seen +} + +/** + * 这个节点实际要核查的是哪一个:配置里写了就是它(还在画布上的话),没写时上游恰好有一个就是那一个; + * 上游有好几个、一个都没有时说不准,返回 undefined + */ +export function targetNode(nodeId: string, chosen: unknown, type: NodeType, nodes: FlowNode[], edges: Edge[]): FlowNode | undefined { + if (typeof chosen === 'string' && chosen) return nodes.find((n) => n.id === chosen) + const up = upstreamOf(nodeId, edges) + const candidates = nodes.filter((n) => up.has(n.id) && n.data?.nodeType === type) + return candidates.length === 1 ? candidates[0] : undefined +} + +// ---------------------------------------------------------------- 选画布上的节点 + +export interface NodePickText { + /** 留空那一项:上游恰好一个时给出它的名字 */ + auto: (only: string | null) => string + /** 上游一个都没有 */ + none: string + /** 上游不止一个、又没有选 */ + many: (n: number) => string + /** 选中的节点已不在画布上(下拉里那一项的叫法) */ + missing: (id: string) => string + /** 选中的节点不在上游(下拉里那一项的叫法) */ + notUpstream: (label: string) => string + missingHint: string + notUpstreamHint: string +} + +function NodePick({ p, nodeType, text }: { p: FieldRenderProps; nodeType: NodeType; text: NodePickText }) { + const nodes = useStudio((s) => s.nodes) + const edges = useStudio((s) => s.edges) + const options = useMemo(() => { + const up = upstreamOf(p.nodeId, edges) + return nodes.filter((n) => up.has(n.id) && n.data?.nodeType === nodeType) + }, [nodes, edges, p.nodeId, nodeType]) + const value = typeof p.value === 'string' ? p.value : '' + const chosen = value ? nodes.find((n) => n.id === value) : undefined + // 选中的节点被删了、挪到了下游:下拉里照实多列一项(选不回去),不让它看着像「未选择」 + const stale = value && !options.some((n) => n.id === value) ? (chosen ? 'notUpstream' : 'missing') : null + const hint = stale === 'missing' ? text.missingHint + : stale === 'notUpstream' ? text.notUpstreamHint + : !options.length ? text.none + : !value && options.length > 1 ? text.many(options.length) : '' + const hintId = `${p.id}-hint` + const only = options.length === 1 ? String(options[0].data?.label || options[0].id) : null + return ( + <> + + {hint && ( +
+ {hint} +
+ )} + + ) +} + +/** + * 从画布上挑本节点上游的某一类节点。配置里存节点 id;留空(写回 undefined,不留空字符串)时服务端按缺省取上游的那一个 + */ +export function nodePickField(def: { + key: string; label: string; nodeType: NodeType; text: NodePickText; help?: string; advanced?: boolean +}): FieldDef { + return { + key: def.key, label: def.label, type: 'select', help: def.help, advanced: def.advanced, + render: (p) => , + } +} + +// ---------------------------------------------------------------- 三态开关 + +export interface TriStateText { + follow: string + on: string + off: string + /** 选中各态时下面那句说明:跟随的是哪一项设置、关掉之后会怎样 */ + hint: { follow: string; on: string; off: string } +} + +type Tri = 'follow' | 'on' | 'off' +const TRI: readonly Tri[] = ['follow', 'on', 'off'] + +function TriState({ p, label, text }: { p: FieldRenderProps; label: string; text: TriStateText }) { + // 只认真正的布尔:手写的 "false"、0 不当成关闭,按跟随设置显示(服务端校验会报出来) + const state: Tri = p.value === true ? 'on' : p.value === false ? 'off' : 'follow' + // 跟随设置写回 undefined:配置里不留这一项,和没配过的节点一样 + const set = (t: Tri) => p.onChange(t === 'on' ? true : t === 'off' ? false : undefined) + const radio = useRadioGroup(TRI, state, set) + const hintId = `${p.id}-hint` + return ( + <> + {/* 字段标签的 for 指不到一组按钮,组名另写在 aria-label 上 */} +
+ {TRI.map((t) => ( + + ))} +
+
{text.hint[state]}
+ + ) +} + +/** 跟随设置 / 开启 / 关闭:配置里没有这一项(或 null)/ true / false */ +export function triStateField(def: { key: string; label: string; text: TriStateText; advanced?: boolean }): FieldDef { + return { + key: def.key, label: def.label, type: 'select', advanced: def.advanced, + render: (p) => , + } +} + +// ---------------------------------------------------------------- 核查模型 + +export interface VerifyModelText { + /** 接入、模型都留空 */ + providerFollow: string + /** 接入留空、模型填了:服务端按模型名找接入,不再跟随设置 */ + providerFromModel: string + providerMissing: (name: string) => string + /** 接在已停用的接入名后面 */ + providerDisabled: string + /** 模型框的占位:接入也没选 / 选了接入 */ + modelFollow: string + modelDefault: string + /** 取值链的说明,常显在模型框下面 */ + help: string + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => string + /** 和被核查的模型是同一个 */ + same: (model: string) => string +} + +/** 被核查的那个模型:节点上明确写着的模型名;说不准(没写、跟随默认)时为 null,不提醒 */ +export type SubjectModel = (nodeId: string, config: Record, graph: { nodes: FlowNode[]; edges: Edge[] }) => string | null + +function ProviderPick({ p, text }: { p: FieldRenderProps; text: VerifyModelText }) { + const providers = useCatalog((s) => s.providers) + const provider = typeof p.value === 'string' ? p.value : '' + const model = typeof p.config.model === 'string' ? p.config.model.trim() : '' + const known = provider ? providers.find((x) => x.name === provider) : undefined + return ( + + ) +} + +function ModelInput({ p, text, subject }: { p: FieldRenderProps; text: VerifyModelText; subject?: SubjectModel }) { + const providers = useCatalog((s) => s.providers) + // 选择器只返回一个字符串:画布上别的节点改动不让这一栏重渲染 + const against = useStudio((s) => (subject ? subject(p.nodeId, p.config, s) : null)) + const provider = typeof p.config.provider === 'string' ? p.config.provider : '' + const model = typeof p.value === 'string' ? p.value : '' + const name = model.trim() + const pv = provider ? providers.find((x) => x.name === provider) : undefined + const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))] + // 只填了模型:这一级写了就整组用节点上的(verify_model_spec 不跨级拼),服务端按模型名找接入 + const byModel = !provider && !!name + const owner = byModel ? providerOfModel(providers, name) : undefined + const enabled = providers.filter((x) => x.enabled) + const fallback = enabled.find((x) => x.kind !== 'mock') ?? enabled[0] + const byModelHint = byModel && providers.length > 0 + ? text.byModel(name, owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : '' + const same = !!name && !!against && name === against + return ( + <> + p.onChange(e.target.value.trim() ? e.target.value : undefined)} /> + {models.map((m) => + {byModelHint && ( +
+ {byModelHint} +
+ )} + {same && ( +
+ {text.same(name)} +
+ )} +
{text.help}
+ + ) +} + +/** + * 核查模型:接入、模型两个字段(配置里的 provider、model)。都留空时用设置里的核查模型;写了任一项就整组以节点为准。 + * subject 给出被核查的那个模型,相同时提醒(等于自己审自己) + */ +export function verifyModelFields(def: { + labels: { provider: string; model: string }; text: VerifyModelText; subject?: SubjectModel; advanced?: boolean +}): FieldDef[] { + return [ + { + key: 'provider', label: def.labels.provider, type: 'select', advanced: def.advanced, + render: (p) => , + }, + { + key: 'model', label: def.labels.model, type: 'model', advanced: def.advanced, + render: (p) => , + }, + ] +} + +/** + * 被核查节点上写着的模型:refKey 是本节点配置里指向它的那一项(反方审稿的 report),没写时取上游唯一的那一个 nodeType + */ +export function upstreamModel(refKey: string, nodeType: NodeType): SubjectModel { + return (nodeId, config, { nodes, edges }) => { + const model = targetNode(nodeId, config[refKey], nodeType, nodes, edges)?.data?.config?.model + return typeof model === 'string' && model.trim() ? model.trim() : null + } +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 6dceec1f..51601995 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3340,10 +3340,104 @@ export const DUAL_CHECK_TEXT = { pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`, } -/** 反方审稿节点:节点库说明、检查器和运行结果 */ +/** + * 反方审稿节点:节点库说明、检查器、节点卡片摘要,以及审批卡上的反方审稿(run/ChallengeSummary.tsx)。 + * 四种判断全站只有这一种叫法:经受住、被削弱、被推翻、无法判断。判断由反方模型给出,不是系统核对。 + * 「出具会降档」只在正式运行、按降档处理时才说;探索运行不出具,不说任何和出具有关的话 + */ export const CHALLENGE_TEXT = { - description: '从反方立场审阅上游报告,逐条提出质疑', - pending: `${PENDING_TEXT}:本节点暂未生效,运行时将跳过`, + description: '从反方立场复核上游报告的关键结论:另一个模型只读查库,逐句判断经受住、被削弱、被推翻还是无法判断', + verdict: { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }, + /** 服务端给了一个界面不认识的判断:照写原值,不归到哪一类 */ + verdictUnknown: (v: string) => `未知判断「${v}」`, + + // ---- 节点卡片的摘要。report 是节点 id:卡片摘要只看配置 ---- + summary: (c: { report?: unknown; max_claims?: unknown; enabled?: unknown }) => [ + c.enabled === false ? '已关闭' : '', + typeof c.report === 'string' && c.report.trim() ? `审报告节点 ${c.report.trim()}` : '审上游报告', + typeof c.max_claims === 'number' && c.max_claims > 0 ? `最多审 ${formatNumber(c.max_claims)} 句结论` : '句数跟随设置', + ].filter(Boolean).join(' · '), + + // ---- 检查器 ---- + field: { + report: '要审的报告', + max_claims: '最多审几句', + max_queries: '每句最多查询次数', + timeout_s: '每句时长上限(秒)', + provider: '反方模型 · 接入', + model: '反方模型', + on_refuted: '结论被推翻时', + enabled: '启用', + }, + followSettings: '跟随设置', + help: { + max_claims: '每份报告最多挑几句关键结论来审。留空则使用「设置 → 核查」中的值', + max_queries: '复核每句结论时最多执行几次查询。留空则使用「设置 → 核查」中的值', + timeout_s: '复核每句结论的时长上限,到时按已查到的结果给出判断。留空则使用「设置 → 核查」中的值', + on_refuted: '降档:被推翻的结论计入出具缺口,正式运行降档出具;只标注:在审批卡和运行记录中标出,不影响出具;' + + '报错:本节点失败,运行停止', + }, + onRefuted: { degrade: '出具降档(默认)', flag: '只标注', fail: '报错并停止运行' }, + unknownValue: (v: string) => `${v}:无法识别的值,请从上方选项中选择`, + reportPick: { + auto: (only: string | null) => (only ? `未选择:审上游的「${only}」` : '未选择:审上游的报告撰写节点'), + none: '上游没有报告撰写节点,请先在本节点之前连接一个报告撰写节点', + many: (n: number) => `上游有 ${formatNumber(n)} 个报告撰写节点,请选择要审的那一个`, + missing: (id: string) => `${id}(已不在画布上)`, + notUpstream: (label: string) => `${label}(不在上游)`, + missingHint: '所选节点已不在画布上,请重新选择', + notUpstreamHint: '所选节点不在本节点上游,运行到这里时它还没有写出报告,请重新选择或调整连线', + }, + enabled: { + follow: '跟随设置', on: '开启', off: '关闭', + hint: { + follow: '使用「设置 → 核查」中「反方审稿 · 启用」的值', + on: '无论设置如何都执行反方审稿', + off: '不执行反方审稿,上游的结果原样交给下游,运行记录中记一条说明', + }, + }, + model: { + providerFollow: '跟随设置', + providerFromModel: '按模型名找接入', + providerMissing: (name: string) => `${name}(不存在)`, + providerDisabled: '(已停用)', + modelFollow: '跟随设置', + modelDefault: '留空则使用该接入的默认模型', + help: '接入和模型均未填写时,使用「设置 → 核查」中的核查模型;填写任一项后,整组以节点配置为准,不与设置合并。' + + '建议反方模型与撰写报告的模型不同,同一模型较难发现自身的错误', + byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => ( + found && !found.enabled + ? `只填了模型:「${model}」属于已停用的接入「${found.name}」,调用将失败。请启用该接入或选择其他接入` + : found + ? `只填了模型:将按模型名使用接入「${found.name}」。接入和模型都未填写时,才跟随设置中的核查模型` + : `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用;` + + '如该模型不属于默认接入,请选择对应的接入'), + same: (model: string) => `反方模型与撰写报告的模型相同(「${model}」),审稿效果有限`, + }, + + // ---- 审批卡 ---- + head: (label: string) => `报告「${label}」的反方审稿`, + count: (verdict: string, n: number) => `${verdict} ${formatNumber(n)} 句`, + none: '报告中没有需要反方复核的关键结论', + degrade: '出具会降档', + degradeHint: '被推翻的结论计入出具缺口,本次正式运行将降档出具', + flagNote: '按节点设置,被推翻的结论只做标注,不影响出具档位', + more: (n: number) => `另有 ${formatNumber(n)} 句未列出`, + showAll: '显示全部', + loading: '正在加载反方审稿…', + failed: '无法获取反方审稿的详细内容', + retry: '重试', + claimMissing: '反方审稿记录中没有这一句的详细内容', + judgedBy: (model: string | null) => `判断由反方模型${model ? `「${model}」` : ''}给出,并非系统核对`, + rationale: '理由', + rationaleEmpty: '反方没有给出理由', + docLoading: '正在加载理由…', + queries: (n: number) => `反方执行的查询(${formatNumber(n)} 次)`, + noQueries: '反方没有执行查询', + rows: (n: number) => `${formatNumber(n)} 行`, + truncated: '结果已截断,数据库中还有更多数据', + /** 理由里有出处的数字:读屏听到的是「数字,出处:查询 Q2 · 第 1 行 · amount」,点一下跳到那次查询 */ + cited: (text: string, source: string) => `${text},出处:${source},跳到这次查询`, } /** 设置页里复核套件(双盲复算、反方审稿等)的默认值一栏 */