mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 03:16:10 +08:00
Enable flow-aware decision routing
This commit is contained in:
@@ -17,9 +17,10 @@ interface PTTRequest {
|
||||
context: {
|
||||
state_id: string;
|
||||
state: any;
|
||||
candidates: Array<{ id: string; state: any }>;
|
||||
candidates: Array<{ id: string; state: any; flow?: string }>;
|
||||
variables: Record<string, any>;
|
||||
flags: Record<string, any>;
|
||||
flow_slug?: string;
|
||||
};
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
@@ -35,6 +36,8 @@ interface PTTResponse {
|
||||
controller_say_tpl?: string;
|
||||
off_schema?: boolean;
|
||||
radio_check?: boolean;
|
||||
activate_flow?: string;
|
||||
resume_previous?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
6
server/api/decision-flows/runtime.get.ts
Normal file
6
server/api/decision-flows/runtime.get.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const system = await buildRuntimeDecisionSystem()
|
||||
return system
|
||||
})
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeNodeCondition,
|
||||
sanitizeNodeTrigger,
|
||||
sanitizeTransition,
|
||||
} from '../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../services/decisionFlowService'
|
||||
import type {
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
@@ -47,9 +54,44 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
|
||||
}
|
||||
|
||||
const transitions = Array.isArray(body.transitions)
|
||||
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
: []
|
||||
let transitions: DecisionNodeTransition[] = []
|
||||
try {
|
||||
transitions = Array.isArray(body.transitions)
|
||||
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Transition ungültig',
|
||||
data: { formError: error?.message || 'Transition ungültig', field: 'transitions' },
|
||||
})
|
||||
}
|
||||
|
||||
let triggers: DecisionNodeTrigger[] = []
|
||||
try {
|
||||
triggers = Array.isArray(body.triggers)
|
||||
? body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Trigger ungültig',
|
||||
data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' },
|
||||
})
|
||||
}
|
||||
|
||||
let conditions: DecisionNodeCondition[] = []
|
||||
try {
|
||||
conditions = Array.isArray(body.conditions)
|
||||
? body.conditions.map((condition: any, index: number) => sanitizeNodeCondition(condition, index))
|
||||
: []
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Bedingung ungültig',
|
||||
data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' },
|
||||
})
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 }
|
||||
const metadata = sanitizeMetadata(body.metadata)
|
||||
@@ -89,6 +131,8 @@ export default defineEventHandler(async (event) => {
|
||||
frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined,
|
||||
frequencyName:
|
||||
typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined,
|
||||
triggers,
|
||||
conditions,
|
||||
transitions,
|
||||
layout,
|
||||
metadata,
|
||||
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeNodeCondition,
|
||||
sanitizeNodeTrigger,
|
||||
sanitizeTransition,
|
||||
} from '../../../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
|
||||
import type {
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
@@ -113,7 +120,49 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
if (Array.isArray(body.transitions)) {
|
||||
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
let sanitizedTransitions: DecisionNodeTransition[]
|
||||
try {
|
||||
sanitizedTransitions = body.transitions.map((transition: any, index: number) =>
|
||||
sanitizeTransition(transition, index)
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Transition ungültig',
|
||||
data: { formError: error?.message || 'Transition ungültig', field: 'transitions' },
|
||||
})
|
||||
}
|
||||
node.transitions = sanitizedTransitions
|
||||
}
|
||||
|
||||
if (Array.isArray(body.triggers)) {
|
||||
let sanitizedTriggers: DecisionNodeTrigger[]
|
||||
try {
|
||||
sanitizedTriggers = body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index))
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Trigger ungültig',
|
||||
data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' },
|
||||
})
|
||||
}
|
||||
node.triggers = sanitizedTriggers
|
||||
}
|
||||
|
||||
if (Array.isArray(body.conditions)) {
|
||||
let sanitizedConditions: DecisionNodeCondition[]
|
||||
try {
|
||||
sanitizedConditions = body.conditions.map((condition: any, index: number) =>
|
||||
sanitizeNodeCondition(condition, index)
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error?.message || 'Bedingung ungültig',
|
||||
data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' },
|
||||
})
|
||||
}
|
||||
node.conditions = sanitizedConditions
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(body.layout)
|
||||
|
||||
@@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (url.pathname.startsWith('/api/service/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import mongoose from 'mongoose'
|
||||
import type {
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
@@ -88,6 +90,37 @@ const autoTriggerSchema = new mongoose.Schema<DecisionNodeAutoTrigger>(
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const triggerSchema = new mongoose.Schema<DecisionNodeTrigger>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
type: { type: String, enum: ['auto_time', 'auto_variable', 'regex', 'none'], required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
delaySeconds: { type: Number },
|
||||
variable: { type: String },
|
||||
operator: { type: String },
|
||||
value: { type: mongoose.Schema.Types.Mixed },
|
||||
pattern: { type: String },
|
||||
patternFlags: { type: String },
|
||||
description: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const conditionSchema = new mongoose.Schema<DecisionNodeCondition>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
type: { type: String, enum: ['variable_value', 'regex', 'regex_not'], required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
variable: { type: String },
|
||||
operator: { type: String },
|
||||
value: { type: mongoose.Schema.Types.Mixed },
|
||||
pattern: { type: String },
|
||||
patternFlags: { type: String },
|
||||
description: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const transitionSchema = new mongoose.Schema<DecisionNodeTransition>(
|
||||
{
|
||||
key: { type: String, required: true },
|
||||
@@ -158,6 +191,8 @@ const decisionNodeSchema = new mongoose.Schema<DecisionNodeDocument>(
|
||||
trigger: { type: String },
|
||||
frequency: { type: String },
|
||||
frequencyName: { type: String },
|
||||
triggers: { type: [triggerSchema], default: undefined },
|
||||
conditions: { type: [conditionSchema], default: undefined },
|
||||
transitions: { type: [transitionSchema], default: () => [] },
|
||||
layout: { type: layoutSchema, default: undefined },
|
||||
metadata: { type: metadataSchema, default: undefined },
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
RuntimeDecisionAutoTransition,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionTree,
|
||||
RuntimeDecisionSystem,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
|
||||
@@ -53,6 +54,8 @@ export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeMo
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
triggers: Array.isArray(obj.triggers) ? obj.triggers : [],
|
||||
conditions: Array.isArray(obj.conditions) ? obj.conditions : [],
|
||||
transitions: Array.isArray(obj.transitions) ? obj.transitions : [],
|
||||
layout: obj.layout || undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
@@ -170,25 +173,26 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
auto_transitions: toRuntimeAutoTransitions(transitions),
|
||||
triggers: Array.isArray(obj.triggers) ? obj.triggers : undefined,
|
||||
conditions: Array.isArray(obj.conditions) ? obj.conditions : undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const nodes = await DecisionNode.find({ flow: flowDoc._id })
|
||||
async function buildRuntimeTreeForDoc(
|
||||
flowDoc: DecisionFlowDocument,
|
||||
nodeDocs?: DecisionNodeDocument[]
|
||||
): Promise<RuntimeDecisionTree> {
|
||||
const nodes = nodeDocs ?? (await DecisionNode.find({ flow: flowDoc._id }))
|
||||
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
|
||||
acc[node.stateId] = serializeRuntimeState(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
slug: flowDoc.slug,
|
||||
schema_version: flowDoc.schemaVersion || '1.0',
|
||||
name: flowDoc.slug,
|
||||
name: flowDoc.name || flowDoc.slug,
|
||||
description: flowDoc.description || undefined,
|
||||
start_state: flowDoc.startState,
|
||||
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
|
||||
@@ -201,3 +205,51 @@ export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDec
|
||||
states,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
return buildRuntimeTreeForDoc(flowDoc)
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionSystem(): Promise<RuntimeDecisionSystem> {
|
||||
const flowDocs = await DecisionFlow.find().sort({ updatedAt: -1 })
|
||||
if (!flowDocs.length) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'No decision flows available' })
|
||||
}
|
||||
|
||||
const flowIds = flowDocs.map((doc) => doc._id)
|
||||
|
||||
const nodeDocs = await DecisionNode.find({ flow: { $in: flowIds } })
|
||||
const groupedNodes = nodeDocs.reduce<Record<string, DecisionNodeDocument[]>>((acc, node) => {
|
||||
const key = String(node.flow)
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const runtimeTrees: RuntimeDecisionTree[] = []
|
||||
for (const doc of flowDocs) {
|
||||
const nodes = groupedNodes[String(doc._id)] || []
|
||||
runtimeTrees.push(await buildRuntimeTreeForDoc(doc, nodes))
|
||||
}
|
||||
|
||||
const flows = runtimeTrees.reduce<Record<string, RuntimeDecisionTree>>((acc, tree) => {
|
||||
acc[tree.slug] = tree
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const order = runtimeTrees.map((tree) => tree.slug)
|
||||
const main = flows['icao_atc_decision_tree'] ? 'icao_atc_decision_tree' : order[0]
|
||||
|
||||
return {
|
||||
main,
|
||||
order,
|
||||
flows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +1,21 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import atcDecisionTree from '~~/shared/data/atcDecisionTree'
|
||||
import { DecisionFlow } from '../models/DecisionFlow'
|
||||
import { DecisionNode } from '../models/DecisionNode'
|
||||
import type { DecisionNodeTransition } from '~~/shared/types/decision'
|
||||
import { getFlowWithNodes } from './decisionFlowService'
|
||||
|
||||
interface LegacyTransition {
|
||||
to?: string
|
||||
when?: string
|
||||
label?: string
|
||||
condition?: string
|
||||
guard?: string
|
||||
after_s?: number
|
||||
allowManualProceed?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ImportDecisionTreeOptions {
|
||||
slug?: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
pilot: '#0ea5e9',
|
||||
atc: '#22d3ee',
|
||||
system: '#f59e0b',
|
||||
}
|
||||
|
||||
function createTransition(
|
||||
type: DecisionNodeTransition['type'],
|
||||
data: LegacyTransition,
|
||||
order: number
|
||||
): DecisionNodeTransition | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const target = typeof data.to === 'string' ? data.to.trim() : ''
|
||||
if (!target) return null
|
||||
|
||||
const transition: DecisionNodeTransition = {
|
||||
key: `${type}_${randomUUID().slice(0, 8)}`,
|
||||
type,
|
||||
target,
|
||||
order,
|
||||
}
|
||||
|
||||
const label = typeof data.label === 'string' ? data.label.trim() : undefined
|
||||
if (label) transition.label = label
|
||||
|
||||
const condition =
|
||||
typeof data.when === 'string'
|
||||
? data.when.trim()
|
||||
: typeof data.condition === 'string'
|
||||
? data.condition.trim()
|
||||
: undefined
|
||||
if (condition) transition.condition = condition
|
||||
|
||||
const guard = typeof data.guard === 'string' ? data.guard.trim() : undefined
|
||||
if (guard) transition.guard = guard
|
||||
|
||||
const description = typeof data.description === 'string' ? data.description.trim() : undefined
|
||||
if (description) transition.description = description
|
||||
|
||||
if (type === 'timer') {
|
||||
const after = typeof data.after_s === 'number' ? data.after_s : Number(data.after_s)
|
||||
if (Number.isFinite(after)) {
|
||||
transition.timer = {
|
||||
afterSeconds: Number(after),
|
||||
allowManualProceed: data.allowManualProceed !== false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transition
|
||||
}
|
||||
|
||||
export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) {
|
||||
const slug = typeof options.slug === 'string' && options.slug.trim().length
|
||||
? options.slug.trim()
|
||||
: atcDecisionTree.name || 'icao_atc_decision_tree'
|
||||
: 'icao_atc_decision_tree'
|
||||
|
||||
const name = typeof options.name === 'string' && options.name.trim().length
|
||||
? options.name.trim()
|
||||
: 'ATC Decision Tree'
|
||||
|
||||
const existingFlow = await DecisionFlow.findOne({ slug })
|
||||
const flow = existingFlow || new DecisionFlow({ slug, name })
|
||||
|
||||
flow.name = name
|
||||
flow.description = options.description ?? atcDecisionTree.description ?? flow.description
|
||||
flow.schemaVersion = atcDecisionTree.schema_version || '1.0'
|
||||
flow.startState = atcDecisionTree.start_state
|
||||
flow.endStates = Array.isArray(atcDecisionTree.end_states) ? atcDecisionTree.end_states : []
|
||||
flow.variables = atcDecisionTree.variables || {}
|
||||
flow.flags = atcDecisionTree.flags || {}
|
||||
flow.policies = atcDecisionTree.policies || {}
|
||||
flow.hooks = atcDecisionTree.hooks || {}
|
||||
flow.roles = Array.isArray(atcDecisionTree.roles) ? atcDecisionTree.roles : flow.roles
|
||||
flow.phases = Array.isArray(atcDecisionTree.phases) ? atcDecisionTree.phases : flow.phases
|
||||
flow.layout = flow.layout || { zoom: 0.9, pan: { x: 0, y: 0 }, groups: [] }
|
||||
|
||||
await flow.save()
|
||||
|
||||
await DecisionNode.deleteMany({ flow: flow._id })
|
||||
|
||||
const phases = Array.isArray(flow.phases) && flow.phases.length ? flow.phases : ['General']
|
||||
const phaseColumns = new Map<string, number>()
|
||||
phases.forEach((phase, index) => phaseColumns.set(phase, index))
|
||||
const phaseRowCounters = new Map<string, number>()
|
||||
|
||||
const stateEntries = Object.entries(atcDecisionTree.states || {})
|
||||
const nodesToInsert = stateEntries.map(([stateId, state]) => {
|
||||
const role = typeof state.role === 'string' ? state.role : 'system'
|
||||
const phase = typeof state.phase === 'string' ? state.phase : 'General'
|
||||
|
||||
const columnIndex = phaseColumns.has(phase) ? phaseColumns.get(phase)! : phaseColumns.size
|
||||
if (!phaseColumns.has(phase)) {
|
||||
phaseColumns.set(phase, columnIndex)
|
||||
}
|
||||
const rowIndex = phaseRowCounters.get(phase) || 0
|
||||
phaseRowCounters.set(phase, rowIndex + 1)
|
||||
|
||||
const transitions: DecisionNodeTransition[] = []
|
||||
let order = 0
|
||||
|
||||
if (Array.isArray(state.next)) {
|
||||
for (const entry of state.next as LegacyTransition[]) {
|
||||
const transition = createTransition('next', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.ok_next)) {
|
||||
for (const entry of state.ok_next as LegacyTransition[]) {
|
||||
const transition = createTransition('ok', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.bad_next)) {
|
||||
for (const entry of state.bad_next as LegacyTransition[]) {
|
||||
const transition = createTransition('bad', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.timer_next)) {
|
||||
for (const entry of state.timer_next as LegacyTransition[]) {
|
||||
const transition = createTransition('timer', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
const readbackRequired = Array.isArray(state.readback_required)
|
||||
? state.readback_required.filter((item: any) => typeof item === 'string' && item.trim().length)
|
||||
: []
|
||||
|
||||
const layout = {
|
||||
x: columnIndex * 340,
|
||||
y: rowIndex * 220,
|
||||
color: ROLE_COLORS[role] || '#38bdf8',
|
||||
}
|
||||
|
||||
return {
|
||||
flow: flow._id,
|
||||
stateId,
|
||||
title: typeof state.title === 'string' ? state.title.trim() || undefined : undefined,
|
||||
summary: typeof state.summary === 'string' ? state.summary.trim() || undefined : undefined,
|
||||
role,
|
||||
phase,
|
||||
sayTemplate: typeof state.say_tpl === 'string' ? state.say_tpl : undefined,
|
||||
utteranceTemplate: typeof state.utterance_tpl === 'string' ? state.utterance_tpl : undefined,
|
||||
elseSayTemplate: typeof state.else_say_tpl === 'string' ? state.else_say_tpl : undefined,
|
||||
readbackRequired,
|
||||
autoBehavior: typeof state.auto === 'string' ? state.auto : undefined,
|
||||
actions: Array.isArray(state.actions) ? state.actions : [],
|
||||
handoff: state.handoff && typeof state.handoff === 'object' ? state.handoff : undefined,
|
||||
guard: typeof state.guard === 'string' ? state.guard : undefined,
|
||||
trigger: typeof state.trigger === 'string' ? state.trigger : undefined,
|
||||
frequency: typeof state.frequency === 'string' ? state.frequency : undefined,
|
||||
frequencyName: typeof state.frequencyName === 'string' ? state.frequencyName : undefined,
|
||||
transitions,
|
||||
layout,
|
||||
}
|
||||
})
|
||||
|
||||
await DecisionNode.insertMany(nodesToInsert)
|
||||
|
||||
const { flow: serializedFlow, nodes } = await getFlowWithNodes(slug)
|
||||
const { flow, nodes } = await getFlowWithNodes(slug)
|
||||
|
||||
return {
|
||||
flow: serializedFlow,
|
||||
flow,
|
||||
nodes,
|
||||
importedStates: nodes.length,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
DecisionComparisonOperator,
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeCondition,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeTrigger,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const TRANSITION_TYPES = new Set(['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'])
|
||||
const AUTO_TRIGGER_TYPES = new Set(['telemetry', 'variable', 'expression'])
|
||||
const NODE_TRIGGER_TYPES = new Set(['auto_time', 'auto_variable', 'regex', 'none'])
|
||||
const NODE_CONDITION_TYPES = new Set(['variable_value', 'regex', 'regex_not'])
|
||||
const COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!='])
|
||||
const TELEMETRY_PARAMETERS = new Set([
|
||||
'altitude_ft',
|
||||
'speed_kts',
|
||||
'groundspeed_kts',
|
||||
'vertical_speed_fpm',
|
||||
'heading_deg',
|
||||
'distance_nm',
|
||||
])
|
||||
|
||||
function asTrimmedString(input: any): string | undefined {
|
||||
if (typeof input === 'string') {
|
||||
@@ -43,6 +56,42 @@ function asBoolean(input: any, fallback: boolean): boolean {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asComparisonOperatorValue(input: any, fallback: DecisionComparisonOperator = '=='): DecisionComparisonOperator {
|
||||
const operator = asTrimmedString(input)
|
||||
if (operator && COMPARISON_OPERATORS.has(operator)) {
|
||||
return operator as DecisionComparisonOperator
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asTelemetryParameter(
|
||||
input: any,
|
||||
fallback: NonNullable<DecisionNodeAutoTrigger['parameter']> = 'altitude_ft'
|
||||
): NonNullable<DecisionNodeAutoTrigger['parameter']> {
|
||||
const parameter = asTrimmedString(input)
|
||||
if (parameter && TELEMETRY_PARAMETERS.has(parameter)) {
|
||||
return parameter as NonNullable<DecisionNodeAutoTrigger['parameter']>
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asTelemetryValue(input: any, fallback: number | string = 0): number | string {
|
||||
const numeric = asNumber(input)
|
||||
if (typeof numeric === 'number') return numeric
|
||||
const stringValue = asTrimmedString(input)
|
||||
if (stringValue !== undefined) return stringValue
|
||||
return fallback
|
||||
}
|
||||
|
||||
function asVariableValue(input: any, fallback: number | string | boolean = ''): number | string | boolean {
|
||||
const numeric = asNumber(input)
|
||||
if (typeof numeric === 'number') return numeric
|
||||
if (typeof input === 'boolean') return input
|
||||
const stringValue = asTrimmedString(input)
|
||||
if (stringValue !== undefined) return stringValue
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function sanitizeLayout(raw: any): DecisionNodeLayout | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const x = asNumber(raw.x) ?? 0
|
||||
@@ -133,70 +182,97 @@ export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefin
|
||||
}
|
||||
|
||||
export function sanitizeAutoTrigger(raw: any): DecisionNodeAutoTrigger | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const type = asTrimmedString(raw.type)
|
||||
if (!type || !AUTO_TRIGGER_TYPES.has(type)) {
|
||||
throw new Error('Invalid auto trigger type')
|
||||
}
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && AUTO_TRIGGER_TYPES.has(type) ? (type as DecisionNodeAutoTrigger['type']) : 'expression'
|
||||
|
||||
const trigger: DecisionNodeAutoTrigger = {
|
||||
id: asTrimmedString(raw.id) || `auto_${randomUUID()}`,
|
||||
type: type as DecisionNodeAutoTrigger['type'],
|
||||
id: asTrimmedString(payload.id) || `auto_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
}
|
||||
|
||||
if (type === 'expression') {
|
||||
const expression = asTrimmedString(raw.expression)
|
||||
if (!expression) {
|
||||
throw new Error('Expression trigger requires an expression')
|
||||
}
|
||||
trigger.expression = expression
|
||||
} else if (type === 'telemetry') {
|
||||
const parameter = asTrimmedString(raw.parameter)
|
||||
if (!parameter) {
|
||||
throw new Error('Telemetry trigger requires a parameter')
|
||||
}
|
||||
trigger.parameter = parameter as DecisionNodeAutoTrigger['parameter']
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Telemetry trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Telemetry trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
const unit = asTrimmedString(raw.unit)
|
||||
if (normalizedType === 'expression') {
|
||||
trigger.expression = asTrimmedString(payload.expression) ?? ''
|
||||
} else if (normalizedType === 'telemetry') {
|
||||
trigger.parameter = asTelemetryParameter(payload.parameter)
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asTelemetryValue(payload.value, 0)
|
||||
const unit = asTrimmedString(payload.unit)
|
||||
if (unit) trigger.unit = unit
|
||||
} else if (type === 'variable') {
|
||||
const variable = asTrimmedString(raw.variable)
|
||||
if (!variable) {
|
||||
throw new Error('Variable trigger requires a variable path')
|
||||
}
|
||||
trigger.variable = variable
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Variable trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Variable trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
} else if (normalizedType === 'variable') {
|
||||
trigger.variable = asTrimmedString(payload.variable) ?? ''
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asVariableValue(payload.value, '')
|
||||
}
|
||||
|
||||
if (raw.once !== undefined) {
|
||||
trigger.once = asBoolean(raw.once, true)
|
||||
}
|
||||
const delayMs = asNumber(raw.delayMs)
|
||||
trigger.once = asBoolean(payload.once, true)
|
||||
const delayMs = asNumber(payload.delayMs)
|
||||
if (typeof delayMs === 'number') trigger.delayMs = delayMs
|
||||
const description = asTrimmedString(raw.description)
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) trigger.description = description
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
export function sanitizeNodeTrigger(raw: any, index = 0): DecisionNodeTrigger {
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && NODE_TRIGGER_TYPES.has(type) ? (type as DecisionNodeTrigger['type']) : 'none'
|
||||
|
||||
const trigger: DecisionNodeTrigger = {
|
||||
id: asTrimmedString(payload.id) || `trigger_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
order: typeof payload.order === 'number' ? payload.order : index,
|
||||
}
|
||||
|
||||
if (trigger.type === 'auto_time') {
|
||||
trigger.delaySeconds = asNumber(payload.delaySeconds) ?? 0
|
||||
} else if (trigger.type === 'auto_variable') {
|
||||
trigger.variable = asTrimmedString(payload.variable) ?? ''
|
||||
trigger.operator = asComparisonOperatorValue(payload.operator)
|
||||
trigger.value = asVariableValue(payload.value, '')
|
||||
} else if (trigger.type === 'regex') {
|
||||
trigger.pattern = asTrimmedString(payload.pattern) ?? ''
|
||||
trigger.patternFlags = asTrimmedString(payload.patternFlags) ?? ''
|
||||
}
|
||||
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) trigger.description = description
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
export function sanitizeNodeCondition(raw: any, index = 0): DecisionNodeCondition {
|
||||
const payload = raw && typeof raw === 'object' ? raw : {}
|
||||
const type = asTrimmedString(payload.type)
|
||||
const normalizedType =
|
||||
type && NODE_CONDITION_TYPES.has(type)
|
||||
? (type as DecisionNodeCondition['type'])
|
||||
: 'variable_value'
|
||||
|
||||
const condition: DecisionNodeCondition = {
|
||||
id: asTrimmedString(payload.id) || `condition_${randomUUID()}`,
|
||||
type: normalizedType,
|
||||
order: typeof payload.order === 'number' ? payload.order : index,
|
||||
}
|
||||
|
||||
const description = asTrimmedString(payload.description)
|
||||
if (description) condition.description = description
|
||||
|
||||
if (condition.type === 'variable_value') {
|
||||
condition.variable = asTrimmedString(payload.variable) ?? ''
|
||||
condition.operator = asComparisonOperatorValue(payload.operator)
|
||||
condition.value = asVariableValue(payload.value, '')
|
||||
} else {
|
||||
condition.pattern = asTrimmedString(payload.pattern) ?? ''
|
||||
condition.patternFlags = asTrimmedString(payload.patternFlags) ?? ''
|
||||
}
|
||||
|
||||
return condition
|
||||
}
|
||||
|
||||
export function sanitizeTransition(raw: any, index = 0): DecisionNodeTransition {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid transition payload')
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import OpenAI from 'openai'
|
||||
import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech'
|
||||
import type {LLMDecision, LLMDecisionInput} from '../../shared/types/llm'
|
||||
import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision'
|
||||
import { buildRuntimeDecisionSystem } from '../services/decisionFlowService'
|
||||
import {getServerRuntimeConfig} from './runtimeConfig'
|
||||
|
||||
let openaiClient: OpenAI | null = null
|
||||
@@ -200,6 +202,319 @@ function fallbackNextState(input: LLMDecisionInput): string {
|
||||
return input.candidates[0]?.id || input.state_id || 'GEN_NO_REPLY'
|
||||
}
|
||||
|
||||
interface IndexedStateEntry {
|
||||
flow: string
|
||||
state: RuntimeDecisionState
|
||||
}
|
||||
|
||||
interface DecisionCandidate {
|
||||
id: string
|
||||
flow: string
|
||||
state: RuntimeDecisionState
|
||||
}
|
||||
|
||||
interface PreparedCandidateResult {
|
||||
filteredCandidates: DecisionCandidate[]
|
||||
candidateFlowMap: Map<string, string>
|
||||
activeFlowSlug: string
|
||||
}
|
||||
|
||||
const RUNTIME_CACHE_TTL_MS = 5_000
|
||||
let runtimeSystemCache: { system: RuntimeDecisionSystem; index: Map<string, IndexedStateEntry>; timestamp: number } | null = null
|
||||
|
||||
function buildRuntimeIndex(system: RuntimeDecisionSystem): Map<string, IndexedStateEntry> {
|
||||
const index = new Map<string, IndexedStateEntry>()
|
||||
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
|
||||
const states = tree?.states || {}
|
||||
for (const [stateId, state] of Object.entries(states)) {
|
||||
index.set(stateId, { flow: flowSlug, state })
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
async function getRuntimeSystemIndex(): Promise<{ system: RuntimeDecisionSystem; index: Map<string, IndexedStateEntry> }> {
|
||||
const now = Date.now()
|
||||
if (!runtimeSystemCache || now - runtimeSystemCache.timestamp > RUNTIME_CACHE_TTL_MS) {
|
||||
const system = await buildRuntimeDecisionSystem()
|
||||
runtimeSystemCache = {
|
||||
system,
|
||||
index: buildRuntimeIndex(system),
|
||||
timestamp: now,
|
||||
}
|
||||
}
|
||||
return { system: runtimeSystemCache.system, index: runtimeSystemCache.index }
|
||||
}
|
||||
|
||||
function evaluateRegexPattern(pattern: string | undefined, flags: string | undefined, value: string): boolean {
|
||||
const source = pattern?.trim()
|
||||
if (!source) {
|
||||
return false
|
||||
}
|
||||
const normalizedFlags = flags && flags.trim().length ? flags : 'i'
|
||||
try {
|
||||
const regex = new RegExp(source, normalizedFlags)
|
||||
return regex.test(value)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeTriggers(triggers: DecisionNodeTrigger[] | undefined, utterance: string) {
|
||||
if (!Array.isArray(triggers) || triggers.length === 0) {
|
||||
return { matchesRegex: false, matchesNone: true }
|
||||
}
|
||||
|
||||
let matchesRegex = false
|
||||
let hasNone = false
|
||||
for (const trigger of triggers) {
|
||||
if (!trigger) continue
|
||||
if (trigger.type === 'regex') {
|
||||
if (evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) {
|
||||
matchesRegex = true
|
||||
}
|
||||
} else if (trigger.type === 'none') {
|
||||
hasNone = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesRegex && !hasNone) {
|
||||
hasNone = true
|
||||
}
|
||||
|
||||
return { matchesRegex, matchesNone: hasNone }
|
||||
}
|
||||
|
||||
function normalizeComparable(value: any): any {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.length) return ''
|
||||
const numeric = Number(trimmed)
|
||||
if (!Number.isNaN(numeric)) return numeric
|
||||
if (trimmed.toLowerCase() === 'true') return true
|
||||
if (trimmed.toLowerCase() === 'false') return false
|
||||
return trimmed
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseComparable(raw: any): any {
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed.length) return ''
|
||||
const numeric = Number(trimmed)
|
||||
if (!Number.isNaN(numeric)) return numeric
|
||||
if (trimmed.toLowerCase() === 'true') return true
|
||||
if (trimmed.toLowerCase() === 'false') return false
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith('\'') && trimmed.endsWith('\''))
|
||||
) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function compareValuesSafe(left: any, operator: string | undefined, right: any): boolean {
|
||||
const normalizedLeft = normalizeComparable(left)
|
||||
const normalizedRight = normalizeComparable(parseComparable(right))
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft > normalizedRight
|
||||
: false
|
||||
case '>=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft >= normalizedRight
|
||||
: false
|
||||
case '<':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft < normalizedRight
|
||||
: false
|
||||
case '<=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft <= normalizedRight
|
||||
: false
|
||||
case '!==':
|
||||
case '!=':
|
||||
return normalizedLeft !== normalizedRight
|
||||
case '===':
|
||||
case '==':
|
||||
default:
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContextPath(
|
||||
path: string | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> }
|
||||
) {
|
||||
if (!path || typeof path !== 'string') return undefined
|
||||
const segments = path.split('.').map(segment => segment.trim()).filter(Boolean)
|
||||
if (!segments.length) return undefined
|
||||
|
||||
let current: any
|
||||
const [first, ...rest] = segments
|
||||
if (first === 'variables' || first === 'flags') {
|
||||
current = (context as any)[first]
|
||||
} else {
|
||||
current = context.variables
|
||||
rest.unshift(first)
|
||||
}
|
||||
|
||||
for (const segment of rest) {
|
||||
if (current == null) return undefined
|
||||
current = current[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function evaluateConditionEntry(
|
||||
condition: DecisionNodeCondition | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
switch (condition.type) {
|
||||
case 'regex':
|
||||
return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'regex_not':
|
||||
return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'variable_value':
|
||||
default: {
|
||||
const left = resolveContextPath(condition.variable, context)
|
||||
const operator = condition.operator || '=='
|
||||
return compareValuesSafe(left, operator, condition.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateConditionList(
|
||||
conditions: DecisionNodeCondition[] | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
if (!Array.isArray(conditions) || conditions.length === 0) {
|
||||
return true
|
||||
}
|
||||
const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
|
||||
for (const condition of ordered) {
|
||||
if (!evaluateConditionEntry(condition, context, utterance)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function filterDecisionCandidates(
|
||||
candidates: DecisionCandidate[],
|
||||
utterance: string,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> }
|
||||
): DecisionCandidate[] {
|
||||
if (!candidates.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regexMatches: DecisionCandidate[] = []
|
||||
const noneMatches: DecisionCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { matchesRegex, matchesNone } = analyzeTriggers(candidate.state?.triggers, utterance)
|
||||
if (matchesRegex) {
|
||||
regexMatches.push(candidate)
|
||||
} else if (matchesNone) {
|
||||
noneMatches.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const pool = regexMatches.length > 0 ? regexMatches : noneMatches
|
||||
if (!pool.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pool.filter(candidate =>
|
||||
evaluateConditionList(candidate.state?.conditions, context, utterance)
|
||||
)
|
||||
}
|
||||
|
||||
async function prepareDecisionCandidates(
|
||||
input: LLMDecisionInput,
|
||||
utterance: string
|
||||
): Promise<PreparedCandidateResult> {
|
||||
const { system, index } = await getRuntimeSystemIndex()
|
||||
|
||||
let activeFlowSlug = input.flow_slug && system.flows[input.flow_slug]
|
||||
? input.flow_slug
|
||||
: undefined
|
||||
|
||||
if (!activeFlowSlug) {
|
||||
const entry = index.get(input.state_id)
|
||||
if (entry) {
|
||||
activeFlowSlug = entry.flow
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeFlowSlug) {
|
||||
activeFlowSlug = system.main || Object.keys(system.flows)[0] || ''
|
||||
}
|
||||
|
||||
const uniqueCandidates = new Map<string, DecisionCandidate>()
|
||||
|
||||
const addCandidate = (candidate: DecisionCandidate | null | undefined) => {
|
||||
if (!candidate || !candidate.id || !candidate.state) return
|
||||
if (uniqueCandidates.has(candidate.id)) return
|
||||
uniqueCandidates.set(candidate.id, candidate)
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
if (!raw?.id) continue
|
||||
const indexed = index.get(raw.id)
|
||||
const flow = raw.flow || indexed?.flow || activeFlowSlug
|
||||
const state = indexed?.state ? { ...indexed.state } : raw.state
|
||||
if (!state) continue
|
||||
addCandidate({ id: raw.id, flow: flow || activeFlowSlug, state })
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
if (!raw?.id || !raw.state) continue
|
||||
if (!uniqueCandidates.has(raw.id)) {
|
||||
addCandidate({ id: raw.id, flow: raw.flow || activeFlowSlug, state: raw.state })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
|
||||
const startStateId = tree.start_state
|
||||
if (!startStateId) continue
|
||||
const indexed = index.get(startStateId)
|
||||
if (!indexed) continue
|
||||
addCandidate({ id: startStateId, flow: flowSlug, state: { ...indexed.state } })
|
||||
}
|
||||
|
||||
const candidates = Array.from(uniqueCandidates.values())
|
||||
const context = { variables: input.variables || {}, flags: input.flags || {} }
|
||||
const filteredCandidates = filterDecisionCandidates(candidates, utterance, context)
|
||||
|
||||
const candidateFlowMap = new Map<string, string>()
|
||||
for (const candidate of filteredCandidates) {
|
||||
if (candidate.flow) {
|
||||
candidateFlowMap.set(candidate.id, candidate.flow)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filteredCandidates,
|
||||
candidateFlowMap,
|
||||
activeFlowSlug,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null {
|
||||
const rawValue = input.variables?.[key]
|
||||
if (rawValue !== undefined && rawValue !== null) {
|
||||
@@ -329,8 +644,29 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
const pilotUtterance = (input.pilot_utterance || '').trim()
|
||||
const pilotText = pilotUtterance.toLowerCase()
|
||||
const trace: LLMDecisionTrace = {calls: []}
|
||||
let candidateFlowMap = new Map<string, string>()
|
||||
let activeFlowSlug = input.flow_slug || ''
|
||||
|
||||
const prepared = await prepareDecisionCandidates(input, pilotUtterance)
|
||||
candidateFlowMap = prepared.candidateFlowMap
|
||||
if (prepared.activeFlowSlug) {
|
||||
activeFlowSlug = prepared.activeFlowSlug
|
||||
input.flow_slug = prepared.activeFlowSlug
|
||||
}
|
||||
input.candidates = prepared.filteredCandidates.map(candidate => ({
|
||||
id: candidate.id,
|
||||
state: candidate.state,
|
||||
flow: candidate.flow,
|
||||
}))
|
||||
|
||||
const finalize = (decision: LLMDecision): LLMDecisionResult => {
|
||||
const targetState = decision.next_state
|
||||
if (targetState) {
|
||||
const targetFlow = candidateFlowMap.get(targetState)
|
||||
if (targetFlow && targetFlow !== activeFlowSlug) {
|
||||
decision.activate_flow = targetFlow
|
||||
}
|
||||
}
|
||||
if (!trace.calls.length && !trace.fallback) {
|
||||
return {decision}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user