From 10cae457f9badf5afcaaa818a25bcde616ba2c04 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 21 Sep 2025 21:16:33 +0200 Subject: [PATCH 1/7] Enable flow-aware decision routing --- app/pages/editor/index.vue | 608 +++++++++++++++--- app/pages/pm.vue | 74 ++- server/api/atc/ptt.post.ts | 5 +- server/api/decision-flows/runtime.get.ts | 6 + server/api/editor/flows/[slug]/nodes.post.ts | 50 +- .../flows/[slug]/nodes/[stateId]/index.put.ts | 51 +- server/middleware/auth.global.ts | 3 + server/models/DecisionNode.ts | 35 + server/services/decisionFlowService.ts | 68 +- server/services/decisionImportService.ts | 182 +----- server/utils/decisionSanitizer.ts | 182 ++++-- server/utils/openai.ts | 336 ++++++++++ shared/data/atcDecisionTree.ts | 585 ----------------- shared/types/decision.ts | 40 ++ shared/types/llm.ts | 5 +- shared/utils/communicationsEngine.ts | 390 ++++++++--- 16 files changed, 1622 insertions(+), 998 deletions(-) create mode 100644 server/api/decision-flows/runtime.get.ts delete mode 100644 shared/data/atcDecisionTree.ts diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue index 7070c81..651f823 100644 --- a/app/pages/editor/index.vue +++ b/app/pages/editor/index.vue @@ -325,7 +325,7 @@
+
+
+

Dieser Flow enthält noch keine Nodes.

+ + Ersten Node anlegen + +
+
@@ -489,99 +500,354 @@
-
-
-

Transitions

-
- - Transition - - - - - - {{ preset.label }} - {{ preset.description }} - - - -
+
+
+ {{ nodeFormError }}
- - - -
-
- {{ transition.type.toUpperCase() }} - → {{ transition.target || 'Ziel wählen' }} -
-
- Auto - Timer {{ transition.timer.afterSeconds }}s - - - -
-
-
- -
-
- - -
- - - - - -
- - -
-
-
-

Auto Trigger

- - {{ transition.autoTrigger ? 'Entfernen' : 'Hinzufügen' }} +
+
+

Node Trigger

+ + Trigger + +
+

+ Keine Trigger definiert. +

+ + + +
+
+ + {{ nodeTriggerTypeLabel(trigger.type) }} + + {{ nodeTriggerSummary(trigger) }} +
+
+ +
-
- -
- - - - -
-
- - - -
- + + +
+
+ - -
- - + +
+
+ +
+
+ + + +
+
+ + +
+

+ Dieser Trigger wird genutzt, wenn kein anderer vorher greift. +

+ +
+ + + +
+
+
+

Node Bedingungen

+ + Bedingung + +
+

+ Keine Bedingungen definiert. +

+ + + +
+
+ + {{ nodeConditionTypeLabel(condition.type) }} + + {{ nodeConditionSummary(condition) }} +
+
+ + + +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + +
+ +
+
+
+
+
+
+
+

Transitions

+
+ + Transition + + + + + + {{ preset.label }} + {{ preset.description }} + + + +
+
+ + + +
+
+ {{ transition.type.toUpperCase() }} + → {{ transition.target || 'Ziel wählen' }} +
+
+ Auto + Timer {{ transition.timer.afterSeconds }}s + + + +
+
+
+ +
+
+ + +
+ + + + + +
+ + +
+
+
+

Auto Trigger

+ + {{ transition.autoTrigger ? 'Entfernen' : 'Hinzufügen' }} + +
+
+ +
+ + + + +
+
+ + + +
+ + +
+ + +
-
-
- - + + + +
@@ -697,7 +963,9 @@ import DecisionNodeCanvas from '~/components/editor/DecisionNodeCanvas.vue' import type { DecisionFlowModel, DecisionFlowSummary, + DecisionNodeCondition, DecisionNodeModel, + DecisionNodeTrigger, DecisionNodeTransition, DecisionNodeLayout, } from '~/shared/types/decision' @@ -771,6 +1039,32 @@ const transitionTypes: DecisionNodeTransition['type'][] = ['next', 'ok', 'bad', const autoTriggerTypes = ['telemetry', 'variable', 'expression'] const roleOptions = ['pilot', 'atc', 'system'] +const nodeTriggerTypeOptions = [ + { value: 'auto_time', title: 'Auto (Zeit)', subtitle: 'Nach einer Verzögerung automatisch aktivieren' }, + { value: 'auto_variable', title: 'Auto (Variable)', subtitle: 'Aktivieren, sobald eine Variable einen Wert erreicht' }, + { value: 'regex', title: 'Regex Match', subtitle: 'Kandidat wenn vorheriger Output passt' }, + { value: 'none', title: 'Fallback', subtitle: 'Kandidat wenn nichts anderes greift' }, +] as const + +const nodeConditionTypeOptions = [ + { value: 'variable_value', title: 'Variable Vergleich', subtitle: 'Prüft Variablenwerte' }, + { value: 'regex', title: 'Regex Match', subtitle: 'Nur wenn der Output passt' }, + { value: 'regex_not', title: 'Regex kein Match', subtitle: 'Nur wenn der Output nicht passt' }, +] as const + +const nodeTriggerTypeLabels: Record = { + auto_time: 'Auto (Zeit)', + auto_variable: 'Auto (Variable)', + regex: 'Regex Match', + none: 'Fallback', +} + +const nodeConditionTypeLabels: Record = { + variable_value: 'Variable Vergleich', + regex: 'Regex Match', + regex_not: 'Regex kein Match', +} + const flows = ref([]) const flowsLoading = ref(false) const flowsError = ref('') @@ -867,6 +1161,7 @@ const nodeSnapshot = ref(null) const nodeSaving = ref(false) const nodeActionsText = ref('[]') const nodeActionsError = ref('') +const nodeFormError = ref('') const nodeIdDraft = ref('') let lastTitleSuggestion = '' @@ -1016,6 +1311,7 @@ watch(selectedNodeId, (stateId) => { nodeSnapshot.value = null nodeActionsText.value = '[]' nodeActionsError.value = '' + nodeFormError.value = '' nodeIdDraft.value = '' return } @@ -1025,6 +1321,7 @@ watch(selectedNodeId, (stateId) => { nodeSnapshot.value = null nodeActionsText.value = '[]' nodeActionsError.value = '' + nodeFormError.value = '' nodeIdDraft.value = '' return } @@ -1034,6 +1331,8 @@ watch(selectedNodeId, (stateId) => { if (!clone.layout) clone.layout = { x: 0, y: 0 } if (!clone.readbackRequired) clone.readbackRequired = [] if (!clone.transitions) clone.transitions = [] + if (!clone.triggers) clone.triggers = [] + if (!clone.conditions) clone.conditions = [] if (!clone.metadata) clone.metadata = {} if (!clone.llmTemplate) clone.llmTemplate = { placeholders: [] } if (!clone.llmTemplate.placeholders) clone.llmTemplate.placeholders = [] @@ -1041,6 +1340,7 @@ watch(selectedNodeId, (stateId) => { nodeSnapshot.value = cloneNode(clone) nodeActionsText.value = JSON.stringify(clone.actions ?? [], null, 2) nodeActionsError.value = '' + nodeFormError.value = '' nodeIdDraft.value = clone.stateId lastTitleSuggestion = buildNodeKeyFromText(clone.title || clone.stateId) pendingNodeHistory = null @@ -1291,7 +1591,8 @@ async function loadFlows() { const response = await api.get('/api/editor/flows') flows.value = response if (!selectedFlowSlug.value && response.length) { - selectedFlowSlug.value = response[0].slug + const preferred = response.find((flow) => flow.slug === 'icao_atc_decision_tree')?.slug + selectedFlowSlug.value = preferred || response[0].slug } } catch (error) { console.error('Failed to load flows', error) @@ -1362,6 +1663,52 @@ function closeCreateFlow() { showCreateFlowDialog.value = false } +async function createInitialNode() { + if (!flowDetail.value) return + const slug = flowDetail.value.flow.slug + const baseStart = flowDetail.value.flow.startState || 'START' + const stateId = baseStart.trim().length ? baseStart.trim().toUpperCase() : 'START' + const role = flowDetail.value.flow.roles?.[0] || 'pilot' + const phase = flowDetail.value.flow.phases?.[0] || 'General' + + const payload = { + stateId, + title: flowDetail.value.flow.name ? `${flowDetail.value.flow.name} Start` : 'Erster Node', + summary: '', + role, + phase, + transitions: [], + triggers: [], + conditions: [], + layout: { x: 320, y: 180 }, + } + + try { + const created = await api.post(`/api/editor/flows/${slug}/nodes`, payload) + flowDetail.value.nodes.push(created) + flowDetail.value.flow.startState = created.stateId + if (!Array.isArray(flowDetail.value.flow.endStates) || !flowDetail.value.flow.endStates.length) { + flowDetail.value.flow.endStates = [created.stateId] + flowForm.endStates = [created.stateId] + } + flowForm.startState = created.stateId + selectedNodeId.value = created.stateId + showSnack('Erster Node erstellt.') + const summaryIndex = flows.value.findIndex((flow) => flow.slug === slug) + if (summaryIndex !== -1) { + const previous = flows.value[summaryIndex] + flows.value.splice(summaryIndex, 1, { + ...previous, + startState: created.stateId, + nodeCount: (previous.nodeCount || 0) + 1, + }) + } + } catch (error) { + console.error('Failed to create initial node', error) + showSnack('Erster Node konnte nicht erstellt werden.', 'red') + } +} + async function createFlow() { if (!newFlowForm.slug.trim() || !newFlowForm.name.trim()) { newFlowError.value = 'Slug und Name werden benötigt.' @@ -1582,6 +1929,7 @@ async function persistNode(options: { silent?: boolean } = {}) { nodeInitializing = false }) lastNodeAutosaveError = '' + nodeFormError.value = '' if (silent) { flashAutosaveIndicator() } else { @@ -1589,10 +1937,11 @@ async function persistNode(options: { silent?: boolean } = {}) { } } catch (error: any) { console.error('Failed to save node', error) - const message = error?.statusMessage || 'Node konnte nicht gespeichert werden.' + const message = error?.data?.formError || error?.statusMessage || 'Node konnte nicht gespeichert werden.' if (!silent || message !== lastNodeAutosaveError) { showSnack(message, 'red') } + nodeFormError.value = message lastNodeAutosaveError = message } finally { nodeSaving.value = false @@ -1814,6 +2163,7 @@ function resetNode() { nodeForm.value = cloneNode(nodeSnapshot.value) nodeActionsText.value = JSON.stringify(nodeSnapshot.value.actions ?? [], null, 2) nodeActionsError.value = '' + nodeFormError.value = '' syncNodeLayout() pendingNodeHistory = null } @@ -2112,6 +2462,90 @@ function generateKey(prefix: string) { return `${prefix}_${Math.random().toString(36).slice(2, 10)}` } +function nodeTriggerTypeLabel(type: DecisionNodeTrigger['type']) { + return nodeTriggerTypeLabels[type] || type +} + +function nodeTriggerSummary(trigger: DecisionNodeTrigger) { + switch (trigger.type) { + case 'auto_time': + return `${trigger.delaySeconds ?? 0}s Verzögerung` + case 'auto_variable': + return trigger.variable + ? `${trigger.variable} ${trigger.operator ?? '=='} ${ + trigger.value !== undefined && trigger.value !== '' ? trigger.value : '?' + }` + : 'Variable prüfen' + case 'regex': + return trigger.pattern ? `/${trigger.pattern}/${trigger.patternFlags || ''}` : 'Regex prüfen' + case 'none': + return 'Fallback' + default: + return '' + } +} + +function nodeConditionTypeLabel(type: DecisionNodeCondition['type']) { + return nodeConditionTypeLabels[type] || type +} + +function nodeConditionSummary(condition: DecisionNodeCondition) { + switch (condition.type) { + case 'variable_value': + return condition.variable + ? `${condition.variable} ${condition.operator ?? '=='} ${ + condition.value !== undefined && condition.value !== '' ? condition.value : '?' + }` + : 'Variable prüfen' + case 'regex': + return condition.pattern ? `/${condition.pattern}/${condition.patternFlags || ''}` : 'Regex prüfen' + case 'regex_not': + return condition.pattern ? `!= /${condition.pattern}/${condition.patternFlags || ''}` : 'Regex darf nicht matchen' + default: + return '' + } +} + +function addNodeTrigger() { + if (!nodeForm.value) return + if (!nodeForm.value.triggers) nodeForm.value.triggers = [] + nodeForm.value.triggers.push({ + id: generateKey('trigger'), + type: 'auto_time', + delaySeconds: 5, + order: nodeForm.value.triggers.length, + }) +} + +function removeNodeTrigger(index: number) { + if (!nodeForm.value?.triggers) return + nodeForm.value.triggers.splice(index, 1) + nodeForm.value.triggers.forEach((trigger, idx) => { + trigger.order = idx + }) +} + +function addNodeCondition() { + if (!nodeForm.value) return + if (!nodeForm.value.conditions) nodeForm.value.conditions = [] + nodeForm.value.conditions.push({ + id: generateKey('condition'), + type: 'variable_value', + variable: '', + operator: '==', + value: '', + order: nodeForm.value.conditions.length, + }) +} + +function removeNodeCondition(index: number) { + if (!nodeForm.value?.conditions) return + nodeForm.value.conditions.splice(index, 1) + nodeForm.value.conditions.forEach((condition, idx) => { + condition.order = idx + }) +} + function addTransition(type: DecisionNodeTransition['type'] = 'next') { if (!nodeForm.value) return const transition: DecisionNodeTransition = { diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 3a9d9a2..48162ed 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -2,17 +2,33 @@
-
+

OpenSquawk

Pilot Monitoring

Alpha Build • Decision Tree • VATSIM

-
- - {{ currentState?.id || 'INIT' }} - -
{{ currentState?.phase || 'Setup' }}
+
+
+ + {{ currentState?.id || 'INIT' }} + +
{{ currentState?.phase || 'Setup' }}
+
+
@@ -696,6 +712,14 @@

{{ entry.message }}

+ + {{ entry.flow }} + {{ entry.frequency || 'N/A' }} {{ entry.state }}
@@ -953,9 +977,12 @@ const { flags, flightContext, currentStep, + availableFlows, + activeFlow, initializeFlight, updateFrequencyVariables, fetchRuntimeTree, + setActiveFlow, isReady: engineReady, processPilotTransmission, buildLLMContext, @@ -1023,6 +1050,41 @@ const clearLog = () => { clearLastTransmission() } +const selectedFlowSlug = ref('') +const flowOptions = computed(() => + availableFlows.value.map((flow) => ({ + title: flow.name, + value: flow.slug, + subtitle: flow.description, + })) +) + +watch( + activeFlow, + (slug) => { + selectedFlowSlug.value = slug || '' + }, + { immediate: true } +) + +watch(selectedFlowSlug, (slug, previous) => { + if (!slug || slug === activeFlow.value || slug === previous) { + return + } + handleFlowChange(slug) +}) + +function handleFlowChange(slug: string) { + if (!slug || slug === activeFlow.value) { + return + } + try { + setActiveFlow(slug) + } catch (error) { + console.error('Failed to activate flow', error) + } +} + // UI State const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login') const loading = ref(false) diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index d7f1a88..7ff4692 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -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; flags: Record; + 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; }; } diff --git a/server/api/decision-flows/runtime.get.ts b/server/api/decision-flows/runtime.get.ts new file mode 100644 index 0000000..91480e5 --- /dev/null +++ b/server/api/decision-flows/runtime.get.ts @@ -0,0 +1,6 @@ +import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService' + +export default defineEventHandler(async () => { + const system = await buildRuntimeDecisionSystem() + return system +}) diff --git a/server/api/editor/flows/[slug]/nodes.post.ts b/server/api/editor/flows/[slug]/nodes.post.ts index 7b40ce9..7615f8b 100644 --- a/server/api/editor/flows/[slug]/nodes.post.ts +++ b/server/api/editor/flows/[slug]/nodes.post.ts @@ -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, diff --git a/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts b/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts index 21008ef..1f50b49 100644 --- a/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts +++ b/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts @@ -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) diff --git a/server/middleware/auth.global.ts b/server/middleware/auth.global.ts index 1d91e00..3ae778d 100644 --- a/server/middleware/auth.global.ts +++ b/server/middleware/auth.global.ts @@ -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 } diff --git a/server/models/DecisionNode.ts b/server/models/DecisionNode.ts index 9fb3dcf..dc5f974 100644 --- a/server/models/DecisionNode.ts +++ b/server/models/DecisionNode.ts @@ -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( { _id: false } ) +const triggerSchema = new mongoose.Schema( + { + 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( + { + 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( { key: { type: String, required: true }, @@ -158,6 +191,8 @@ const decisionNodeSchema = new mongoose.Schema( 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 }, diff --git a/server/services/decisionFlowService.ts b/server/services/decisionFlowService.ts index 068028e..def3b66 100644 --- a/server/services/decisionFlowService.ts +++ b/server/services/decisionFlowService.ts @@ -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 { - 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 { + const nodes = nodeDocs ?? (await DecisionNode.find({ flow: flowDoc._id })) const states = nodes.reduce>((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 { + 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 { + 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>((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>((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, + } +} diff --git a/server/services/decisionImportService.ts b/server/services/decisionImportService.ts index b42a225..55fe6fb 100644 --- a/server/services/decisionImportService.ts +++ b/server/services/decisionImportService.ts @@ -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 = { - 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() - phases.forEach((phase, index) => phaseColumns.set(phase, index)) - const phaseRowCounters = new Map() - - 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, } } - diff --git a/server/utils/decisionSanitizer.ts b/server/utils/decisionSanitizer.ts index 18f148e..8949f90 100644 --- a/server/utils/decisionSanitizer.ts +++ b/server/utils/decisionSanitizer.ts @@ -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 = 'altitude_ft' +): NonNullable { + const parameter = asTrimmedString(input) + if (parameter && TELEMETRY_PARAMETERS.has(parameter)) { + return parameter as NonNullable + } + 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') diff --git a/server/utils/openai.ts b/server/utils/openai.ts index c3961e9..a565063 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -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 + activeFlowSlug: string +} + +const RUNTIME_CACHE_TTL_MS = 5_000 +let runtimeSystemCache: { system: RuntimeDecisionSystem; index: Map; timestamp: number } | null = null + +function buildRuntimeIndex(system: RuntimeDecisionSystem): Map { + const index = new Map() + 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 }> { + 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; flags: Record } +) { + 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; flags: Record }, + 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; flags: Record }, + 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; flags: Record } +): 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 { + 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() + + 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() + 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() + 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} } diff --git a/shared/data/atcDecisionTree.ts b/shared/data/atcDecisionTree.ts deleted file mode 100644 index 38a3f9d..0000000 --- a/shared/data/atcDecisionTree.ts +++ /dev/null @@ -1,585 +0,0 @@ -const atcDecisionTree = { - "schema_version": "1.0", - "name": "icao_atc_decision_tree", - "description": "Machine-readable ICAO/FAA-style ATC flow for IFR, suited for LLM+Whisper+TTS loop.", - "start_state": "CD_CHECK_ATIS", - "end_states": ["FLOW_COMPLETE"], - "variables": { - "callsign": "DLH39A", - "acf_type": "A320", - "dep": "EDDF", - "dest": "EDDM", - "stand": "A12", - "runway": "25R", - "sid": "ANEKI 7S", - "transition": "ANEKI", - "squawk": "1234", - "initial_altitude_ft": 5000, - "climb_altitude_ft": 7000, - "cruise_flight_level": "FL360", - "star": "RNAV X", - "approach_type": "ILS Z", - "taxi_route": "V A", - "missed_approach": "as published", - "delivery_freq": "121.900", - "ground_freq": "121.700", - "tower_freq": "118.700", - "departure_freq": "125.350", - "approach_freq": "120.800", - "handoff_freq": "121.800", - "atis_freq": "118.025", - "atis_code": "K", - "gate": "B24", - "trans_level": "FL070", - "qnh_hpa": 1015, - "push_delay_min": 5, - "surface_wind": "220/05", - "speed_restriction": "210 knots", - "emergency_heading": "180", - "remarks": "standard", - "time_now": "ISO8601" - }, - "flags": { - "in_air": false, - "emergency_active": false, - "current_unit": "DEL", - "stack": [] - }, - "policies": { - "timeouts": { - "pilot_readback_timeout_s": 8, - "controller_ack_timeout_s": 6, - "no_reply_retry_after_s": 5, - "no_reply_max_retries": 2, - "lost_comms_detect_after_s": 90 - }, - "no_reply_sequence": [ - {"after_s": 5, "controller_say_tpl": "{callsign}, confirm last transmission."}, - {"after_s": 10, "controller_say_tpl": "{callsign}, do you read?"}, - {"after_s": 20, "controller_say_tpl": "{callsign}, if you read, ident."} - ], - "interrupts_allowed_when": { - "MAYDAY": "flags.in_air === true", - "PANPAN": "flags.in_air === true", - "TCAS_RA": "flags.in_air === true", - "GO_AROUND": "true", - "LOST_COMMS": "true", - "UNABLE": "true", - "STANDBY": "true" - } - }, - "hooks": { - "on_state_enter": true, - "on_state_exit": true, - "on_timeout": true, - "on_interrupt": true, - "on_handoff": true, - "on_readback_check": true, - "on_pilot_speech": true, - "router_note": "Given pilot utterance → detect intent → if valid in active flow, branch; else jump to flow start with matching intent." - }, - "roles": ["pilot", "atc", "system"], - "phases": ["Preflight","Clearance","PushStart","TaxiOut","Departure","Climb","Enroute","Descent","Approach","Landing","TaxiIn","Postflight","Interrupt","LostComms","Missed"], - "states": { - "PREFLIGHT_START": { - "role": "pilot", - "phase": "Preflight", - "prompt_out": "Initial contact with Clearance Delivery.", - "next": [{"to": "CD_CHECK_ATIS"}] - }, - "CD_CHECK_ATIS": { - "role": "pilot", - "phase": "Clearance", - "utterance_tpl": "{callsign} information {atis_code}, IFR to {dest}, stand {stand}, request clearance.", - "next": [{"to": "CD_ISSUE_CLR"}], - "on_timeout": [{"after_s": 5, "action": "remind", "say_tpl": "{callsign}, say request."}] - }, - "CD_ISSUE_CLR": { - "role": "atc", - "phase": "Clearance", - "say_tpl": "{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.", - "readback_required": ["dest","sid","runway","initial_altitude_ft","squawk"], - "next": [ - {"to": "CD_VERIFY_READBACK"}, - {"to": "CD_AMEND_CLR", "when": "pilot_requests_amendment === true"} - ] - }, - "CD_VERIFY_READBACK": { - "role": "pilot", - "phase": "Clearance", - "utterance_tpl": "{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.", - "next": [{"to": "CD_READBACK_CHECK"}] - }, - "CD_READBACK_CHECK": { - "role": "atc", - "phase": "Clearance", - "auto": "check_readback", - "readback_required": ["dest","sid","runway","initial_altitude_ft","squawk"], - "ok_next": [{"to": "CD_CLR_COMPLETE"}], - "bad_next": [{"to": "CD_READBACK_CORRECT"}], - "on_timeout": [{"after_s": 6, "action": "say", "say_tpl": "{callsign}, read back clearance."}] - }, - "CD_READBACK_CORRECT": { - "role": "atc", - "phase": "Clearance", - "say_tpl": "{callsign}, negative; expect {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.", - "next": [{"to": "CD_VERIFY_READBACK"}] - }, - "CD_AMEND_CLR": { - "role": "atc", - "phase": "Clearance", - "say_tpl": "{callsign}, amended clearance: {sid} {transition} departure, runway {runway}.", - "next": [{"to": "CD_VERIFY_READBACK"}] - }, - "CD_CLR_COMPLETE": { - "role": "atc", - "phase": "Clearance", - "say_tpl": "{callsign}, readback correct. Start-up at own discretion. Contact Ground {ground_freq} when ready for push and start.", - "actions": [{"set": "flags.current_unit", "to": "DEL"}], - "handoff": {"to": "GROUND","freq": "{ground_freq}"}, - "next": [{"to": "GRD_READY_FOR_PUSH"}] - }, - - "GRD_READY_FOR_PUSH": { - "role": "pilot", - "phase": "PushStart", - "utterance_tpl": "{callsign} stand {stand}, ready for push and start.", - "next": [ - {"to": "GRD_PUSH_APPROVE", "when": "push_available === true"}, - {"to": "GRD_PUSH_WAIT", "when": "push_available === false"} - ] - }, - "GRD_PUSH_WAIT": { - "role": "atc", - "phase": "PushStart", - "say_tpl": "{callsign}, push and start approved in {push_delay_min} minutes. Expect taxi via {taxi_route}.", - "timer_next": [{"after_s": 60, "to": "GRD_PUSH_APPROVE"}], - "next": [{"to": "GRD_PUSH_APPROVE"}] - }, - "GRD_PUSH_APPROVE": { - "role": "atc", - "phase": "PushStart", - "say_tpl": "{callsign}, push and start approved, facing {runway}. QNH {qnh_hpa}.", - "next": [{"to": "GRD_TAXI_REQUEST"}] - }, - - "GRD_TAXI_REQUEST": { - "role": "pilot", - "phase": "TaxiOut", - "utterance_tpl": "{callsign}, request taxi.", - "next": [{"to": "GRD_TAXI_INSTR"}] - }, - "GRD_TAXI_INSTR": { - "role": "atc", - "phase": "TaxiOut", - "say_tpl": "{callsign}, taxi to runway {runway} via {taxi_route}, hold short runway {runway}.", - "readback_required": ["runway","taxi_route","hold_short"], - "next": [{"to": "GRD_TAXI_READBACK"}] - }, - "GRD_TAXI_READBACK": { - "role": "pilot", - "phase": "TaxiOut", - "utterance_tpl": "{callsign} taxi to {runway} via {taxi_route}, holding short {runway}.", - "next": [{"to": "GRD_TAXI_READBACK_CHECK"}] - }, - "GRD_TAXI_READBACK_CHECK": { - "role": "atc", - "phase": "TaxiOut", - "auto": "check_readback", - "readback_required": ["runway","taxi_route","hold_short"], - "ok_next": [{"to": "TWR_CONTACT"}], - "bad_next": [{"to": "GRD_TAXI_READBACK_CORRECT"}] - }, - "GRD_TAXI_READBACK_CORRECT": { - "role": "atc", - "phase": "TaxiOut", - "say_tpl": "{callsign}, negative; taxi to runway {runway} via {taxi_route}, hold short runway {runway}.", - "next": [{"to": "GRD_TAXI_READBACK"}] - }, - - "TWR_CONTACT": { - "role": "atc", - "phase": "TaxiOut", - "say_tpl": "{callsign}, contact Tower {tower_freq} when number one.", - "handoff": {"to": "TOWER","freq": "{tower_freq}"}, - "next": [{"to": "TWR_LINEUP_REQ"}] - }, - "TWR_LINEUP_REQ": { - "role": "pilot", - "phase": "Departure", - "utterance_tpl": "{callsign} holding short {runway}, ready for departure.", - "next": [ - {"to": "TWR_LINEUP", "when": "runway_occupied === true"}, - {"to": "TWR_TAKEOFF_CLR", "when": "runway_occupied === false"} - ] - }, - "TWR_LINEUP": { - "role": "atc", - "phase": "Departure", - "say_tpl": "{callsign}, line up and wait runway {runway}.", - "next": [{"to": "TWR_TAKEOFF_CLR"}] - }, - "TWR_TAKEOFF_CLR": { - "role": "atc", - "phase": "Departure", - "say_tpl": "{callsign}, wind {surface_wind}, runway {runway} cleared for take-off.", - "readback_required": ["runway","cleared_takeoff"], - "actions": [{"set": "flags.in_air", "to": true}], - "next": [{"to": "TWR_TAKEOFF_READBACK"}] - }, - "TWR_TAKEOFF_READBACK": { - "role": "pilot", - "phase": "Departure", - "utterance_tpl": "{callsign} cleared for take-off {runway}.", - "next": [{"to": "TWR_TAKEOFF_READBACK_CHECK"}] - }, - "TWR_TAKEOFF_READBACK_CHECK": { - "role": "atc", - "phase": "Departure", - "auto": "check_readback", - "readback_required": ["runway","cleared_takeoff"], - "ok_next": [{"to": "DEP_CONTACT"}], - "bad_next": [{"to": "TWR_TAKEOFF_READBACK_CORRECT"}] - }, - "TWR_TAKEOFF_READBACK_CORRECT": { - "role": "atc", - "phase": "Departure", - "say_tpl": "{callsign}, negative; runway {runway}, cleared for take-off.", - "next": [{"to": "TWR_TAKEOFF_READBACK"}] - }, - - "DEP_CONTACT": { - "role": "atc", - "phase": "Departure", - "say_tpl": "{callsign}, contact Departure {departure_freq}.", - "handoff": {"to": "DEPARTURE","freq": "{departure_freq}"}, - "actions": [{"set": "flags.current_unit", "to": "DEP"}], - "next": [{"to": "DEP_IDENT"}] - }, - "DEP_IDENT": { - "role": "pilot", - "phase": "Climb", - "utterance_tpl": "{callsign} passing {initial_altitude_ft}, on SID {sid}.", - "next": [{"to": "DEP_CLIMB_INSTR"}] - }, - "DEP_CLIMB_INSTR": { - "role": "atc", - "phase": "Climb", - "say_tpl": "{callsign}, climb {climb_altitude_ft} feet, proceed direct {transition} if able.", - "next": [ - {"to": "DEP_CLIMB_READBACK", "when": "pilot_able === true"}, - {"to": "DEP_UNABLE_DIR", "when": "pilot_able === false"} - ] - }, - "DEP_UNABLE_DIR": { - "role": "pilot", - "phase": "Climb", - "utterance_tpl": "{callsign} unable direct {transition}.", - "next": [{"to": "DEP_ALT_RTE"}] - }, - "DEP_ALT_RTE": { - "role": "atc", - "phase": "Climb", - "say_tpl": "{callsign}, continue SID, report passing {climb_altitude_ft}.", - "next": [{"to": "ENR_HANDOFF"}] - }, - "DEP_CLIMB_READBACK": { - "role": "pilot", - "phase": "Climb", - "utterance_tpl": "{callsign} climb {climb_altitude_ft}, direct {transition}.", - "next": [{"to": "ENR_HANDOFF"}] - }, - - "ENR_HANDOFF": { - "role": "atc", - "phase": "Enroute", - "say_tpl": "{callsign}, contact Center {handoff_freq}.", - "handoff": {"to": "CENTER","freq": "{handoff_freq}"}, - "actions": [{"set": "flags.current_unit", "to": "CTR"}], - "next": [{"to": "ENR_CRUISE"}] - }, - "ENR_CRUISE": { - "role": "pilot", - "phase": "Enroute", - "auto": "monitor", - "next": [{"to": "DES_INITIATE"}] - }, - - "DES_INITIATE": { - "role": "atc", - "phase": "Descent", - "say_tpl": "{callsign}, descend via {star} {transition}, QNH {qnh_hpa}.", - "next": [{"to": "DES_READBACK"}] - }, - "DES_READBACK": { - "role": "pilot", - "phase": "Descent", - "utterance_tpl": "{callsign} descend via {star} {transition}, QNH {qnh_hpa}.", - "next": [{"to": "APP_HANDOFF"}] - }, - - "APP_HANDOFF": { - "role": "atc", - "phase": "Descent", - "say_tpl": "{callsign}, contact Approach {approach_freq}.", - "handoff": {"to": "APPROACH","freq": "{approach_freq}"}, - "actions": [{"set": "flags.current_unit", "to": "APP"}], - "next": [{"to": "APP_VECTORING"}] - }, - "APP_VECTORING": { - "role": "atc", - "phase": "Approach", - "say_tpl": "{callsign}, turn left heading 220, descend to {initial_altitude_ft} feet, reduce speed {speed_restriction}.", - "next": [{"to": "APP_CLEARED_APP"}] - }, - "APP_CLEARED_APP": { - "role": "atc", - "phase": "Approach", - "say_tpl": "{callsign}, cleared {approach_type} approach runway {runway}, report established.", - "next": [{"to": "APP_ESTABLISHED"}] - }, - "APP_ESTABLISHED": { - "role": "pilot", - "phase": "Approach", - "utterance_tpl": "{callsign} established localizer {runway}.", - "next": [{"to": "TWR_LAND_CONTACT"}] - }, - - "TWR_LAND_CONTACT": { - "role": "atc", - "phase": "Approach", - "say_tpl": "{callsign}, contact Tower {tower_freq}.", - "handoff": {"to": "TOWER","freq": "{tower_freq}"}, - "actions": [{"set": "flags.current_unit", "to": "TWR"}], - "next": [{"to": "TWR_LAND_CLEARABLE"}] - }, - "TWR_LAND_CLEARABLE": { - "role": "atc", - "phase": "Landing", - "condition": "runway_available === true", - "say_tpl": "{callsign}, wind {surface_wind}, runway {runway} cleared to land.", - "else_say_tpl": "{callsign}, continue approach, expect late landing clearance.", - "next": [ - {"to": "TWR_LAND_READBACK", "when": "runway_available === true"}, - {"to": "TWR_CONTINUE_APPROACH", "when": "runway_available === false"} - ] - }, - "TWR_CONTINUE_APPROACH": { - "role": "atc", - "phase": "Landing", - "say_tpl": "{callsign}, continue approach.", - "next": [{"to": "TWR_LAND_CLEARABLE"}] - }, - "TWR_LAND_READBACK": { - "role": "pilot", - "phase": "Landing", - "utterance_tpl": "{callsign} cleared to land {runway}.", - "next": [{"to": "TWR_VACATE"}] - }, - "TWR_VACATE": { - "role": "atc", - "phase": "Landing", - "say_tpl": "{callsign}, vacate via {taxi_route}, contact Ground {ground_freq}.", - "actions": [{"set": "flags.in_air", "to": false}], - "handoff": {"to": "GROUND","freq": "{ground_freq}"}, - "next": [{"to": "GRD_TAXI_IN_REQ"}] - }, - - "GRD_TAXI_IN_REQ": { - "role": "pilot", - "phase": "TaxiIn", - "utterance_tpl": "{callsign} runway vacated, request taxi to stand.", - "next": [{"to": "GRD_TAXI_INSTR_IN"}] - }, - "GRD_TAXI_INSTR_IN": { - "role": "atc", - "phase": "TaxiIn", - "say_tpl": "{callsign}, taxi to stand {gate} via {taxi_route}.", - "readback_required": ["gate","taxi_route"], - "next": [{"to": "GRD_TAXI_IN_READBACK"}] - }, - "GRD_TAXI_IN_READBACK": { - "role": "pilot", - "phase": "TaxiIn", - "utterance_tpl": "{callsign} taxi to stand {gate} via {taxi_route}.", - "next": [{"to": "GRD_TAXI_IN_READBACK_CHECK"}] - }, - "GRD_TAXI_IN_READBACK_CHECK": { - "role": "atc", - "phase": "TaxiIn", - "auto": "check_readback", - "readback_required": ["gate","taxi_route"], - "ok_next": [{"to": "FLOW_COMPLETE"}], - "bad_next": [{"to": "GRD_TAXI_IN_READBACK_CORRECT"}] - }, - "GRD_TAXI_IN_READBACK_CORRECT": { - "role": "atc", - "phase": "TaxiIn", - "say_tpl": "{callsign}, negative; taxi to stand {gate} via {taxi_route}.", - "next": [{"to": "GRD_TAXI_IN_READBACK"}] - }, - "FLOW_COMPLETE": { - "role": "system", - "phase": "Postflight", - "auto": "end", - "next": [] - }, - - /* ===== Interrupts (conditioned) ===== */ - - "INT_MAYDAY": { - "role": "pilot", - "phase": "Interrupt", - "guard": "flags.in_air === true", - "utterance_tpl": "MAYDAY MAYDAY MAYDAY, {callsign}, {problem}, intentions {intent}.", - "priority": "highest", - "actions": [{"set": "flags.emergency_active", "to": true}], - "next": [{"to": "ATC_MAYDAY_VECTOR"}] - }, - "ATC_MAYDAY_VECTOR": { - "role": "atc", - "phase": "Interrupt", - "say_tpl": "{callsign}, roger MAYDAY, fly heading {emergency_heading}, climb/descend {initial_altitude_ft}, cleared direct {dest} when able, QNH {qnh_hpa}.", - "next": [{"to": "ATC_MAYDAY_COORD"}] - }, - "ATC_MAYDAY_COORD": { - "role": "system", - "phase": "Interrupt", - "actions": ["alert_emergency_services","notify_adjacent_units"], - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - - "INT_PANPAN": { - "role": "pilot", - "phase": "Interrupt", - "guard": "flags.in_air === true", - "utterance_tpl": "PAN PAN PAN, {callsign}, {problem}, request priority.", - "priority": "high", - "actions": [{"set": "flags.emergency_active", "to": true}], - "next": [{"to": "ATC_PAN_ACK"}] - }, - "ATC_PAN_ACK": { - "role": "atc", - "phase": "Interrupt", - "say_tpl": "{callsign}, PAN acknowledged, priority granted, expect vectors direct {dest} or nearest suitable.", - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - - "INT_TCAS_RA": { - "role": "pilot", - "phase": "Interrupt", - "guard": "flags.in_air === true", - "utterance_tpl": "{callsign} TCAS RA, deviating.", - "actions": ["suspend_clearances"], - "next": [{"to": "ATC_TCAS_ACK"}] - }, - "ATC_TCAS_ACK": { - "role": "atc", - "phase": "Interrupt", - "say_tpl": "{callsign}, roger TCAS RA, report clear of conflict.", - "next": [{"to": "ATC_TCAS_RESUME"}] - }, - "ATC_TCAS_RESUME": { - "role": "pilot", - "phase": "Interrupt", - "utterance_tpl": "{callsign} clear of conflict, returning to clearance.", - "actions": [{"set": "flags.emergency_active", "to": false}], - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - - "INT_GOA": { - "role": "pilot", - "phase": "Missed", - "utterance_tpl": "{callsign} going around, {missed_approach}.", - "actions": [{"set": "flags.in_air", "to": true}], - "next": [{"to": "ATC_GOA_INSTR"}] - }, - "ATC_GOA_INSTR": { - "role": "atc", - "phase": "Missed", - "say_tpl": "{callsign}, roger go-around, fly published missed approach, climb {initial_altitude_ft}, contact Approach {approach_freq}.", - "handoff": {"to": "APPROACH","freq": "{approach_freq}"}, - "next": [{"to": "APP_VECTORING"}] - }, - - "INT_NORDO": { - "role": "system", - "phase": "LostComms", - "trigger": "no_reply > policies.timeouts.lost_comms_detect_after_s", - "actions": ["lost_comms_procedure"], - "next": [{"to": "ATC_NORDO_ACTION"}] - }, - "ATC_NORDO_ACTION": { - "role": "atc", - "phase": "LostComms", - "say_tpl": "(Transmitted blind) {callsign}, if you read, squawk IDENT and continue per last clearance. Expect vectors.", - "next": [{"to": "SYSTEM_NORDO_COORD"}] - }, - "SYSTEM_NORDO_COORD": { - "role": "system", - "phase": "LostComms", - "actions": ["notify_adjacent_units","monitor_light_gun","publish_ATIS_note"], - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - - "INT_UNABLE": { - "role": "pilot", - "phase": "Interrupt", - "utterance_tpl": "{callsign} unable {instruction}.", - "next": [{"to": "ATC_ALT_PROPOSAL"}] - }, - "ATC_ALT_PROPOSAL": { - "role": "atc", - "phase": "Interrupt", - "say_tpl": "{callsign}, alternative: {alt_instruction}.", - "next": [ - {"to": "PILOT_ACCEPT_ALT"}, - {"to": "PILOT_REJECT_ALT"} - ] - }, - "PILOT_ACCEPT_ALT": { - "role": "pilot", - "phase": "Interrupt", - "utterance_tpl": "{callsign} wilco.", - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - "PILOT_REJECT_ALT": { - "role": "pilot", - "phase": "Interrupt", - "utterance_tpl": "{callsign} negative, request {intent}.", - "next": [{"to": "ATC_ALT_PROPOSAL"}] - }, - - "INT_STANDBY": { - "role": "pilot", - "phase": "Interrupt", - "utterance_tpl": "{callsign} standby.", - "actions": ["pause_exchange"], - "next": [{"to": "RESUME_PRIOR_FLOW"}] - }, - - /* ===== Generic glue & router ===== */ - - "RESUME_PRIOR_FLOW": { - "role": "system", - "phase": "Interrupt", - "auto": "pop_stack_or_route_by_intent", - "actions": [ - {"if": "flags.emergency_active === true && flags.in_air === true", "set": "flags.emergency_active", "to": false} - ], - "next": [] - }, - - "GEN_NO_REPLY": { - "role": "system", - "phase": "Interrupt", - "trigger": "no_reply", - "policy_ref": "policies.no_reply_sequence", - "escalate_to": "INT_NORDO", - "next": [] - } - } -} - - -export default atcDecisionTree; diff --git a/shared/types/decision.ts b/shared/types/decision.ts index a5d5f31..b2fdc63 100644 --- a/shared/types/decision.ts +++ b/shared/types/decision.ts @@ -33,6 +33,35 @@ export interface DecisionNodeAutoTrigger { delayMs?: number } +export type DecisionNodeTriggerType = 'auto_time' | 'auto_variable' | 'regex' | 'none' + +export interface DecisionNodeTrigger { + id: string + type: DecisionNodeTriggerType + order?: number + delaySeconds?: number + variable?: string + operator?: DecisionComparisonOperator + value?: number | string | boolean + pattern?: string + patternFlags?: string + description?: string +} + +export type DecisionNodeConditionType = 'variable_value' | 'regex' | 'regex_not' + +export interface DecisionNodeCondition { + id: string + type: DecisionNodeConditionType + order?: number + variable?: string + operator?: DecisionComparisonOperator + value?: number | string | boolean + pattern?: string + patternFlags?: string + description?: string +} + export interface DecisionTransitionMetadata { color?: string icon?: string @@ -114,6 +143,8 @@ export interface DecisionNodeModel { trigger?: string frequency?: string frequencyName?: string + triggers?: DecisionNodeTrigger[] + conditions?: DecisionNodeCondition[] transitions: DecisionNodeTransition[] layout?: DecisionNodeLayout metadata?: DecisionNodeMetadata @@ -198,10 +229,13 @@ export interface RuntimeDecisionState { frequency?: string frequencyName?: string auto_transitions?: RuntimeDecisionAutoTransition[] + triggers?: DecisionNodeTrigger[] + conditions?: DecisionNodeCondition[] metadata?: DecisionNodeMetadata } export interface RuntimeDecisionTree { + slug: string schema_version: string name: string description?: string @@ -216,6 +250,12 @@ export interface RuntimeDecisionTree { states: Record } +export interface RuntimeDecisionSystem { + main: string + order: string[] + flows: Record +} + export interface DecisionFlowSummary { id: string slug: string diff --git a/shared/types/llm.ts b/shared/types/llm.ts index baad63a..95b6820 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -1,10 +1,11 @@ export interface LLMDecisionInput { state_id: string state: any - candidates: Array<{ id: string; state: any }> + candidates: Array<{ id: string; state: any; flow?: string }> variables: Record flags: Record pilot_utterance: string + flow_slug?: string } export interface LLMDecision { @@ -14,4 +15,6 @@ export interface LLMDecision { controller_say_tpl?: string off_schema?: boolean radio_check?: boolean + activate_flow?: string + resume_previous?: boolean } diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index bac5a37..6f39598 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,7 +1,8 @@ // communicationsEngine composable -import { ref, computed, readonly } from 'vue' +import { ref, computed, readonly, reactive } from 'vue' import type { RuntimeDecisionTree, + RuntimeDecisionSystem, RuntimeDecisionState, RuntimeDecisionAutoTransition, DecisionNodeAutoTrigger, @@ -93,6 +94,19 @@ export interface EngineLog { state: string radioCheck?: boolean offSchema?: boolean + flow?: string +} + +interface FlowSnapshot { + tree: RuntimeDecisionTree + variables: Record + flags: EngineFlags + telemetry: TelemetryState + currentStateId: string + communicationLog: EngineLog[] + autoHistory: Map> + flightContext: FlightContext + ready: boolean } type TelemetryState = { @@ -111,6 +125,33 @@ export function normalizeATCText(text: string, context: Record): st return normalizeRadioPhrase(rendered) } +function createDefaultFlightContext(): FlightContext { + return { + callsign: '', + aircraft: 'A320', + dep: 'EDDF', + dest: 'EDDM', + stand: 'A12', + runway: '25R', + squawk: '1234', + atis_code: 'K', + sid: 'ANEKI7S', + transition: 'ANEKI', + flight_level: 'FL360', + atis_freq: '118.025', + ground_freq: '121.700', + tower_freq: '118.700', + departure_freq: '125.350', + approach_freq: '120.800', + handoff_freq: '121.800', + qnh_hpa: 1015, + taxi_route: 'A, V', + remarks: 'standard', + time_now: undefined, + phase: 'clearance', + } +} + function renderTpl(tpl: string, ctx: Record): string { return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => { const parts = key.split('.') @@ -121,9 +162,15 @@ function renderTpl(tpl: string, ctx: Record): string { } export default function useCommunicationsEngine() { + const runtimeSystem = ref(null) + const flowOrder = ref([]) + const activeFlowSlug = ref('') + const tree = ref(null) const ready = ref(false) + const flowSnapshots = reactive>({}) + const states = computed>(() => tree.value?.states ?? {}) const variables = ref>({}) @@ -148,31 +195,8 @@ export default function useCommunicationsEngine() { heading_deg: 0, }) - const autoExecutionHistory = new Map>() - // Flight context used for pm_alt.vue integration - const flightContext = ref({ - callsign: '', - aircraft: 'A320', - dep: 'EDDF', - dest: 'EDDM', - stand: 'A12', - runway: '25R', - squawk: '1234', - atis_code: 'K', - sid: 'ANEKI7S', - transition: 'ANEKI', - flight_level: 'FL360', - atis_freq: '118.025', - ground_freq: '121.700', - tower_freq: '118.700', - departure_freq: '125.350', - approach_freq: '120.800', - handoff_freq: '121.800', - qnh_hpa: 1015, - taxi_route: 'A, V', - phase: 'clearance' - }) + const flightContext = ref(createDefaultFlightContext()) const currentState = computed(() => { const stateMap = states.value @@ -183,6 +207,140 @@ export default function useCommunicationsEngine() { return base ? { ...base, id } : null }) + function ensureSnapshot(slug: string): FlowSnapshot { + const snapshot = flowSnapshots[slug] + if (!snapshot) { + throw new Error(`Flow snapshot not loaded: ${slug}`) + } + return snapshot + } + + function getActiveSnapshot(): FlowSnapshot | null { + if (!activeFlowSlug.value) return null + return flowSnapshots[activeFlowSlug.value] || null + } + + function assignActiveVariables(next: Record) { + variables.value = next + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].variables = next + } + } + + function assignActiveFlags(next: EngineFlags) { + flags.value = next + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].flags = next + } + } + + function assignActiveTelemetry(next: TelemetryState) { + telemetry.value = next + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].telemetry = next + } + } + + function assignCommunicationLog(next: EngineLog[]) { + communicationLog.value = next + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].communicationLog = next + } + } + + function assignFlightContext(next: FlightContext) { + flightContext.value = next + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].flightContext = next + } + } + + function setActiveStateId(stateId: string) { + currentStateId.value = stateId + if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { + flowSnapshots[activeFlowSlug.value].currentStateId = stateId + } + } + + function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot { + const variables = { ...treeData.variables } + const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {} + const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : [] + const flags: EngineFlags = { + in_air: Boolean((baseFlags as any).in_air), + emergency_active: Boolean((baseFlags as any).emergency_active), + current_unit: typeof (baseFlags as any).current_unit === 'string' + ? (baseFlags as any).current_unit + : 'DEL', + stack, + off_schema_count: Number((baseFlags as any).off_schema_count) || 0, + radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0, + ...(baseFlags as EngineFlags), + } + if (!Array.isArray(flags.stack)) { + flags.stack = [] + } + + const telemetry: TelemetryState = { + altitude_ft: Number((baseFlags as any).altitude_ft) || 0, + speed_kts: Number((baseFlags as any).speed_kts) || 0, + groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0, + vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0, + latitude_deg: Number((baseFlags as any).latitude_deg) || 0, + longitude_deg: Number((baseFlags as any).longitude_deg) || 0, + heading_deg: Number((baseFlags as any).heading_deg) || 0, + } + + const log: EngineLog[] = [] + const snapshotContext = createDefaultFlightContext() + snapshotContext.phase = 'clearance' + + const autoHistory = new Map>() + if (treeData.start_state) { + autoHistory.set(treeData.start_state, new Set()) + } + + return { + tree: treeData, + variables, + flags, + telemetry, + currentStateId: treeData.start_state, + communicationLog: log, + autoHistory, + flightContext: snapshotContext, + ready: true, + } + } + + function persistActiveSnapshot() { + if (!activeFlowSlug.value) return + const snapshot = flowSnapshots[activeFlowSlug.value] + if (!snapshot) return + snapshot.variables = variables.value + snapshot.flags = flags.value + snapshot.telemetry = telemetry.value + snapshot.currentStateId = currentStateId.value + snapshot.communicationLog = communicationLog.value + snapshot.flightContext = flightContext.value + snapshot.ready = ready.value + } + + function activateFlow(slug: string) { + const snapshot = ensureSnapshot(slug) + if (activeFlowSlug.value && activeFlowSlug.value !== slug) { + persistActiveSnapshot() + } + activeFlowSlug.value = slug + tree.value = snapshot.tree + assignActiveVariables(snapshot.variables) + assignActiveFlags(snapshot.flags) + assignActiveTelemetry(snapshot.telemetry) + assignCommunicationLog(snapshot.communicationLog) + assignFlightContext(snapshot.flightContext) + setActiveStateId(snapshot.currentStateId) + ready.value = snapshot.ready + } const nextCandidates = computed(() => { const s = currentState.value if (!s) return [] @@ -210,69 +368,137 @@ export default function useCommunicationsEngine() { return tree.value } - function resetAutoHistory(stateId: string) { - autoExecutionHistory.set(stateId, new Set()) + function resetAutoHistory(stateId: string, slug = activeFlowSlug.value) { + if (!slug) return + const snapshot = ensureSnapshot(slug) + snapshot.autoHistory.set(stateId, new Set()) } - function markAutoExecuted(stateId: string, transitionId: string) { - if (!autoExecutionHistory.has(stateId)) { - autoExecutionHistory.set(stateId, new Set()) + function markAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value) { + if (!slug) return + const snapshot = ensureSnapshot(slug) + if (!snapshot.autoHistory.has(stateId)) { + snapshot.autoHistory.set(stateId, new Set()) } - autoExecutionHistory.get(stateId)!.add(transitionId) + snapshot.autoHistory.get(stateId)!.add(transitionId) } - function hasAutoExecuted(stateId: string, transitionId: string): boolean { - const set = autoExecutionHistory.get(stateId) + function hasAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value): boolean { + if (!slug) return false + const snapshot = ensureSnapshot(slug) + const set = snapshot.autoHistory.get(stateId) return set ? set.has(transitionId) : false } function resetEngineFromTree(treeData: RuntimeDecisionTree) { - tree.value = treeData - variables.value = { ...treeData.variables } - const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {} - const stack = Array.isArray(baseFlags.stack) ? [...baseFlags.stack] : [] - flags.value = { - in_air: Boolean(baseFlags.in_air), - emergency_active: Boolean(baseFlags.emergency_active), - current_unit: typeof baseFlags.current_unit === 'string' ? baseFlags.current_unit : 'DEL', - stack, - off_schema_count: 0, - radio_checks_done: 0, - ...baseFlags, + const system: RuntimeDecisionSystem = { + main: treeData.slug, + order: [treeData.slug], + flows: { [treeData.slug]: treeData }, } - if (!Array.isArray(flags.value.stack)) { - flags.value.stack = [] + resetEngineFromSystem(system, { activeSlug: treeData.slug }) + } + + function resetEngineFromSystem(system: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) { + runtimeSystem.value = system + const order = Array.isArray(system.order) && system.order.length + ? [...system.order] + : Object.keys(system.flows) + flowOrder.value = order + + for (const key of Object.keys(flowSnapshots)) { + delete flowSnapshots[key] } - currentStateId.value = treeData.start_state - communicationLog.value = [] - telemetry.value = { - altitude_ft: Number(baseFlags.altitude_ft) || 0, - speed_kts: Number(baseFlags.speed_kts) || 0, - groundspeed_kts: Number(baseFlags.groundspeed_kts) || 0, - vertical_speed_fpm: Number(baseFlags.vertical_speed_fpm) || 0, - latitude_deg: Number(baseFlags.latitude_deg) || 0, - longitude_deg: Number(baseFlags.longitude_deg) || 0, - heading_deg: Number(baseFlags.heading_deg) || 0, + + for (const slug of order) { + const treeData = system.flows[slug] + if (!treeData) continue + flowSnapshots[slug] = createSnapshotFromTree(treeData) + } + + const preferred = options.activeSlug && system.flows[options.activeSlug] + ? options.activeSlug + : system.main && system.flows[system.main] + ? system.main + : order[0] + + if (preferred) { + activateFlow(preferred) + ready.value = true + const snapshot = ensureSnapshot(preferred) + resetAutoHistory(snapshot.currentStateId, preferred) + evaluateAutoTransitions() + } else { + activeFlowSlug.value = '' + tree.value = null + ready.value = false + assignActiveVariables({}) + assignActiveFlags({ + in_air: false, + emergency_active: false, + current_unit: 'DEL', + stack: [], + off_schema_count: 0, + radio_checks_done: 0, + }) + assignActiveTelemetry({ + altitude_ft: 0, + speed_kts: 0, + groundspeed_kts: 0, + vertical_speed_fpm: 0, + latitude_deg: 0, + longitude_deg: 0, + heading_deg: 0, + }) + assignCommunicationLog([]) + assignFlightContext(createDefaultFlightContext()) + setActiveStateId('') } - autoExecutionHistory.clear() - resetAutoHistory(currentStateId.value) - flightContext.value.phase = 'clearance' - ready.value = true - evaluateAutoTransitions() } function loadRuntimeTree(data: RuntimeDecisionTree) { resetEngineFromTree(data) } + function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) { + resetEngineFromSystem(data, options) + } + + const activeFlow = computed(() => activeFlowSlug.value) + + const availableFlows = computed(() => { + if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }> + return flowOrder.value + .filter((slug) => Boolean(runtimeSystem.value!.flows[slug])) + .map((slug) => { + const treeData = runtimeSystem.value!.flows[slug] + return { + slug, + name: treeData.name || slug, + description: treeData.description, + start: treeData.start_state, + } + }) + }) + + function setActiveFlow(slug: string) { + if (!slug || !flowSnapshots[slug]) { + throw new Error(`Flow snapshot not loaded: ${slug}`) + } + activateFlow(slug) + ready.value = true + queueMicrotask(() => evaluateAutoTransitions()) + } + async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') { ready.value = false const fetcher: any = (globalThis as any).$fetch if (typeof fetcher !== 'function') { throw new Error('Universal fetch is not available in this context') } - const data = await fetcher(`/api/decision-flows/${slug}/runtime`) - resetEngineFromTree(data) + const data = await fetcher('/api/decision-flows/runtime') + const activeSlug = slug && data.flows[slug] ? slug : data.main + resetEngineFromSystem(data, { activeSlug }) } function normalizeComparableValue(value: any): any { @@ -417,7 +643,7 @@ export default function useCommunicationsEngine() { function initializeFlight(fpl: any) { const runtime = ensureTree() // Set variables - variables.value = { + const nextVariables = { ...variables.value, callsign: fpl.callsign || fpl.callsign, acf_type: fpl.aircraft?.split('/')[0] || 'A320', @@ -448,6 +674,7 @@ export default function useCommunicationsEngine() { remarks: 'standard', time_now: new Date().toISOString() } + assignActiveVariables(nextVariables) // Update flight context Object.assign(flightContext.value, { @@ -455,7 +682,7 @@ export default function useCommunicationsEngine() { phase: 'clearance' }) - flags.value = { + const nextFlags: EngineFlags = { ...flags.value, in_air: false, emergency_active: false, @@ -464,10 +691,11 @@ export default function useCommunicationsEngine() { off_schema_count: 0, radio_checks_done: 0 } + assignActiveFlags(nextFlags) - currentStateId.value = runtime.start_state - communicationLog.value = [] - resetAutoHistory(currentStateId.value) + setActiveStateId(runtime.start_state) + assignCommunicationLog([]) + resetAutoHistory(runtime.start_state) } function updateFrequencyVariables(update: Partial>) { @@ -495,7 +723,7 @@ export default function useCommunicationsEngine() { throw new Error('Decision state unavailable') } const candidates = nextCandidates.value - .map(id => ({ id, state: states.value[id] })) + .map(id => ({ id, state: states.value[id], flow: runtime.slug })) .filter(candidate => candidate.state) return { @@ -506,6 +734,7 @@ export default function useCommunicationsEngine() { flags: { ...flags.value }, pilot_utterance: pilotTranscript, tree: runtime.name, + flow_slug: runtime.slug, } } @@ -530,6 +759,14 @@ export default function useCommunicationsEngine() { flags.value.stack = decision.stack.slice() } + if (decision.activate_flow && decision.activate_flow !== activeFlowSlug.value) { + try { + setActiveFlow(decision.activate_flow) + } catch (err) { + console.warn('[Engine] Failed to activate flow from decision', err) + } + } + if (decision.off_schema) { flags.value.off_schema_count++ console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`) @@ -624,7 +861,7 @@ export default function useCommunicationsEngine() { const fallback = typeof raw === 'number' ? raw : Number(raw) next[key] = Number.isNaN(fallback) ? current : fallback } - telemetry.value = next + assignActiveTelemetry(next) queueMicrotask(() => evaluateAutoTransitions()) } @@ -693,7 +930,7 @@ export default function useCommunicationsEngine() { flags.value.stack.push(currentStateId.value) } - currentStateId.value = stateId + setActiveStateId(stateId) resetAutoHistory(stateId) const s = currentState.value if (!s) return @@ -764,7 +1001,8 @@ export default function useCommunicationsEngine() { normalized: normalizeATCText(msg, exposeCtxFlat()), state: stateId, radioCheck: options.radioCheck, - offSchema: options.offSchema + offSchema: options.offSchema, + flow: activeFlowSlug.value || undefined, } communicationLog.value.push(entry) } @@ -855,7 +1093,9 @@ export default function useCommunicationsEngine() { nextCandidates, activeFrequency, communicationLog: readonly(communicationLog), - clearCommunicationLog: () => { communicationLog.value = [] }, + clearCommunicationLog: () => { assignCommunicationLog([]) }, + activeFlow, + availableFlows, // pm_alt.vue integration flightContext: readonly(flightContext), @@ -865,7 +1105,9 @@ export default function useCommunicationsEngine() { initializeFlight, updateFrequencyVariables, loadRuntimeTree, + loadRuntimeSystem, fetchRuntimeTree, + setActiveFlow, isReady, // Communication From ba93c494c94e20d80bba3436192d55fb39232c90 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 21 Sep 2025 23:08:10 +0200 Subject: [PATCH 2/7] Add session timeline logging and admin sessions view --- app/pages/admin/index.vue | 908 +++++++----------- app/pages/pm.vue | 179 ++-- server/api/admin/logs/sessions.get.ts | 107 +++ .../admin/logs/sessions/[sessionId].get.ts | 85 ++ server/api/admin/logs/transmissions.get.ts | 2 + server/api/atc/ptt.post.ts | 19 +- server/api/atc/say.post.ts | 7 + server/api/editor/flows.post.ts | 12 + server/api/editor/flows/[slug]/index.put.ts | 15 + server/api/llm/decide.post.ts | 6 +- server/models/DecisionFlow.ts | 4 + server/models/TransmissionLog.ts | 2 + server/services/decisionFlowService.ts | 11 +- server/utils/openai.ts | 483 +++++++--- shared/types/decision.ts | 9 + shared/types/llm.ts | 91 +- shared/utils/communicationsEngine.ts | 165 +++- shared/utils/openaiDecision.ts | 6 +- 18 files changed, 1303 insertions(+), 808 deletions(-) create mode 100644 server/api/admin/logs/sessions.get.ts create mode 100644 server/api/admin/logs/sessions/[sessionId].get.ts diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue index 2ccbe6f..5dbc629 100644 --- a/app/pages/admin/index.vue +++ b/app/pages/admin/index.vue @@ -612,443 +612,223 @@
+ -
-
-
- - - - - - - Apply filters - -
-
- {{ logPagination.total }} entries · Page {{ logPagination.page }} of {{ logPagination.pages }} -
-
- - - {{ logError }} - - -
- -

Loading radio logs…

-
- -
-
- - -
-
- {{ entry.channel }} - {{ entry.direction }} - {{ entry.role }} - User: {{ entry.user.email }} -
- {{ formatDateTime(entry.createdAt) }} -
-

{{ entry.text }}

-

- Normalized: {{ entry.normalized }} -

-
- Module {{ entry.metadata.moduleId }} - Lesson {{ entry.metadata.lessonId }} - - Auto Decision: {{ entry.metadata.autoDecide ? 'Yes' : 'No' }} - -
-
- - {{ expandedLog === entry.id ? 'Close tracer' : 'Open tracer' }} - -
- -
-
-

LLM Decision Summary

-
-
-

Next State

-

{{ entry.metadata.decision.next_state || '—' }}

-
-
-

Controller

-

{{ entry.metadata.decision.controller_say_tpl || '—' }}

-
-
-
- - Off-script: {{ entry.metadata.decision.off_schema ? 'Yes' : 'No' }} - - - Radio check: {{ entry.metadata.decision.radio_check ? 'Yes' : 'No' }} - -
-
- -
-

State context snapshot

-
-
-
-

- {{ entry.metadata.context.stateId || 'Unknown state' }} -

-

- {{ entry.metadata.context.state?.name }} -

-
-
- - Role: {{ entry.metadata.context.state?.role }} - - - Phase: {{ entry.metadata.context.state?.phase }} - -
-
-
-

State Say Template

-

{{ entry.metadata.context.state?.say_tpl }}

-
-
-
-

Next transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-

OK transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-

Bad transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-
-

Candidates

-
-
- {{ candidate.id || 'unknown' }} - - Selected - -
-

- {{ candidate.state?.name }} -

-
- - Role: {{ candidate.state?.role }} - - - Requires ATC reply - -
-

- {{ candidate.state?.say_tpl }} -

-
-
-
-
-

Variables snapshot

-
{{ formatJson(entry.metadata.context.variables) }}
-
-
-

Flags snapshot

-
{{ formatJson(entry.metadata.context.flags) }}
-
-
-
-
-
-

OpenAI Decision Calls

-
-
- - Step: {{ call.stage === 'decision' ? 'Decision' : 'Readback check' }} - - Call failed -
-
-

Request

-
{{ formatJson(call.request) }}
-
-
-

Response

-
{{ formatJson(call.response) }}
-
-
- No response received. -
-
-

Raw Response

-
{{ call.rawResponseText }}
-
- - {{ call.error }} - -
-
-

Fallback activated

-

- Reason: {{ entry.metadata.decisionTrace.fallback.reason || 'unknown' }} - - · Path: {{ entry.metadata.decisionTrace.fallback.selected }} - -

-
-
- -
-

Say endpoint invocation

-
-
-

Voice

-

{{ entry.metadata?.voice || '—' }}

-
-
-

Signal level

-

{{ entry.metadata?.level ?? '—' }}

-
-
-

Speech speed

-

{{ entry.metadata?.speed ?? '—' }}

-
-
-

Radio quality

-

{{ entry.metadata?.radioQuality || '—' }}

-
-
-
-
-

TTS provider

-

{{ entry.metadata?.tts?.provider || '—' }}

-
-
-

Model

-

{{ entry.metadata?.tts?.model || '—' }}

-
-
-

Format

-

- {{ entry.metadata?.tts?.format || '—' }} - - ({{ entry.metadata?.tts?.extension }}) - -

-
-
-
-

Tag

-

{{ entry.metadata?.tag }}

-
-
-
-

Metadata

-
{{ formatJson(entry.metadata) }}
-
-
-
-
-
-
-

No transmissions found.

- -
-
- Page {{ logPagination.page }} of {{ logPagination.pages }} · {{ logPagination.total }} logs +
+
+
+
+

Sessions

+

{{ sessionPagination.total }} conversations

- Back + Prev Next
+ + + {{ sessionsError }} + + +
+ + Loading sessions… +
+ +
+ + +
+ {{ session.callsign || 'Unknown' }} + {{ formatRelative(session.updatedAt || undefined) }} +
+

Session {{ session.sessionId }}

+
+ {{ session.entryCount }} entries + {{ formatDateTime(session.startedAt || undefined) }} +
+

+ {{ session.lastMessage.role.toUpperCase() }} · {{ session.lastMessage.text }} +

+
+
+ +

+ No sessions recorded yet. +

+
+
+ +
+
+ + Loading session details… +
+ + + {{ sessionDetailError }} + + +
+
+
+
+

Session

+

{{ sessionDetails.sessionId }}

+
+
+ {{ sessionDetails.entryCount }} entries + {{ formatDateTime(sessionDetails.startedAt || undefined) }} + + {{ formatDateTime(sessionDetails.updatedAt || undefined) }} +
+
+

Callsign: {{ sessionDetails.callsign || 'Unknown' }}

+
+ +
+
+
+ + {{ entry.channel }} + {{ entry.role }} + {{ entry.direction }} + + {{ formatDateTime(entry.createdAt) }} +
+

{{ entry.text }}

+

{{ entry.normalized }}

+
+ User: {{ entry.user.email }} + + {{ expandedEntryId === entry.id ? 'Hide decision' : 'Show decision' }} + +
+ +
+
+

Decision trace

+
+ Next: {{ entry.metadata.decision.next_state }} + + Flow: + {{ typeof entry.metadata.decision.activate_flow === 'string' + ? entry.metadata.decision.activate_flow + : entry.metadata.decision.activate_flow.slug }} + +
+
+
+
+
+
+

{{ step.label }}

+

{{ step.stage }}

+
+ {{ step.candidates.length }} candidates +
+

{{ step.note }}

+
+
+
+ {{ candidate.id }} + {{ candidate.flow || 'current' }} +
+

{{ candidate.summary }}

+
+
+
+

Eliminated

+
+
+ {{ elim.candidate.id }} + {{ elim.kind }} +
+

{{ elim.reason }}

+

{{ describeElimination(elim) }}

+
+
+
+
+

No candidate timeline recorded.

+
+
+
+
+ +

+ No messages recorded for this session. +

+
+ +

Select a session to view its transcript.

+
@@ -1252,15 +1032,6 @@ interface TransmissionMetadata { [key: string]: any } -interface LlmUsageSummary { - method: string - openaiUsed: boolean - callCount: number - fallbackUsed: boolean - autoDecide?: boolean - reason?: string -} - interface TransmissionEntry { id: string role: string @@ -1271,13 +1042,37 @@ interface TransmissionEntry { createdAt: string metadata?: TransmissionMetadata user?: TransmissionUser + sessionId?: string } -interface LogsResponse { - items: TransmissionEntry[] +interface SessionSummary { + sessionId: string + startedAt: string | null + updatedAt: string | null + entryCount: number + callsign?: string + lastMessage?: { + text: string + role: string + channel: string + createdAt: string + } +} + +interface SessionsResponse { + items: SessionSummary[] pagination: { total: number; page: number; pageSize: number; pages: number } } +interface SessionDetail { + sessionId: string + startedAt: string | null + updatedAt: string | null + entryCount: number + callsign?: string + entries: TransmissionEntry[] +} + interface WaitlistEntryItem { id: string email: string @@ -1387,38 +1182,16 @@ const waitlistStatusOptions = [ ] const waitlistStats = reactive({ total: 0, updates: 0, activated: 0, pending: 0 }) -const logs = ref([]) -const logPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 }) -const logLoading = ref(false) -const logError = ref('') -const logSearch = ref('') -const logChannel = ref<'all' | 'ptt' | 'say' | 'text'>('all') -const logDirection = ref<'all' | 'incoming' | 'outgoing'>('all') -const logRole = ref<'all' | 'pilot' | 'atc'>('all') -const logTimeframe = ref<'24h' | '7d' | '30d' | 'all'>('24h') -const logChannelOptions = [ - { title: 'All channels', value: 'all' }, - { title: 'PTT', value: 'ptt' }, - { title: 'Say', value: 'say' }, - { title: 'Text', value: 'text' }, -] -const logDirectionOptions = [ - { title: 'All directions', value: 'all' }, - { title: 'Incoming', value: 'incoming' }, - { title: 'Outgoing', value: 'outgoing' }, -] -const logRoleOptions = [ - { title: 'All roles', value: 'all' }, - { title: 'Pilot', value: 'pilot' }, - { title: 'ATC', value: 'atc' }, -] -const logTimeframeOptions = [ - { title: 'Last 24 hours', value: '24h' }, - { title: '7 days', value: '7d' }, - { title: '30 days', value: '30d' }, - { title: 'All time', value: 'all' }, -] -const expandedLog = ref(null) +const sessions = ref([]) +const sessionsLoading = ref(false) +const sessionsError = ref('') +const sessionsLoaded = ref(false) +const sessionPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 10 }) +const selectedSessionId = ref(null) +const sessionDetails = ref(null) +const sessionDetailLoading = ref(false) +const sessionDetailError = ref('') +const expandedEntryId = ref(null) const showCreateInvite = ref(false) const newInviteLabel = ref('') @@ -1430,12 +1203,11 @@ const createInviteResult = ref<{ code: string; expiresAt?: string } | null>(null const usersLoaded = ref(false) const invitationsLoaded = ref(false) const waitlistLoaded = ref(false) -const logsLoaded = ref(false) let userSearchTimeout: ReturnType | undefined let invitationSearchTimeout: ReturnType | undefined -let logSearchTimeout: ReturnType | undefined let waitlistSearchTimeout: ReturnType | undefined +let activeSessionDetailRequest: string | null = null function extractErrorMessage(error: any, fallback: string) { return ( @@ -1507,69 +1279,6 @@ function describeTransition(transition: any) { return details.length ? `${destination} (${details.join(', ')})` : destination } -function buildLlmUsage(entry: TransmissionEntry): LlmUsageSummary | null { - const metadata = entry.metadata - if (!metadata) return null - - const hasDecisionData = - metadata.autoDecide !== undefined || - Boolean(metadata.llm) || - Boolean(metadata.decisionTrace?.calls?.length) || - Boolean(metadata.decisionTrace?.fallback?.used) - - if (!hasDecisionData) return null - - const autoDecide = metadata.llm?.autoDecide ?? metadata.autoDecide - const callCount = metadata.llm?.callCount ?? metadata.decisionTrace?.calls?.length ?? 0 - const fallbackUsed = metadata.llm?.fallbackUsed ?? Boolean(metadata.decisionTrace?.fallback?.used) - const openaiUsed = metadata.llm?.openaiUsed ?? callCount > 0 - - const strategy = - metadata.llm?.strategy || - (!autoDecide - ? 'manual' - : openaiUsed - ? 'openai' - : fallbackUsed - ? 'fallback' - : 'heuristic') - - let method: string - switch (strategy) { - case 'openai': - method = `OpenAI decision (${callCount} ${callCount === 1 ? 'call' : 'calls'})` - break - case 'fallback': - method = 'Fallback decision after OpenAI error' - break - case 'manual': - method = 'Manual routing (auto decision disabled)' - break - default: - method = 'Heuristic decision (no OpenAI call)' - break - } - - const reason = - metadata.llm?.reason || - (strategy === 'openai' - ? `Decision derived from OpenAI with ${callCount} ${callCount === 1 ? 'call' : 'calls'}.` - : strategy === 'fallback' - ? metadata.decisionTrace?.fallback?.reason || 'Fallback executed because OpenAI response could not be used.' - : strategy === 'manual' - ? 'Automatic decision was disabled for this transmission.' - : 'Rules and heuristics resolved the decision without contacting OpenAI.') - - return { - method, - openaiUsed, - callCount, - fallbackUsed, - autoDecide, - reason, - } -} - function isExpired(expiresAt?: string) { if (!expiresAt) return false const date = new Date(expiresAt) @@ -1583,14 +1292,6 @@ function invitationStatusLabel(inv: InvitationItem) { return 'active' } -function computeSince(timeframe: '24h' | '7d' | '30d' | 'all') { - const now = Date.now() - if (timeframe === '24h') return new Date(now - 24 * 60 * 60 * 1000) - if (timeframe === '7d') return new Date(now - 7 * 24 * 60 * 60 * 1000) - if (timeframe === '30d') return new Date(now - 30 * 24 * 60 * 60 * 1000) - return null -} - async function loadOverview(force = false) { if (overview.value && !force) return overviewLoading.value = true @@ -1727,48 +1428,107 @@ function changeWaitlistPage(page: number) { fetchWaitlist() } -function computeLogQuery() { - const query: Record = { - page: logPagination.page, - pageSize: logPagination.pageSize, - } - if (logSearch.value.trim()) query.search = logSearch.value.trim() - if (logChannel.value !== 'all') query.channel = logChannel.value - if (logDirection.value !== 'all') query.direction = logDirection.value - if (logRole.value !== 'all') query.role = logRole.value - const since = computeSince(logTimeframe.value) - if (since) query.since = since.toISOString() - return query -} - -async function fetchLogs(resetPage = false) { +async function fetchSessions(resetPage = false, options: { forceDetail?: boolean } = {}) { if (resetPage) { - logPagination.page = 1 + sessionPagination.page = 1 } - logLoading.value = true - logError.value = '' + + sessionsLoading.value = true + sessionsError.value = '' try { - const response = await api.get('/api/admin/logs/transmissions', { - query: computeLogQuery(), - }) - logs.value = response.items - Object.assign(logPagination, response.pagination) - logsLoaded.value = true + const query: Record = { + page: sessionPagination.page, + pageSize: sessionPagination.pageSize, + } + const response = await api.get('/api/admin/logs/sessions', { query }) + sessions.value = response.items + Object.assign(sessionPagination, response.pagination) + sessionsLoaded.value = true + + if (!response.items.length) { + sessionDetails.value = null + selectedSessionId.value = null + expandedEntryId.value = null + sessionDetailError.value = '' + sessionDetailLoading.value = false + activeSessionDetailRequest = null + return + } + + const hasSelected = selectedSessionId.value + ? response.items.some((item) => item.sessionId === selectedSessionId.value) + : false + + if (!hasSelected) { + await selectSession(response.items[0].sessionId, { force: true }) + } else if (options.forceDetail && selectedSessionId.value) { + await selectSession(selectedSessionId.value, { force: true }) + } } catch (error) { - logError.value = extractErrorMessage(error, 'Could not load radio logs.') + sessionsError.value = extractErrorMessage(error, 'Could not load sessions.') } finally { - logLoading.value = false + sessionsLoading.value = false } } -function changeLogPage(page: number) { - if (page < 1 || page > logPagination.pages) return - logPagination.page = page - fetchLogs() +function changeSessionPage(page: number) { + if (page < 1 || page > sessionPagination.pages || page === sessionPagination.page) return + sessionPagination.page = page + fetchSessions() } -function toggleLog(id: string) { - expandedLog.value = expandedLog.value === id ? null : id +async function fetchSessionDetail(sessionId: string) { + if (!sessionId) { + sessionDetails.value = null + return + } + + const requestId = sessionId + activeSessionDetailRequest = requestId + sessionDetailLoading.value = true + sessionDetailError.value = '' + + try { + const detail = await api.get(`/api/admin/logs/sessions/${sessionId}`) + if (activeSessionDetailRequest !== requestId) { + return + } + sessionDetails.value = detail + } catch (error) { + if (activeSessionDetailRequest !== requestId) { + return + } + sessionDetailError.value = extractErrorMessage(error, 'Could not load session details.') + sessionDetails.value = null + } finally { + if (activeSessionDetailRequest === requestId) { + sessionDetailLoading.value = false + activeSessionDetailRequest = null + } + } +} + +async function selectSession(sessionId: string | null, options: { force?: boolean } = {}) { + if (!sessionId) { + selectedSessionId.value = null + sessionDetails.value = null + sessionDetailError.value = '' + expandedEntryId.value = null + sessionDetailLoading.value = false + activeSessionDetailRequest = null + return + } + + if (!options.force && selectedSessionId.value === sessionId && sessionDetails.value) { + expandedEntryId.value = null + return + } + + selectedSessionId.value = sessionId + sessionDetails.value = null + sessionDetailError.value = '' + expandedEntryId.value = null + await fetchSessionDetail(sessionId) } async function submitCreateInvite() { @@ -1812,7 +1572,7 @@ async function refreshActiveTab() { } else if (activeTab.value === 'waitlist') { await fetchWaitlist(true) } else { - await fetchLogs(true) + await fetchSessions(true, { forceDetail: true }) } } finally { refreshing.value = false @@ -1824,11 +1584,6 @@ watch(invitationStatus, () => fetchInvitations(true)) watch(invitationChannel, () => fetchInvitations(true)) watch(waitlistSubscription, () => fetchWaitlist(true)) watch(waitlistStatus, () => fetchWaitlist(true)) -watch(logChannel, () => fetchLogs(true)) -watch(logDirection, () => fetchLogs(true)) -watch(logRole, () => fetchLogs(true)) -watch(logTimeframe, () => fetchLogs(true)) - watch(userSearch, () => { if (userSearchTimeout) clearTimeout(userSearchTimeout) userSearchTimeout = setTimeout(() => fetchUsers(true), 400) @@ -1844,11 +1599,6 @@ watch(waitlistSearch, () => { waitlistSearchTimeout = setTimeout(() => fetchWaitlist(true), 400) }) -watch(logSearch, () => { - if (logSearchTimeout) clearTimeout(logSearchTimeout) - logSearchTimeout = setTimeout(() => fetchLogs(true), 400) -}) - watch(activeTab, (tab) => { if (tab === 'overview') { loadOverview() @@ -1858,8 +1608,8 @@ watch(activeTab, (tab) => { fetchInvitations(true) } else if (tab === 'waitlist' && !waitlistLoaded.value) { fetchWaitlist(true) - } else if (tab === 'logs' && !logsLoaded.value) { - fetchLogs(true) + } else if (tab === 'logs' && !sessionsLoaded.value) { + fetchSessions(true) } }) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 48162ed..b777089 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -9,26 +9,19 @@

Alpha Build • Decision Tree • VATSIM

-
- +
+ {{ currentState?.id || 'INIT' }}
{{ currentState?.phase || 'Setup' }}
+
+ {{ activeFlowInfo.name }} + {{ activeFlowInfo.modeLabel }} +
+

+ {{ activeFlowInfo.description }} +

-
@@ -617,6 +610,21 @@ LLM
+
+ Session: {{ sessionLabel }} +
+ + Auto: {{ traceAutoSelection.id }} + + + Fallback candidates + + + Fallback: {{ traceFallback.reason || 'triggered' }} + +
+
+

Current node

{{ debugState?.id || '—' }}

@@ -668,6 +676,57 @@

No further decisions available.

+ +
+

Decision timeline

+
+
+
+
+

{{ step.label }}

+

{{ step.stage }}

+
+ + {{ step.candidates.length }} candidates + +
+

{{ step.note }}

+
+
+
+ {{ candidate.id }} + {{ candidate.flow || 'current' }} +
+

{{ candidate.summary }}

+
+
+
+

Eliminated

+
+
+ {{ elim.candidate.id }} + {{ elim.kind }} +
+

{{ elim.reason }}

+

{{ describeElimination(elim) }}

+
+
+
+
+

No decision timeline available yet.

+
@@ -979,10 +1038,11 @@ const { currentStep, availableFlows, activeFlow, + sessionId: engineSessionId, + lastDecisionTrace, initializeFlight, updateFrequencyVariables, fetchRuntimeTree, - setActiveFlow, isReady: engineReady, processPilotTransmission, buildLLMContext, @@ -1050,39 +1110,49 @@ const clearLog = () => { clearLastTransmission() } -const selectedFlowSlug = ref('') -const flowOptions = computed(() => - availableFlows.value.map((flow) => ({ - title: flow.name, - value: flow.slug, - subtitle: flow.description, - })) -) - -watch( - activeFlow, - (slug) => { - selectedFlowSlug.value = slug || '' - }, - { immediate: true } -) - -watch(selectedFlowSlug, (slug, previous) => { - if (!slug || slug === activeFlow.value || slug === previous) { - return - } - handleFlowChange(slug) +const activeFlowInfo = computed(() => { + const slug = activeFlow.value + const flows = availableFlows.value + const entry = (slug ? flows.find((flow) => flow.slug === slug) : undefined) || flows.find((flow) => flow.mode === 'main') || flows[0] + const resolvedSlug = entry?.slug || slug || '' + const name = entry?.name || resolvedSlug || 'Main Flow' + const description = entry?.description || '' + const mode = entry?.mode || (resolvedSlug && resolvedSlug === slug ? 'parallel' : 'parallel') + const modeLabel = mode === 'main' ? 'Main' : mode === 'linear' ? 'Linear' : 'Parallel' + return { slug: resolvedSlug, name, description, mode, modeLabel } }) -function handleFlowChange(slug: string) { - if (!slug || slug === activeFlow.value) { - return +const decisionTrace = computed(() => lastDecisionTrace.value) +const timelineSteps = computed(() => decisionTrace.value?.candidateTimeline?.steps ?? []) +const timelineUsedFallback = computed(() => Boolean(decisionTrace.value?.candidateTimeline?.fallbackUsed)) +const traceAutoSelection = computed(() => decisionTrace.value?.autoSelection ?? null) +const traceFallback = computed(() => decisionTrace.value?.fallback ?? null) +const sessionLabel = computed(() => engineSessionId.value || flags.session_id || '-') + +function describeElimination(entry: any): string { + if (!entry || typeof entry !== 'object') { + return '' } - try { - setActiveFlow(slug) - } catch (error) { - console.error('Failed to activate flow', error) + if (entry.kind === 'regex' && entry.context?.patterns?.length) { + const patterns = entry.context.patterns + .map((pattern: any) => pattern?.pattern) + .filter((value: string | undefined) => Boolean(value)) + .join(', ') + return patterns ? `Patterns: ${patterns}` : entry.reason } + if (entry.kind === 'condition' && entry.context?.condition) { + const condition = entry.context.condition + if (condition.type === 'regex' || condition.type === 'regex_not') { + const flag = condition.pattern ? `/${condition.pattern}/${condition.patternFlags || 'i'}` : '' + return flag ? `Condition: ${condition.type} ${flag}` : entry.reason + } + const variable = condition.variable || 'value' + const operator = condition.operator || '==' + const expected = entry.context?.expectedValue ?? condition.value ?? '—' + const actual = entry.context?.actualValue ?? '—' + return `${variable} ${operator} ${expected} (actual: ${actual})` + } + return entry.reason } // UI State @@ -1538,7 +1608,8 @@ const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions = speed, moduleId: 'pilot-monitoring', lessonId: currentState.value?.id || 'general', - tag: options.tag || 'controller-reply' + tag: options.tag || 'controller-reply', + sessionId: engineSessionId.value || flags.session_id || undefined, }) if (response.success && response.audio) { @@ -1581,7 +1652,8 @@ const speakPlainText = (text: string, options: SpeechOptions = {}) => { speed, moduleId: 'pilot-monitoring', lessonId, - tag: options.tag || 'announcement' + tag: options.tag || 'announcement', + sessionId: engineSessionId.value || flags.session_id || undefined, }) if (response.success && response.audio) { @@ -1624,23 +1696,18 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot' setLastTransmission(`${prefix}: ${transcript}`) - const quickResponse = processPilotTransmission(transcript) + processPilotTransmission(transcript) if (readbackEnabled.value) { speakPilotReadback(transcript) } - if (quickResponse) { - scheduleControllerSpeech(quickResponse) - return - } - const ctx = buildLLMContext(transcript) try { - const decision = await api.post('/api/llm/decide', ctx) + const { decision, trace } = await api.post('/api/llm/decide', ctx) - applyLLMDecision(decision) + applyLLMDecision(decision, trace) if (decision.controller_say_tpl && !decision.radio_check) { scheduleControllerSpeech(decision.controller_say_tpl) diff --git a/server/api/admin/logs/sessions.get.ts b/server/api/admin/logs/sessions.get.ts new file mode 100644 index 0000000..adec230 --- /dev/null +++ b/server/api/admin/logs/sessions.get.ts @@ -0,0 +1,107 @@ +import { defineEventHandler, getQuery } from 'h3' +import { requireAdmin } from '../../../utils/auth' +import { TransmissionLog } from '../../../models/TransmissionLog' + +interface SessionSummaryEntry { + sessionId: string + startedAt: string | null + updatedAt: string | null + entryCount: number + callsign?: string + lastMessage?: { + text: string + role: string + channel: string + createdAt: string + } +} + +function toISO(value: any): string | null { + if (!value) { + return null + } + const date = new Date(value) + return Number.isNaN(date.valueOf()) ? null : date.toISOString() +} + +function extractCallsign(entry: any): string | undefined { + const fromMetadata = (payload: any) => + payload?.metadata?.context?.variables?.callsign || + payload?.metadata?.context?.variables?.CALLSIGN || + payload?.metadata?.context?.variables?.Callsign + + return ( + fromMetadata(entry.lastEntry) || + fromMetadata(entry.firstEntry) || + entry.lastEntry?.metadata?.variables?.callsign || + entry.firstEntry?.metadata?.variables?.callsign || + undefined + ) +} + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const query = getQuery(event) + const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1) + const pageSizeRaw = parseInt(String(query.pageSize ?? query.limit ?? '20'), 10) + const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100) + const skip = (page - 1) * pageSize + + const matchStage = { sessionId: { $exists: true, $nin: [null, ''] } } + + const [sessionDocs, totalCountAgg] = await Promise.all([ + TransmissionLog.aggregate([ + { $match: matchStage }, + { $sort: { sessionId: 1, createdAt: 1 } }, + { + $group: { + _id: '$sessionId', + firstEntry: { $first: '$$ROOT' }, + lastEntry: { $last: '$$ROOT' }, + count: { $sum: 1 }, + }, + }, + { $sort: { 'lastEntry.createdAt': -1 } }, + { $skip: skip }, + { $limit: pageSize }, + ]).exec(), + TransmissionLog.aggregate([ + { $match: matchStage }, + { $group: { _id: '$sessionId' } }, + { $count: 'count' }, + ]).exec(), + ]) + + const total = totalCountAgg?.[0]?.count || 0 + + const items: SessionSummaryEntry[] = sessionDocs.map((entry: any) => { + const first = entry.firstEntry || {} + const last = entry.lastEntry || {} + return { + sessionId: entry._id, + startedAt: toISO(first.createdAt), + updatedAt: toISO(last.createdAt), + entryCount: entry.count || 0, + callsign: extractCallsign(entry), + lastMessage: last.text + ? { + text: last.text, + role: last.role, + channel: last.channel, + createdAt: toISO(last.createdAt) || new Date().toISOString(), + } + : undefined, + } + }) + + return { + items, + pagination: { + total, + page, + pageSize, + pages: Math.ceil(total / pageSize) || 1, + }, + } +}) diff --git a/server/api/admin/logs/sessions/[sessionId].get.ts b/server/api/admin/logs/sessions/[sessionId].get.ts new file mode 100644 index 0000000..4cd3f73 --- /dev/null +++ b/server/api/admin/logs/sessions/[sessionId].get.ts @@ -0,0 +1,85 @@ +import { defineEventHandler, createError } from 'h3' +import { requireAdmin } from '../../../../utils/auth' +import { TransmissionLog } from '../../../../models/TransmissionLog' + +function toISO(value: any): string | null { + if (!value) { + return null + } + const date = new Date(value) + return Number.isNaN(date.valueOf()) ? null : date.toISOString() +} + +function mapEntry(doc: any) { + return { + id: String(doc._id), + role: doc.role, + channel: doc.channel, + direction: doc.direction, + text: doc.text, + normalized: doc.normalized || undefined, + createdAt: toISO(doc.createdAt) || new Date().toISOString(), + metadata: doc.metadata || undefined, + sessionId: doc.sessionId || undefined, + user: doc.user + ? { + id: String(doc.user._id), + email: doc.user.email, + name: doc.user.name || undefined, + role: doc.user.role, + } + : undefined, + } +} + +function extractCallsign(entries: any[]): string | undefined { + for (const entry of [...entries].reverse()) { + const callsign = + entry.metadata?.context?.variables?.callsign || + entry.metadata?.context?.variables?.CALLSIGN || + entry.metadata?.variables?.callsign + if (callsign) { + return callsign + } + } + return undefined +} + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + const sessionId = event.context.params?.sessionId + + if (!sessionId || typeof sessionId !== 'string') { + throw createError({ statusCode: 400, statusMessage: 'Session ID is required' }) + } + + const items = await TransmissionLog.find({ sessionId }) + .sort({ createdAt: 1 }) + .populate('user', 'email name role') + .lean() + .exec() + + if (!items.length) { + return { + sessionId, + startedAt: null, + updatedAt: null, + entryCount: 0, + callsign: undefined, + entries: [], + } + } + + const startedAt = toISO(items[0].createdAt) + const updatedAt = toISO(items[items.length - 1].createdAt) + const callsign = extractCallsign(items) + + return { + sessionId, + startedAt, + updatedAt, + entryCount: items.length, + callsign, + entries: items.map(mapEntry), + } +}) diff --git a/server/api/admin/logs/transmissions.get.ts b/server/api/admin/logs/transmissions.get.ts index 822417e..64e6950 100644 --- a/server/api/admin/logs/transmissions.get.ts +++ b/server/api/admin/logs/transmissions.get.ts @@ -17,6 +17,7 @@ type TransmissionListItem = { createdAt: string metadata?: Record user?: { id: string; email: string; name?: string; role: string } + sessionId?: string } function escapeRegExp(input: string) { @@ -33,6 +34,7 @@ function mapTransmission(doc: any): TransmissionListItem { normalized: doc.normalized || undefined, createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(), metadata: doc.metadata || undefined, + sessionId: doc.sessionId || undefined, user: doc.user ? { id: String(doc.user._id), diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 7ff4692..2d01093 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -31,14 +31,8 @@ interface PTTRequest { interface PTTResponse { success: boolean; transcription: string; - decision?: { - next_state: string; - controller_say_tpl?: string; - off_schema?: boolean; - radio_check?: boolean; - activate_flow?: string; - resume_previous?: boolean; - }; + decision?: LLMDecisionResult['decision']; + trace?: LLMDecisionResult['trace']; } async function sh(cmd: string, args: string[]) { @@ -228,6 +222,7 @@ export default defineEventHandler(async (event) => { return { id: candidate.id, + flow: candidate.flow || undefined, state: candidateState }; }) @@ -235,12 +230,17 @@ export default defineEventHandler(async (event) => { const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state); + const sessionId = typeof body.context?.flags?.session_id === 'string' + ? body.context.flags.session_id + : undefined; + await TransmissionLog.create({ user: user?._id, role: "pilot", channel: "ptt", direction: "incoming", text: transcribedText, + sessionId, metadata: { moduleId: body.moduleId, lessonId: body.lessonId, @@ -270,6 +270,9 @@ export default defineEventHandler(async (event) => { if (decision) { result.decision = decision; } + if (decisionResult?.trace) { + result.trace = decisionResult.trace; + } return result; diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index aa26450..afa5ca3 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -123,10 +123,16 @@ export default defineEventHandler(async (event) => { lessonId?: string; tag?: string; format?: AudioFmt | "smallest"; + sessionId?: string; }>(event); const user = await requireUserSession(event); + const rawSessionId = typeof body?.sessionId === "string" + ? body.sessionId.trim() + : ""; + const sessionId = rawSessionId.length ? rawSessionId : undefined; + const raw = (body?.text || "").trim(); if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" }); @@ -226,6 +232,7 @@ export default defineEventHandler(async (event) => { direction: "outgoing", text: raw, normalized, + sessionId, metadata: { level, voice, diff --git a/server/api/editor/flows.post.ts b/server/api/editor/flows.post.ts index 315431d..7260eca 100644 --- a/server/api/editor/flows.post.ts +++ b/server/api/editor/flows.post.ts @@ -43,6 +43,9 @@ export default defineEventHandler(async (event) => { .filter((phase: string) => phase.length) : [] + const entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel' + const isMain = body.isMain === true + const endStates = Array.isArray(body.endStates) ? body.endStates .map((state: any) => (typeof state === 'string' ? state.trim() : '')) @@ -62,10 +65,19 @@ export default defineEventHandler(async (event) => { hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {}, roles, phases, + entryMode, + isMain, }) await flow.save() + if (isMain) { + await DecisionFlow.updateMany( + { _id: { $ne: flow._id } }, + { $set: { isMain: false } } + ) + } + const { flow: serialized } = await getFlowWithNodes(slug) return serialized }) diff --git a/server/api/editor/flows/[slug]/index.put.ts b/server/api/editor/flows/[slug]/index.put.ts index a9326c8..d202e62 100644 --- a/server/api/editor/flows/[slug]/index.put.ts +++ b/server/api/editor/flows/[slug]/index.put.ts @@ -59,6 +59,14 @@ export default defineEventHandler(async (event) => { flow.phases = phases } + if (typeof body.entryMode === 'string') { + flow.entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel' + } + + if (typeof body.isMain === 'boolean') { + flow.isMain = body.isMain + } + if (body.variables && typeof body.variables === 'object') { flow.variables = body.variables flow.markModified('variables') @@ -145,6 +153,13 @@ export default defineEventHandler(async (event) => { await flow.save() + if (flow.isMain) { + await DecisionFlow.updateMany( + { _id: { $ne: flow._id } }, + { $set: { isMain: false } } + ) + } + const data = await getFlowWithNodes(slug) return data }) diff --git a/server/api/llm/decide.post.ts b/server/api/llm/decide.post.ts index c004539..93e8dce 100644 --- a/server/api/llm/decide.post.ts +++ b/server/api/llm/decide.post.ts @@ -13,9 +13,9 @@ export default defineEventHandler(async (event) => { } try { - const { decision, trace } = await routeDecision(body) + const result = await routeDecision(body) + const { decision, trace } = result - // Log for debugging when off-schema or radio check triggers if (decision.off_schema) { console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`) } @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => { console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)') } - return decision + return result } catch (err: any) { console.error('Router failed:', err) throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' }) diff --git a/server/models/DecisionFlow.ts b/server/models/DecisionFlow.ts index 7e06b9c..a97cff6 100644 --- a/server/models/DecisionFlow.ts +++ b/server/models/DecisionFlow.ts @@ -19,6 +19,8 @@ export interface DecisionFlowDocument extends mongoose.Document { phases: string[] layout?: DecisionFlowLayout metadata?: DecisionFlowMetadata + entryMode?: 'parallel' | 'linear' + isMain?: boolean createdAt: Date updatedAt: Date } @@ -37,6 +39,8 @@ const decisionFlowSchema = new mongoose.Schema( hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, roles: { type: [String], default: () => [] }, phases: { type: [String], default: () => [] }, + entryMode: { type: String, enum: ['parallel', 'linear'], default: 'parallel' }, + isMain: { type: Boolean, default: false }, layout: { type: new mongoose.Schema( { diff --git a/server/models/TransmissionLog.ts b/server/models/TransmissionLog.ts index 7ea3e2d..922fe6c 100644 --- a/server/models/TransmissionLog.ts +++ b/server/models/TransmissionLog.ts @@ -10,6 +10,7 @@ export interface TransmissionLogDocument extends mongoose.Document { text: string normalized?: string metadata?: Record + sessionId?: string createdAt: Date } @@ -21,6 +22,7 @@ const transmissionSchema = new mongoose.Schema({ text: { type: String, required: true }, normalized: { type: String }, metadata: { type: Schema.Types.Mixed }, + sessionId: { type: String, index: true }, createdAt: { type: Date, default: () => new Date() }, }) diff --git a/server/services/decisionFlowService.ts b/server/services/decisionFlowService.ts index def3b66..530afb1 100644 --- a/server/services/decisionFlowService.ts +++ b/server/services/decisionFlowService.ts @@ -32,6 +32,8 @@ export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(), updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(), nodeCount, + entryMode: doc.entryMode || 'parallel', + isMain: doc.isMain || false, } } @@ -91,6 +93,8 @@ export async function listDecisionFlows(): Promise { nodeCount: countMap[String(flow._id)] || 0, updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(), createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(), + entryMode: flow.entryMode || 'parallel', + isMain: Boolean(flow.isMain), })) } @@ -157,6 +161,8 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState return { role: obj.role as any, phase: obj.phase, + name: obj.title || undefined, + summary: obj.summary || undefined, say_tpl: obj.sayTemplate || undefined, utterance_tpl: obj.utteranceTemplate || undefined, else_say_tpl: obj.elseSayTemplate || undefined, @@ -203,6 +209,7 @@ async function buildRuntimeTreeForDoc( roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [], phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [], states, + entry_mode: flowDoc.isMain ? 'main' : flowDoc.entryMode || 'parallel', } } @@ -245,7 +252,9 @@ export async function buildRuntimeDecisionSystem(): Promise tree.slug) - const main = flows['icao_atc_decision_tree'] ? 'icao_atc_decision_tree' : order[0] + const preferredMain = flowDocs.find((doc) => doc.isMain)?.slug + const fallbackMain = flowDocs.find((doc) => doc.slug === 'icao_atc_decision_tree')?.slug + const main = preferredMain || fallbackMain || order[0] return { main, diff --git a/server/utils/openai.ts b/server/utils/openai.ts index a565063..a7ec3dd 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,7 +1,17 @@ // server/utils/openai.ts import OpenAI from 'openai' import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' -import type {LLMDecision, LLMDecisionInput} from '../../shared/types/llm' +import type { + CandidateTraceEntry, + CandidateTraceStep, + DecisionCandidateTimeline, + FlowActivationInstruction, + FlowActivationMode, + LLMDecision, + LLMDecisionInput, + LLMDecisionTrace, + LLMDecisionTraceCall, +} from '../../shared/types/llm' import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision' import { buildRuntimeDecisionSystem } from '../services/decisionFlowService' import {getServerRuntimeConfig} from './runtimeConfig' @@ -64,23 +74,6 @@ export async function decide(system: string, user: string): Promise { return r.choices?.[0]?.message?.content?.trim() || '' } -export interface LLMDecisionTraceCall { - stage: 'readback-check' | 'decision' - request: Record - response?: any - rawResponseText?: string - error?: string -} - -export interface LLMDecisionTrace { - calls: LLMDecisionTraceCall[] - fallback?: { - used: boolean - reason?: string - selected?: string - } -} - export interface LLMDecisionResult { decision: LLMDecision trace?: LLMDecisionTrace @@ -211,12 +204,18 @@ interface DecisionCandidate { id: string flow: string state: RuntimeDecisionState + triggers: DecisionNodeTrigger[] + regexTriggers: DecisionNodeTrigger[] + noneTriggers: DecisionNodeTrigger[] } interface PreparedCandidateResult { - filteredCandidates: DecisionCandidate[] + finalCandidates: DecisionCandidate[] candidateFlowMap: Map activeFlowSlug: string + flowEntryModes: Map + timeline: DecisionCandidateTimeline + autoSelected?: DecisionCandidate | null } const RUNTIME_CACHE_TTL_MS = 5_000 @@ -322,34 +321,48 @@ function parseComparable(raw: any): any { return raw } -function compareValuesSafe(left: any, operator: string | undefined, right: any): boolean { +function compareValuesSafe(left: any, operator: string | undefined, right: any): { + result: boolean + left: any + right: any + operator: string +} { const normalizedLeft = normalizeComparable(left) const normalizedRight = normalizeComparable(parseComparable(right)) - switch (operator) { + const op = operator || '==' + let result = false + switch (op) { case '>': - return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' ? normalizedLeft > normalizedRight : false + break case '>=': - return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' ? normalizedLeft >= normalizedRight : false + break case '<': - return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' ? normalizedLeft < normalizedRight : false + break case '<=': - return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' ? normalizedLeft <= normalizedRight : false + break case '!==': case '!=': - return normalizedLeft !== normalizedRight + result = normalizedLeft !== normalizedRight + break case '===': case '==': default: - return normalizedLeft === normalizedRight + result = normalizedLeft === normalizedRight + break } + return { result, left: normalizedLeft, right: normalizedRight, operator: op } } function resolveContextPath( @@ -380,18 +393,39 @@ function evaluateConditionEntry( condition: DecisionNodeCondition | undefined, context: { variables: Record; flags: Record }, utterance: string -): boolean { - if (!condition) return true +): { passed: boolean; detail?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { + if (!condition) return { passed: true } switch (condition.type) { - case 'regex': - return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) - case 'regex_not': - return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) + case 'regex': { + const passed = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) + return { + passed, + detail: passed ? undefined : { condition }, + } + } + case 'regex_not': { + const matched = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) + const passed = !matched + return { + passed, + detail: passed ? undefined : { condition }, + } + } case 'variable_value': default: { const left = resolveContextPath(condition.variable, context) - const operator = condition.operator || '==' - return compareValuesSafe(left, operator, condition.value) + const comparison = compareValuesSafe(left, condition.operator, condition.value) + return { + passed: comparison.result, + detail: comparison.result + ? undefined + : { + condition, + actualValue: comparison.left, + expectedValue: comparison.right, + operator: comparison.operator, + }, + } } } } @@ -400,48 +434,26 @@ function evaluateConditionList( conditions: DecisionNodeCondition[] | undefined, context: { variables: Record; flags: Record }, utterance: string -): boolean { +): { passed: boolean; failure?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { if (!Array.isArray(conditions) || conditions.length === 0) { - return true + return { passed: 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 + const result = evaluateConditionEntry(condition, context, utterance) + if (!result.passed) { + return { + passed: false, + failure: { + condition, + actualValue: result.detail?.actualValue, + expectedValue: result.detail?.expectedValue, + operator: result.detail?.operator, + }, + } } } - return true -} - -function filterDecisionCandidates( - candidates: DecisionCandidate[], - utterance: string, - context: { variables: Record; flags: Record } -): 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) - ) + return { passed: true } } async function prepareDecisionCandidates( @@ -465,12 +477,42 @@ async function prepareDecisionCandidates( activeFlowSlug = system.main || Object.keys(system.flows)[0] || '' } - const uniqueCandidates = new Map() + const flowEntryModes = new Map() + for (const [slug, tree] of Object.entries(system.flows || {})) { + const mode = tree.entry_mode === 'main' + ? 'main' + : tree.entry_mode === 'linear' + ? 'linear' + : slug === system.main + ? 'main' + : 'parallel' + flowEntryModes.set(slug, mode) + } - const addCandidate = (candidate: DecisionCandidate | null | undefined) => { - if (!candidate || !candidate.id || !candidate.state) return - if (uniqueCandidates.has(candidate.id)) return - uniqueCandidates.set(candidate.id, candidate) + const candidateMap = new Map() + + const createCandidate = (id: string, flow: string | undefined, state: RuntimeDecisionState | undefined): DecisionCandidate | null => { + if (!id || !state) return null + const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : [] + const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex') + const noneTriggers = triggers.filter(trigger => trigger?.type === 'none') + return { + id, + flow: flow || activeFlowSlug, + state, + triggers, + regexTriggers, + noneTriggers, + } + } + + const addCandidate = (id: string | undefined, flow: string | undefined, state: RuntimeDecisionState | undefined) => { + if (!id) return + if (candidateMap.has(id)) return + const candidate = createCandidate(id, flow, state) + if (candidate) { + candidateMap.set(id, candidate) + } } for (const raw of input.candidates || []) { @@ -478,14 +520,13 @@ async function prepareDecisionCandidates( 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 }) + addCandidate(raw.id, flow, 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 }) + if (!candidateMap.has(raw.id)) { + addCandidate(raw.id, raw.flow || activeFlowSlug, raw.state) } } @@ -493,25 +534,211 @@ async function prepareDecisionCandidates( 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 state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId] + addCandidate(startStateId, flowSlug, state) } - const candidates = Array.from(uniqueCandidates.values()) + const candidates = Array.from(candidateMap.values()) const context = { variables: input.variables || {}, flags: input.flags || {} } - const filteredCandidates = filterDecisionCandidates(candidates, utterance, context) + const timelineSteps: CandidateTraceStep[] = [] + let fallbackUsed = false + + const toTraceEntry = (candidate: DecisionCandidate): CandidateTraceEntry => ({ + id: candidate.id, + flow: candidate.flow, + name: candidate.state?.name, + summary: candidate.state?.summary, + role: candidate.state?.role, + triggers: candidate.triggers, + conditions: candidate.state?.conditions || [], + }) + + const recordStep = ( + stage: CandidateTraceStage, + label: string, + stepCandidates: DecisionCandidate[], + eliminated: CandidateTraceElimination[] = [], + note?: string + ) => { + timelineSteps.push({ + stage, + label, + candidates: stepCandidates.map(toTraceEntry), + eliminated: eliminated.length ? eliminated : undefined, + note, + }) + } + + const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0) + let workingSet: DecisionCandidate[] = [] + + if (regexCandidates.length > 0) { + recordStep('regex_candidates', 'Regex candidates', regexCandidates) + + const survivors: DecisionCandidate[] = [] + const eliminated: CandidateTraceElimination[] = [] + + for (const candidate of regexCandidates) { + const matched = candidate.regexTriggers.some(trigger => + evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance) + ) + if (matched) { + survivors.push(candidate) + } else { + eliminated.push({ + candidate: toTraceEntry(candidate), + kind: 'regex', + reason: 'No regex trigger matched the pilot utterance.', + context: { + patterns: candidate.regexTriggers.map(trigger => ({ + id: trigger.id, + pattern: trigger.pattern, + flags: trigger.patternFlags, + })), + transcript: utterance, + }, + }) + } + } + + recordStep( + 'regex_filtered', + 'Regex evaluation', + survivors, + eliminated, + survivors.length ? undefined : 'No regex triggers matched the pilot transmission.' + ) + + workingSet = survivors + } else { + recordStep('regex_candidates', 'Regex candidates', [], [], 'No regex-triggered transitions available.') + workingSet = [] + } + + let finalCandidates: DecisionCandidate[] = [] + + if (workingSet.length > 0) { + const survivors: DecisionCandidate[] = [] + const eliminated: CandidateTraceElimination[] = [] + + for (const candidate of workingSet) { + const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance) + if (evaluation.passed) { + survivors.push(candidate) + } else if (evaluation.failure) { + eliminated.push({ + candidate: toTraceEntry(candidate), + kind: 'condition', + reason: 'Node conditions were not satisfied.', + context: { + condition: evaluation.failure.condition, + actualValue: evaluation.failure.actualValue, + expectedValue: evaluation.failure.expectedValue, + operator: evaluation.failure.operator, + }, + }) + } else { + eliminated.push({ + candidate: toTraceEntry(candidate), + kind: 'condition', + reason: 'Node conditions were not satisfied.', + }) + } + } + + recordStep( + 'condition_filtered', + 'Condition evaluation', + survivors, + eliminated, + survivors.length ? undefined : 'All regex candidates failed their conditions.' + ) + + finalCandidates = survivors + } + + if (finalCandidates.length === 0) { + fallbackUsed = true + const fallbackCandidates = candidates.filter(candidate => + candidate.noneTriggers.length > 0 || (candidate.triggers.length === 0 && candidate.regexTriggers.length === 0) + ) + + if (fallbackCandidates.length > 0) { + recordStep('fallback_candidates', 'Fallback candidates', fallbackCandidates) + + const survivors: DecisionCandidate[] = [] + const eliminated: CandidateTraceElimination[] = [] + + for (const candidate of fallbackCandidates) { + const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance) + if (evaluation.passed) { + survivors.push(candidate) + } else if (evaluation.failure) { + eliminated.push({ + candidate: toTraceEntry(candidate), + kind: 'condition', + reason: 'Node conditions were not satisfied.', + context: { + condition: evaluation.failure.condition, + actualValue: evaluation.failure.actualValue, + expectedValue: evaluation.failure.expectedValue, + operator: evaluation.failure.operator, + }, + }) + } else { + eliminated.push({ + candidate: toTraceEntry(candidate), + kind: 'condition', + reason: 'Node conditions were not satisfied.', + }) + } + } + + recordStep( + 'fallback_filtered', + 'Fallback evaluation', + survivors, + eliminated, + survivors.length ? undefined : 'No fallback candidates satisfied their conditions.' + ) + + finalCandidates = survivors + } else { + recordStep('fallback_candidates', 'Fallback candidates', [], [], 'No fallback triggers defined.') + recordStep('fallback_filtered', 'Fallback evaluation', [], [], 'No fallback candidates available.') + } + } + + recordStep( + 'final', + 'Final candidates', + finalCandidates, + [], + finalCandidates.length ? undefined : 'No transitions remain after evaluation.' + ) + + const autoSelected = finalCandidates.length === 1 ? finalCandidates[0] : null const candidateFlowMap = new Map() - for (const candidate of filteredCandidates) { + for (const candidate of finalCandidates) { if (candidate.flow) { candidateFlowMap.set(candidate.id, candidate.flow) } } + const timeline: DecisionCandidateTimeline = { + steps: timelineSteps, + fallbackUsed, + autoSelected: autoSelected ? toTraceEntry(autoSelected) : null, + } + return { - filteredCandidates, + finalCandidates, candidateFlowMap, activeFlowSlug, + flowEntryModes, + timeline, + autoSelected, } } @@ -649,28 +876,74 @@ export async function routeDecision(input: LLMDecisionInput): Promise ({ + trace.candidateTimeline = prepared.timeline + if (prepared.autoSelected) { + trace.autoSelection = { + id: prepared.autoSelected.id, + flow: prepared.autoSelected.flow, + reason: 'Single candidate remained after trigger and condition evaluation.', + } + } + input.candidates = prepared.finalCandidates.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 + const resolveActivationInstruction = ( + value?: string | FlowActivationInstruction | null + ): FlowActivationInstruction | undefined => { + if (!value) return undefined + if (typeof value === 'string') { + const normalizedMode = flowEntryModes.get(value) + || (value === prepared.activeFlowSlug ? 'main' : undefined) + return { + slug: value, + mode: normalizedMode || 'parallel', } } - if (!trace.calls.length && !trace.fallback) { - return {decision} + if (!value.slug) return undefined + const normalizedMode = value.mode + || flowEntryModes.get(value.slug) + || (value.slug === prepared.activeFlowSlug ? 'main' : undefined) + return { + slug: value.slug, + mode: normalizedMode || 'parallel', } - return {decision, trace} + } + + const finalize = (decision: LLMDecision): LLMDecisionResult => { + const targetState = decision.next_state + let activation = resolveActivationInstruction(decision.activate_flow as any) + if (!activation && targetState) { + const targetFlow = candidateFlowMap.get(targetState) + if (targetFlow && targetFlow !== activeFlowSlug) { + activation = resolveActivationInstruction(targetFlow) + } + } + + if (activation) { + decision.activate_flow = activation + } else if (decision.activate_flow) { + delete (decision as any).activate_flow + } + + const shouldAttachTrace = Boolean( + trace.calls.length + || trace.fallback + || (trace.candidateTimeline && trace.candidateTimeline.steps.length) + || trace.autoSelection + ) + + if (!shouldAttachTrace) { + return { decision } + } + return { decision, trace } } async function handleReadbackCheck(): Promise { @@ -799,24 +1072,6 @@ export async function routeDecision(input: LLMDecisionInput): Promise + entry_mode?: 'main' | DecisionFlowEntryMode } export interface RuntimeDecisionSystem { @@ -265,4 +272,6 @@ export interface DecisionFlowSummary { nodeCount: number updatedAt: string createdAt: string + entryMode?: DecisionFlowEntryMode + isMain?: boolean } diff --git a/shared/types/llm.ts b/shared/types/llm.ts index 95b6820..58de96b 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -1,3 +1,5 @@ +import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision' + export interface LLMDecisionInput { state_id: string state: any @@ -8,6 +10,68 @@ export interface LLMDecisionInput { flow_slug?: string } +export type FlowActivationMode = 'main' | 'parallel' | 'linear' + +export interface FlowActivationInstruction { + slug: string + mode?: FlowActivationMode +} + +export interface CandidateTraceEntry { + id: string + flow: string + name?: string + summary?: string + role?: string + triggers?: DecisionNodeTrigger[] + conditions?: DecisionNodeCondition[] +} + +export type CandidateTraceStage = + | 'regex_candidates' + | 'regex_filtered' + | 'condition_filtered' + | 'fallback_candidates' + | 'fallback_filtered' + | 'final' + +export interface CandidateTraceEliminationContext { + patterns?: Array<{ id?: string; pattern?: string; flags?: string }> + transcript?: string + condition?: { + id?: string + type: DecisionNodeCondition['type'] + variable?: string + operator?: string + value?: number | string | boolean + pattern?: string + patternFlags?: string + } + actualValue?: any + expectedValue?: any +} + +export interface CandidateTraceElimination { + candidate: CandidateTraceEntry + kind: 'regex' | 'condition' + reason: string + context?: CandidateTraceEliminationContext +} + +export interface CandidateTraceStep { + stage: CandidateTraceStage + label: string + candidates: CandidateTraceEntry[] + eliminated?: CandidateTraceElimination[] + note?: string +} + +export interface DecisionCandidateTimeline { + steps: CandidateTraceStep[] + fallbackUsed?: boolean + autoSelected?: CandidateTraceEntry | null +} + export interface LLMDecision { next_state: string updates?: Record @@ -15,6 +79,31 @@ export interface LLMDecision { controller_say_tpl?: string off_schema?: boolean radio_check?: boolean - activate_flow?: string + activate_flow?: string | FlowActivationInstruction resume_previous?: boolean } + +export interface LLMDecisionTraceCall { + stage: 'readback-check' | 'decision' + request: Record + response?: any + rawResponseText?: string + error?: string +} + +export interface LLMDecisionTraceFallback { + used: boolean + reason?: string + selected?: string +} + +export interface LLMDecisionTrace { + calls: LLMDecisionTraceCall[] + fallback?: LLMDecisionTraceFallback + candidateTimeline?: DecisionCandidateTimeline + autoSelection?: { + id: string + flow: string + reason?: string + } +} diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 6f39598..252da90 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -7,6 +7,7 @@ import type { RuntimeDecisionAutoTransition, DecisionNodeAutoTrigger, } from '../types/decision' +import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm' import { normalizeRadioPhrase } from './radioSpeech' // --- DecisionTree runtime types --- @@ -20,6 +21,7 @@ interface EngineFlags { stack: string[] off_schema_count: number radio_checks_done: number + session_id: string [key: string]: any } @@ -120,6 +122,10 @@ type TelemetryState = { [key: string]: number } +function createSessionId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + export function normalizeATCText(text: string, context: Record): string { const rendered = renderTpl(text, context) return normalizeRadioPhrase(rendered) @@ -165,9 +171,12 @@ export default function useCommunicationsEngine() { const runtimeSystem = ref(null) const flowOrder = ref([]) const activeFlowSlug = ref('') + const sessionId = ref('') + const flowStack = ref([]) const tree = ref(null) const ready = ref(false) + const lastDecisionTrace = ref(null) const flowSnapshots = reactive>({}) @@ -227,7 +236,18 @@ export default function useCommunicationsEngine() { } } + function ensureSessionValue(raw?: string): string { + if (raw && typeof raw === 'string' && raw.trim().length) { + sessionId.value = raw.trim() + } else if (!sessionId.value) { + sessionId.value = createSessionId() + } + return sessionId.value + } + function assignActiveFlags(next: EngineFlags) { + const normalizedSession = ensureSessionValue(next?.session_id) + next.session_id = normalizedSession flags.value = next if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { flowSnapshots[activeFlowSlug.value].flags = next @@ -275,11 +295,15 @@ export default function useCommunicationsEngine() { stack, off_schema_count: Number((baseFlags as any).off_schema_count) || 0, radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0, + session_id: '', ...(baseFlags as EngineFlags), } if (!Array.isArray(flags.stack)) { flags.stack = [] } + if (typeof flags.session_id !== 'string') { + flags.session_id = '' + } const telemetry: TelemetryState = { altitude_ft: Number((baseFlags as any).altitude_ft) || 0, @@ -341,6 +365,49 @@ export default function useCommunicationsEngine() { setActiveStateId(snapshot.currentStateId) ready.value = snapshot.ready } + + function resolveFlowMode(slug: string | undefined): FlowActivationMode { + if (!slug) return 'parallel' + const system = runtimeSystem.value + if (!system) return 'parallel' + if (slug === system.main) return 'main' + const treeData = system.flows[slug] + if (!treeData) return 'parallel' + if (treeData.entry_mode === 'main') return 'main' + if (treeData.entry_mode === 'linear') return 'linear' + return 'parallel' + } + + function normalizeFlowInstruction(target: string | FlowActivationInstruction | null | undefined): FlowActivationInstruction | null { + if (!target) return null + if (typeof target === 'string') { + return { slug: target, mode: resolveFlowMode(target) } + } + if (!target.slug) return null + return { slug: target.slug, mode: target.mode ?? resolveFlowMode(target.slug) } + } + + function setActiveFlow(target: string | FlowActivationInstruction, options: { skipStack?: boolean } = {}) { + const instruction = normalizeFlowInstruction(target) + if (!instruction) { + throw new Error(`Flow snapshot not loaded: ${typeof target === 'string' ? target : target?.slug}`) + } + const slug = instruction.slug + if (!slug || !flowSnapshots[slug]) { + throw new Error(`Flow snapshot not loaded: ${slug}`) + } + const previous = activeFlowSlug.value + const shouldPush = !options.skipStack + && instruction.mode === 'linear' + && previous + && previous !== slug + if (shouldPush) { + flowStack.value.push(previous) + } + activateFlow(slug) + ready.value = true + queueMicrotask(() => evaluateAutoTransitions()) + } const nextCandidates = computed(() => { const s = currentState.value if (!s) return [] @@ -416,6 +483,15 @@ export default function useCommunicationsEngine() { flowSnapshots[slug] = createSnapshotFromTree(treeData) } + flowStack.value = [] + sessionId.value = createSessionId() + for (const slug of order) { + const snapshot = flowSnapshots[slug] + if (snapshot) { + snapshot.flags.session_id = sessionId.value + } + } + const preferred = options.activeSlug && system.flows[options.activeSlug] ? options.activeSlug : system.main && system.flows[system.main] @@ -440,6 +516,7 @@ export default function useCommunicationsEngine() { stack: [], off_schema_count: 0, radio_checks_done: 0, + session_id: ensureSessionValue(), }) assignActiveTelemetry({ altitude_ft: 0, @@ -466,6 +543,8 @@ export default function useCommunicationsEngine() { const activeFlow = computed(() => activeFlowSlug.value) + const mainFlowSlug = computed(() => runtimeSystem.value?.main || '') + const availableFlows = computed(() => { if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }> return flowOrder.value @@ -477,19 +556,11 @@ export default function useCommunicationsEngine() { name: treeData.name || slug, description: treeData.description, start: treeData.start_state, + mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'), } }) }) - function setActiveFlow(slug: string) { - if (!slug || !flowSnapshots[slug]) { - throw new Error(`Flow snapshot not loaded: ${slug}`) - } - activateFlow(slug) - ready.value = true - queueMicrotask(() => evaluateAutoTransitions()) - } - async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') { ready.value = false const fetcher: any = (globalThis as any).$fetch @@ -689,7 +760,8 @@ export default function useCommunicationsEngine() { current_unit: 'DEL', stack: [], off_schema_count: 0, - radio_checks_done: 0 + radio_checks_done: 0, + session_id: ensureSessionValue(flags.value.session_id) } assignActiveFlags(nextFlags) @@ -738,11 +810,14 @@ export default function useCommunicationsEngine() { } } - function applyLLMDecision(decision: any) { + function applyLLMDecision(decision: any, trace?: LLMDecisionTrace | null) { if (!decision || typeof decision !== 'object') { + lastDecisionTrace.value = null return } + lastDecisionTrace.value = trace ?? null + if (decision.updates && typeof decision.updates === 'object') { Object.assign(variables.value, decision.updates) } @@ -759,11 +834,14 @@ export default function useCommunicationsEngine() { flags.value.stack = decision.stack.slice() } - if (decision.activate_flow && decision.activate_flow !== activeFlowSlug.value) { - try { - setActiveFlow(decision.activate_flow) - } catch (err) { - console.warn('[Engine] Failed to activate flow from decision', err) + if (decision.activate_flow) { + const activation = normalizeFlowInstruction(decision.activate_flow as any) + if (activation && (activation.slug !== activeFlowSlug.value || activation.mode === 'main')) { + try { + setActiveFlow(activation) + } catch (err) { + console.warn('[Engine] Failed to activate flow from decision', err) + } } } @@ -792,7 +870,10 @@ export default function useCommunicationsEngine() { : null if (resumeFlow) { - resumePriorFlow() + const resumed = resumeLinearFlow() + if (!resumed) { + resumeStackedState() + } } else if (!decision.radio_check && nextState) { moveTo(nextState) } @@ -806,31 +887,8 @@ export default function useCommunicationsEngine() { if (!ready.value) { return null } - // Log pilot input speak('pilot', transcript, currentStateId.value) - - // Radio check detection (fallback if the LLM misses it) - const t = transcript.toLowerCase() - if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) { - const callsign = variables.value.callsign || '' - const response = `${callsign}, read you five by five.` - flags.value.radio_checks_done++ - - setTimeout(() => { - speak('atc', response, currentStateId.value, { radioCheck: true }) - }, 500) - - return response - } - - // Emergency Interrupts - if (flags.value.in_air && /^(mayday|pan\s*pan)/.test(t)) { - const intId = t.startsWith('mayday') ? 'INT_MAYDAY' : 'INT_PANPAN' - moveTo(intId) - return null - } - - return null // Let the LLM decide + return null } function processUserTransmission(transcript: string): string | null { @@ -986,11 +1044,31 @@ export default function useCommunicationsEngine() { } } - function resumePriorFlow() { + function resumeStackedState() { const prev = flags.value.stack.pop() if (prev) moveTo(prev) } + function resumeLinearFlow(): boolean { + const previousFlow = flowStack.value.pop() + if (previousFlow) { + try { + setActiveFlow({ slug: previousFlow, mode: resolveFlowMode(previousFlow) }, { skipStack: true }) + return true + } catch (err) { + console.warn('[Engine] Failed to resume linear flow', err) + } + } else if (mainFlowSlug.value && activeFlowSlug.value !== mainFlowSlug.value) { + try { + setActiveFlow({ slug: mainFlowSlug.value, mode: 'main' }, { skipStack: true }) + return true + } catch (err) { + console.warn('[Engine] Failed to restore main flow', err) + } + } + return false + } + function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) { const msg = renderTpl(tpl, exposeCtx()) const entry: EngineLog = { @@ -1096,6 +1174,8 @@ export default function useCommunicationsEngine() { clearCommunicationLog: () => { assignCommunicationLog([]) }, activeFlow, availableFlows, + sessionId: readonly(sessionId), + lastDecisionTrace: readonly(lastDecisionTrace), // pm_alt.vue integration flightContext: readonly(flightContext), @@ -1118,7 +1198,6 @@ export default function useCommunicationsEngine() { // Flow Control moveTo, - resumePriorFlow, // Utilities normalizeATCText, diff --git a/shared/utils/openaiDecision.ts b/shared/utils/openaiDecision.ts index 2014c90..e36f857 100644 --- a/shared/utils/openaiDecision.ts +++ b/shared/utils/openaiDecision.ts @@ -1,9 +1,9 @@ // utils/openaiDecision.ts -import type { LLMDecision, LLMDecisionInput } from '../types/llm' +import type { LLMDecisionInput, LLMDecisionResult } from '../types/llm' /** Client-seitig: ruft den Backend-Endpunkt auf */ -export async function decideNextStateLLM(input: LLMDecisionInput): Promise { - return await $fetch('/api/llm/decide', { +export async function decideNextStateLLM(input: LLMDecisionInput): Promise { + return await $fetch('/api/llm/decide', { method: 'POST', body: input }) From d37b8a631cefaf30d9536f74aa30784fb2d01983 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:12:02 +0200 Subject: [PATCH 3/7] Improve flow activation handling and expose active nodes --- server/api/atc/ptt.post.ts | 7 ++- server/utils/openai.ts | 114 ++++++++++++++++++++++++++++++++----- shared/types/llm.ts | 14 +++++ 3 files changed, 121 insertions(+), 14 deletions(-) diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 2d01093..7674853 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { getOpenAIClient, routeDecision, type LLMDecisionResult } from "../../utils/openai"; +import { getOpenAIClient, routeDecision } from "../../utils/openai"; +import type { LLMDecisionResult } from "~~/shared/types/llm"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; @@ -33,6 +34,7 @@ interface PTTResponse { transcription: string; decision?: LLMDecisionResult['decision']; trace?: LLMDecisionResult['trace']; + active_nodes?: LLMDecisionResult['active_nodes']; } async function sh(cmd: string, args: string[]) { @@ -273,6 +275,9 @@ export default defineEventHandler(async (event) => { if (decisionResult?.trace) { result.trace = decisionResult.trace; } + if (decisionResult?.active_nodes?.length) { + result.active_nodes = decisionResult.active_nodes; + } return result; diff --git a/server/utils/openai.ts b/server/utils/openai.ts index a7ec3dd..caf0305 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -2,6 +2,7 @@ import OpenAI from 'openai' import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' import type { + ActiveNodeSummary, CandidateTraceEntry, CandidateTraceStep, DecisionCandidateTimeline, @@ -9,6 +10,7 @@ import type { FlowActivationMode, LLMDecision, LLMDecisionInput, + LLMDecisionResult, LLMDecisionTrace, LLMDecisionTraceCall, } from '../../shared/types/llm' @@ -74,10 +76,6 @@ export async function decide(system: string, user: string): Promise { return r.choices?.[0]?.message?.content?.trim() || '' } -export interface LLMDecisionResult { - decision: LLMDecision - trace?: LLMDecisionTrace -} type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' @@ -212,6 +210,8 @@ interface DecisionCandidate { interface PreparedCandidateResult { finalCandidates: DecisionCandidate[] candidateFlowMap: Map + candidateIndex: Map + finalCandidateIndex: Map activeFlowSlug: string flowEntryModes: Map timeline: DecisionCandidateTimeline @@ -726,6 +726,11 @@ async function prepareDecisionCandidates( } } + const finalCandidateIndex = new Map() + for (const candidate of finalCandidates) { + finalCandidateIndex.set(candidate.id, candidate) + } + const timeline: DecisionCandidateTimeline = { steps: timelineSteps, fallbackUsed, @@ -735,6 +740,8 @@ async function prepareDecisionCandidates( return { finalCandidates, candidateFlowMap, + candidateIndex: candidateMap, + finalCandidateIndex, activeFlowSlug, flowEntryModes, timeline, @@ -918,21 +925,98 @@ export async function routeDecision(input: LLMDecisionInput): Promise { - const targetState = decision.next_state + const targetState = typeof decision.next_state === 'string' ? decision.next_state : '' + const normalizedControllerSay = typeof decision.controller_say_tpl === 'string' + ? decision.controller_say_tpl.trim() + : '' + + const targetCandidate = targetState + ? prepared.finalCandidateIndex.get(targetState) || prepared.candidateIndex.get(targetState) + : undefined + const targetFlow = targetCandidate?.flow || (targetState ? candidateFlowMap.get(targetState) : undefined) + const candidateSayTemplate = targetCandidate?.state?.say_tpl + const candidateRole = targetCandidate?.state?.role + + if ((!normalizedControllerSay.length) && candidateRole === 'atc' && typeof candidateSayTemplate === 'string') { + decision.controller_say_tpl = candidateSayTemplate + } + let activation = resolveActivationInstruction(decision.activate_flow as any) - if (!activation && targetState) { - const targetFlow = candidateFlowMap.get(targetState) - if (targetFlow && targetFlow !== activeFlowSlug) { - activation = resolveActivationInstruction(targetFlow) - } + if (!activation && targetFlow && targetFlow !== activeFlowSlug) { + activation = resolveActivationInstruction(targetFlow) + } + + const finalControllerSay = typeof decision.controller_say_tpl === 'string' + ? decision.controller_say_tpl.trim() + : '' + const atcWillSpeak = Boolean( + (finalControllerSay && finalControllerSay.length) + || (candidateRole === 'atc' && typeof candidateSayTemplate === 'string' && candidateSayTemplate.trim().length) + ) + + if (targetCandidate?.state?.auto === 'pop_stack_or_route_by_intent') { + decision.resume_previous = true } if (activation) { + if (activation.slug !== activeFlowSlug && atcWillSpeak && activation.mode !== 'main') { + activation.mode = 'linear' + } decision.activate_flow = activation } else if (decision.activate_flow) { delete (decision as any).activate_flow } + let activeNodes: ActiveNodeSummary[] | undefined + if (activation?.mode === 'parallel') { + const nodes: ActiveNodeSummary[] = [] + + if (input.state_id && activeFlowSlug) { + const previousSay = typeof input.state?.say_tpl === 'string' ? input.state.say_tpl : undefined + nodes.push({ + flow: activeFlowSlug, + state: input.state_id, + role: input.state?.role, + say_tpl: previousSay, + controller_say_tpl: input.state?.role === 'atc' ? previousSay : undefined, + }) + } + + if (targetState) { + const flowForTarget = targetFlow || activeFlowSlug + if (flowForTarget) { + nodes.push({ + flow: flowForTarget, + state: targetState, + role: candidateRole, + say_tpl: typeof candidateSayTemplate === 'string' ? candidateSayTemplate : undefined, + controller_say_tpl: candidateRole === 'atc' + ? (decision.controller_say_tpl || candidateSayTemplate || undefined) + : undefined, + }) + } + } + + if (nodes.length) { + const seen = new Set() + activeNodes = nodes.filter(node => { + if (!node.flow || !node.state) { + return false + } + const key = `${node.flow}::${node.state}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + } + + if (!activeNodes?.length) { + activeNodes = undefined + } + } + const shouldAttachTrace = Boolean( trace.calls.length || trace.fallback @@ -940,10 +1024,14 @@ export async function routeDecision(input: LLMDecisionInput): Promise { diff --git a/shared/types/llm.ts b/shared/types/llm.ts index 58de96b..a443eca 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -17,6 +17,14 @@ export interface FlowActivationInstruction { mode?: FlowActivationMode } +export interface ActiveNodeSummary { + flow: string + state: string + role?: string + say_tpl?: string + controller_say_tpl?: string +} + export interface CandidateTraceEntry { id: string flow: string @@ -107,3 +115,9 @@ export interface LLMDecisionTrace { reason?: string } } + +export interface LLMDecisionResult { + decision: LLMDecision + trace?: LLMDecisionTrace + active_nodes?: ActiveNodeSummary[] +} From 717b9b711299aa89233e35c376eddcea4792aacd Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:00:47 +0200 Subject: [PATCH 4/7] feat(editor): manage flow properties in inspector --- app/pages/editor/index.vue | 360 ++++++++++++++++++++++++++++++++++++- 1 file changed, 354 insertions(+), 6 deletions(-) diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue index 651f823..0888412 100644 --- a/app/pages/editor/index.vue +++ b/app/pages/editor/index.vue @@ -365,7 +365,29 @@ v-if="inspectorOpen && flowDetail" class="w-[380px] shrink-0 overflow-y-auto border-l border-white/10 bg-[#0b1224]/85 backdrop-blur fixed right-0 bottom-0 top-12" > -
+
+ + Nodes + Flow + + + Flow speichern + +
+ +
@@ -961,6 +1151,7 @@ import { useApi } from '~/composables/useApi' import { useAuthStore } from '~/stores/auth' import DecisionNodeCanvas from '~/components/editor/DecisionNodeCanvas.vue' import type { + DecisionFlowEntryMode, DecisionFlowModel, DecisionFlowSummary, DecisionNodeCondition, @@ -1003,6 +1194,8 @@ interface FlowFormState { name: string description: string schemaVersion: string + entryMode: DecisionFlowEntryMode + isMain: boolean startState: string endStates: string[] roles: string[] @@ -1075,6 +1268,7 @@ const autosaveIndicator = ref(false) const canvasComponent = ref | null>(null) const inspectorOpen = ref(false) +const inspectorMode = ref<'node' | 'flow'>('node') const selectedFlowSlug = ref(null) const flowDetail = ref<{ flow: DecisionFlowModel; nodes: DecisionNodeModel[] } | null>(null) @@ -1087,6 +1281,8 @@ const flowForm = reactive({ name: '', description: '', schemaVersion: '1.0', + entryMode: 'parallel', + isMain: false, startState: '', endStates: [], roles: ['pilot', 'atc', 'system'], @@ -1249,6 +1445,61 @@ const nodeSelectorItems = computed(() => { .map(({ matchesSearch, matchesRole, matchesPhase, matchesAuto, ...rest }) => rest) }) +const flowModeOptions: Array<{ value: DecisionFlowEntryMode; title: string; subtitle: string }> = [ + { value: 'parallel', title: 'Parallel', subtitle: 'Mehrere Branches können gleichzeitig laufen' }, + { value: 'linear', title: 'Einzeln', subtitle: 'Es läuft immer nur ein Branch' }, +] + +const flowNodeOptions = computed(() => { + if (!flowDetail.value) return [] + const items = flowDetail.value.nodes + .slice() + .sort((a, b) => a.stateId.localeCompare(b.stateId)) + .map((node) => ({ + value: node.stateId, + title: node.title ? `${node.stateId} — ${node.title}` : node.stateId, + })) + + const known = new Set(items.map((item) => item.value)) + const extras = [flowForm.startState, ...flowForm.endStates] + .map((state) => (typeof state === 'string' ? state.trim() : '')) + .filter((state): state is string => Boolean(state && !known.has(state))) + + extras.forEach((state) => { + items.push({ value: state, title: `${state} (nicht im Flow)` }) + known.add(state) + }) + + return items +}) + +const flowRoleOptions = computed(() => { + const roles = new Set(roleOptions) + flowForm.roles.forEach((role) => { + const trimmed = typeof role === 'string' ? role.trim() : '' + if (trimmed) roles.add(trimmed) + }) + return Array.from(roles) +}) + +const flowPhaseOptions = computed(() => { + const phases = new Set() + flowForm.phases.forEach((phase) => { + const trimmed = typeof phase === 'string' ? phase.trim() : '' + if (trimmed) phases.add(trimmed) + }) + flowDetail.value?.nodes.forEach((node) => { + const trimmed = typeof node.phase === 'string' ? node.phase.trim() : '' + if (trimmed) phases.add(trimmed) + }) + return Array.from(phases) +}) + +const currentFlowModeDescription = computed(() => { + const option = flowModeOptions.find((entry) => entry.value === flowForm.entryMode) + return option?.subtitle ?? '' +}) + const canvasNodes = computed(() => { if (!flowDetail.value) return [] const flow = flowDetail.value.flow @@ -1353,6 +1604,7 @@ watch( inspectorOpen, (open) => { if (!open || !flowDetail.value) return + if (inspectorMode.value !== 'node') return if (selectedNodeId.value) return const preferred = flowDetail.value.flow.startState && @@ -1397,7 +1649,7 @@ watch( () => flowForm.endStates, (states) => { if (!flowDetail.value) return - flowDetail.value.flow.endStates = [...states] + flowDetail.value.flow.endStates = Array.from(new Set(states)) if (!flowInitializing) { scheduleFlowSave() } @@ -1405,6 +1657,70 @@ watch( { deep: true } ) +watch( + () => flowForm.name, + (name) => { + if (!flowDetail.value || flowInitializing) return + flowDetail.value.flow.name = name + } +) + +watch( + () => flowForm.description, + (description) => { + if (!flowDetail.value || flowInitializing) return + flowDetail.value.flow.description = description || '' + } +) + +watch( + () => flowForm.schemaVersion, + (version) => { + if (!flowDetail.value || flowInitializing) return + flowDetail.value.flow.schemaVersion = version + } +) + +watch( + () => flowForm.entryMode, + (mode) => { + if (!flowDetail.value || flowInitializing) return + flowDetail.value.flow.entryMode = mode + } +) + +watch( + () => flowForm.isMain, + (isMain) => { + if (!flowDetail.value || flowInitializing) return + flowDetail.value.flow.isMain = isMain + } +) + +watch( + () => flowForm.roles, + (roles) => { + if (!flowDetail.value || flowInitializing) return + const sanitized = roles + .map((role) => (typeof role === 'string' ? role.trim() : '')) + .filter((role): role is string => Boolean(role)) + flowDetail.value.flow.roles = Array.from(new Set(sanitized)) + }, + { deep: true } +) + +watch( + () => flowForm.phases, + (phases) => { + if (!flowDetail.value || flowInitializing) return + const sanitized = phases + .map((phase) => (typeof phase === 'string' ? phase.trim() : '')) + .filter((phase): phase is string => Boolean(phase)) + flowDetail.value.flow.phases = Array.from(new Set(sanitized)) + }, + { deep: true } +) + watch( flowForm, () => { @@ -1641,6 +1957,8 @@ function populateFlowForm(flow: DecisionFlowModel) { flowForm.name = flow.name flowForm.description = flow.description ?? '' flowForm.schemaVersion = flow.schemaVersion ?? '1.0' + flowForm.entryMode = flow.entryMode || 'parallel' + flowForm.isMain = Boolean(flow.isMain) flowForm.startState = flow.startState flowForm.endStates = Array.isArray(flow.endStates) ? [...new Set(flow.endStates)] : [] flowForm.roles = flow.roles?.length ? [...new Set(flow.roles)] : ['pilot', 'atc', 'system'] @@ -1762,6 +2080,7 @@ function selectNode(stateId: string | null, options: { focus?: boolean } = {}) { selectedNodeId.value = null return } + inspectorMode.value = 'node' inspectorOpen.value = true if (selectedNodeId.value !== stateId) { selectedNodeId.value = stateId @@ -1806,10 +2125,14 @@ async function persistFlow(options: { silent?: boolean } = {}) { flowSaveLoading.value = true let payload: Record try { + const entryMode = flowForm.entryMode === 'linear' ? 'linear' : 'parallel' + const isMain = Boolean(flowForm.isMain) payload = { name: flowForm.name.trim(), description: flowForm.description, schemaVersion: flowForm.schemaVersion.trim(), + entryMode, + isMain, startState: flowForm.startState.trim(), endStates: flowForm.endStates, roles: flowForm.roles, @@ -1826,6 +2149,8 @@ async function persistFlow(options: { silent?: boolean } = {}) { } flowForm.name = payload.name flowForm.schemaVersion = payload.schemaVersion + flowForm.entryMode = payload.entryMode + flowForm.isMain = payload.isMain flowForm.startState = payload.startState } catch (error: any) { flowSaveLoading.value = false @@ -1848,8 +2173,25 @@ async function persistFlow(options: { silent?: boolean } = {}) { flowDetail.value.nodes = updated.nodes } } + flowInitializing = true + flowForm.name = updated.flow.name + flowForm.description = updated.flow.description ?? '' + flowForm.schemaVersion = updated.flow.schemaVersion ?? '1.0' + flowForm.entryMode = updated.flow.entryMode || 'parallel' + flowForm.isMain = Boolean(updated.flow.isMain) + flowForm.startState = updated.flow.startState + flowForm.endStates = Array.isArray(updated.flow.endStates) + ? [...new Set(updated.flow.endStates)] + : [] + flowForm.roles = updated.flow.roles?.length + ? [...new Set(updated.flow.roles)] + : ['pilot', 'atc', 'system'] + flowForm.phases = updated.flow.phases?.length ? [...new Set(updated.flow.phases)] : [] flowSnapshot.value = cloneNode(flowForm) lastFlowAutosaveError = '' + nextTick(() => { + flowInitializing = false + }) if (silent) { const summaryIndex = flows.value.findIndex((flow) => flow.slug === updated.flow.slug) @@ -1861,6 +2203,8 @@ async function persistFlow(options: { silent?: boolean } = {}) { description: updated.flow.description, startState: updated.flow.startState, nodeCount: updated.nodes?.length ?? previous.nodeCount, + entryMode: updated.flow.entryMode, + isMain: updated.flow.isMain, }) } flashAutosaveIndicator() @@ -1880,6 +2224,10 @@ async function persistFlow(options: { silent?: boolean } = {}) { } } +function persistFlowNow() { + void persistFlow({ silent: false }) +} + async function persistNode(options: { silent?: boolean } = {}) { if (!flowDetail.value || !nodeForm.value) return const silent = options.silent ?? false From 66121fc4fa0e2bbadd5da4d328280385fad1cee0 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:00:31 +0200 Subject: [PATCH 5/7] Refactor route decision selection --- server/utils/openai.ts | 536 ++++++++++++----------------------------- 1 file changed, 151 insertions(+), 385 deletions(-) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index caf0305..421f256 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -2,13 +2,10 @@ import OpenAI from 'openai' import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' import type { - ActiveNodeSummary, CandidateTraceEntry, CandidateTraceStep, DecisionCandidateTimeline, - FlowActivationInstruction, FlowActivationMode, - LLMDecision, LLMDecisionInput, LLMDecisionResult, LLMDecisionTrace, @@ -874,424 +871,193 @@ function optimizeInputForLLM(input: LLMDecisionInput) { } } + export async function routeDecision(input: LLMDecisionInput): Promise { - const pilotUtterance = (input.pilot_utterance || '').trim() - const pilotText = pilotUtterance.toLowerCase() - const trace: LLMDecisionTrace = {calls: []} - let candidateFlowMap = new Map() - let activeFlowSlug = input.flow_slug || '' + const utterance = (input.pilot_utterance || '').trim() + const { system, index } = await getRuntimeSystemIndex() - const prepared = await prepareDecisionCandidates(input, pilotUtterance) - candidateFlowMap = prepared.candidateFlowMap - const flowEntryModes = prepared.flowEntryModes - if (prepared.activeFlowSlug) { - activeFlowSlug = prepared.activeFlowSlug - input.flow_slug = prepared.activeFlowSlug - } - trace.candidateTimeline = prepared.timeline - if (prepared.autoSelected) { - trace.autoSelection = { - id: prepared.autoSelected.id, - flow: prepared.autoSelected.flow, - reason: 'Single candidate remained after trigger and condition evaluation.', - } - } - input.candidates = prepared.finalCandidates.map(candidate => ({ - id: candidate.id, - state: candidate.state, - flow: candidate.flow, - })) + const currentEntry = index.get(input.state_id) + const activeFlowSlug = + input.flow_slug + || currentEntry?.flow + || system.main + || Object.keys(system.flows || {})[0] + || '' - const resolveActivationInstruction = ( - value?: string | FlowActivationInstruction | null - ): FlowActivationInstruction | undefined => { - if (!value) return undefined - if (typeof value === 'string') { - const normalizedMode = flowEntryModes.get(value) - || (value === prepared.activeFlowSlug ? 'main' : undefined) - return { - slug: value, - mode: normalizedMode || 'parallel', - } - } - if (!value.slug) return undefined - const normalizedMode = value.mode - || flowEntryModes.get(value.slug) - || (value.slug === prepared.activeFlowSlug ? 'main' : undefined) - return { - slug: value.slug, - mode: normalizedMode || 'parallel', - } - } - - const finalize = (decision: LLMDecision): LLMDecisionResult => { - const targetState = typeof decision.next_state === 'string' ? decision.next_state : '' - const normalizedControllerSay = typeof decision.controller_say_tpl === 'string' - ? decision.controller_say_tpl.trim() - : '' - - const targetCandidate = targetState - ? prepared.finalCandidateIndex.get(targetState) || prepared.candidateIndex.get(targetState) - : undefined - const targetFlow = targetCandidate?.flow || (targetState ? candidateFlowMap.get(targetState) : undefined) - const candidateSayTemplate = targetCandidate?.state?.say_tpl - const candidateRole = targetCandidate?.state?.role - - if ((!normalizedControllerSay.length) && candidateRole === 'atc' && typeof candidateSayTemplate === 'string') { - decision.controller_say_tpl = candidateSayTemplate + const candidateMap = new Map() + const addCandidate = ( + id?: string, + flow?: string, + providedState?: RuntimeDecisionState + ) => { + if (!id || candidateMap.has(id)) { + return } - let activation = resolveActivationInstruction(decision.activate_flow as any) - if (!activation && targetFlow && targetFlow !== activeFlowSlug) { - activation = resolveActivationInstruction(targetFlow) + const indexed = index.get(id) + const state = providedState || indexed?.state + if (!state) { + return } - const finalControllerSay = typeof decision.controller_say_tpl === 'string' - ? decision.controller_say_tpl.trim() - : '' - const atcWillSpeak = Boolean( - (finalControllerSay && finalControllerSay.length) - || (candidateRole === 'atc' && typeof candidateSayTemplate === 'string' && candidateSayTemplate.trim().length) - ) + const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : [] + const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex') + const noneTriggers = triggers.filter(trigger => trigger?.type === 'none') + const flowSlug = flow || indexed?.flow || activeFlowSlug - if (targetCandidate?.state?.auto === 'pop_stack_or_route_by_intent') { - decision.resume_previous = true - } - - if (activation) { - if (activation.slug !== activeFlowSlug && atcWillSpeak && activation.mode !== 'main') { - activation.mode = 'linear' - } - decision.activate_flow = activation - } else if (decision.activate_flow) { - delete (decision as any).activate_flow - } - - let activeNodes: ActiveNodeSummary[] | undefined - if (activation?.mode === 'parallel') { - const nodes: ActiveNodeSummary[] = [] - - if (input.state_id && activeFlowSlug) { - const previousSay = typeof input.state?.say_tpl === 'string' ? input.state.say_tpl : undefined - nodes.push({ - flow: activeFlowSlug, - state: input.state_id, - role: input.state?.role, - say_tpl: previousSay, - controller_say_tpl: input.state?.role === 'atc' ? previousSay : undefined, - }) - } - - if (targetState) { - const flowForTarget = targetFlow || activeFlowSlug - if (flowForTarget) { - nodes.push({ - flow: flowForTarget, - state: targetState, - role: candidateRole, - say_tpl: typeof candidateSayTemplate === 'string' ? candidateSayTemplate : undefined, - controller_say_tpl: candidateRole === 'atc' - ? (decision.controller_say_tpl || candidateSayTemplate || undefined) - : undefined, - }) - } - } - - if (nodes.length) { - const seen = new Set() - activeNodes = nodes.filter(node => { - if (!node.flow || !node.state) { - return false - } - const key = `${node.flow}::${node.state}` - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) - } - - if (!activeNodes?.length) { - activeNodes = undefined - } - } - - const shouldAttachTrace = Boolean( - trace.calls.length - || trace.fallback - || (trace.candidateTimeline && trace.candidateTimeline.steps.length) - || trace.autoSelection - ) - - const result: LLMDecisionResult = { decision } - if (shouldAttachTrace) { - result.trace = trace - } - if (activeNodes && activeNodes.length) { - result.active_nodes = activeNodes - } - return result - } - - async function handleReadbackCheck(): Promise { - const requiredKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || [] - const expectedItems = requiredKeys.reduce>((acc, key) => { - const value = resolveReadbackValue(key, input) - if (!value) { - return acc - } - - const normalizedValue = String(value) - if (!normalizedValue.trim().length) { - return acc - } - - acc.push({ - key, - value: normalizedValue, - spoken_variants: buildSpokenVariants(key, normalizedValue) - }) - return acc - }, []) - - const okNext = pickTransition(input.state.ok_next, input.candidates) - const badNext = pickTransition(input.state.bad_next, input.candidates) - const defaultNext = fallbackNextState(input) - - if (!expectedItems.length) { - return finalize({next_state: okNext ?? defaultNext}) - } - - const sanitizedPilot = sanitizeForQuickMatch(pilotUtterance) - const heuristicsOk = expectedItems.every(item => { - const sanitizedValue = sanitizeForQuickMatch(item.value) - return sanitizedValue ? sanitizedPilot.includes(sanitizedValue) : true + candidateMap.set(id, { + id, + flow: flowSlug, + state, + triggers, + regexTriggers, + noneTriggers, }) + } - if (heuristicsOk && okNext) { - return finalize({next_state: okNext}) - } - - const payload = { - state_id: input.state_id, - callsign: input.variables?.callsign, - pilot_utterance: pilotUtterance, - expected_items: expectedItems, - controller_instruction: input.state.say_tpl ?? null - } - - const requestBody = { - model: getModel(), - response_format: {type: 'json_schema', json_schema: READBACK_JSON_SCHEMA}, - reasoning_effort: 'low', - n: 1, - verbosity: 'low', - messages: [ - { - role: 'system', - content: [ - 'You are an aviation clearance readback checker.', - 'Evaluate if the pilot_utterance correctly repeats every item in expected_items.', - 'Return JSON with keys: status (ok, missing, incorrect, uncertain), missing (array), incorrect (array), notes (optional).', - 'Treat reasonable phonetic variations as correct.' - ].join(' ') - }, - {role: 'user', content: JSON.stringify(payload)} - ] - } - - const callTrace: LLMDecisionTraceCall = { - stage: 'readback-check', - request: JSON.parse(JSON.stringify(requestBody)) - } - - try { - const client = ensureOpenAI() - const response = await client.chat.completions.create(requestBody) - - const raw = response.choices?.[0]?.message?.content || '{}' - callTrace.response = JSON.parse(JSON.stringify(response)) - callTrace.rawResponseText = raw - trace.calls.push(callTrace) - - const parsed = JSON.parse(raw) as { status?: ReadbackStatus } - const status: ReadbackStatus = parsed.status || 'uncertain' - - if (status === 'ok') { - return finalize({next_state: okNext ?? defaultNext}) - } - - if ((status === 'missing' || status === 'incorrect') && badNext) { - return finalize({next_state: badNext}) - } - - if (status === 'uncertain' && okNext) { - return finalize({next_state: okNext}) - } - - return finalize({next_state: badNext ?? defaultNext}) - } catch (err) { - callTrace.error = err instanceof Error ? err.message : String(err) - trace.calls.push(callTrace) - if (!trace.fallback) { - trace.fallback = {used: true, reason: callTrace.error, selected: 'readback-check-fallback'} - } - console.warn('[ATC] Readback check failed, using fallback:', err) - return finalize({next_state: okNext ?? defaultNext}) + if (currentEntry?.state) { + const transitions = [ + ...(currentEntry.state.next || []), + ...(currentEntry.state.ok_next || []), + ...(currentEntry.state.bad_next || []), + ...(currentEntry.state.timer_next || []), + ] + for (const transition of transitions) { + if (!transition?.to) continue + addCandidate(transition.to, currentEntry.flow) } } - if (input.state?.auto === 'check_readback') { - return await handleReadbackCheck() + for (const raw of input.candidates || []) { + if (!raw?.id) continue + addCandidate(raw.id, raw.flow, raw.state) } - if (!pilotUtterance) { - const interruptCandidate = input.candidates.find(c => c.id.startsWith('INT_')) - || input.candidates.find(c => c.state?.auto === 'monitor') - || input.candidates.find(c => c.state?.role === 'system') - - if (interruptCandidate) { - return finalize({next_state: interruptCandidate.id}) - } + for (const [flowSlug, tree] of Object.entries(system.flows || {})) { + if (flowSlug === activeFlowSlug) continue + const start = tree?.start_state + if (!start) continue + const startState = tree?.states?.[start] + addCandidate(start, flowSlug, startState) } - const optimizedInput = optimizeInputForLLM(input) + let candidates = Array.from(candidateMap.values()) + if (candidates.length === 0) { + return { decision: { next_state: input.state_id } } + } - // Check whether the next states require ATC responses - const atcCandidates = input.candidates.filter(c => - c.state.role === 'atc' || c.state.say_tpl || c.id.startsWith('INT_') + candidates = candidates.filter(candidate => + !candidate.triggers.some(trigger => trigger?.type === 'auto_time' || trigger?.type === 'auto_variable') ) - // If no ATC states are available, perform a simple transition without a response - if (atcCandidates.length === 0 && input.candidates.length > 0) { - return finalize({next_state: input.candidates[0].id}) + const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0) + const regexMatches = regexCandidates.filter(candidate => + candidate.regexTriggers.some(trigger => evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) + ) + + let workingSet = regexMatches + if (workingSet.length === 0) { + workingSet = candidates.filter(candidate => candidate.regexTriggers.length === 0) } - // Compact yet informative prompt — includes variable info for intelligent responses - const system = [ - 'You are an ATC state router. Return strict JSON.', - 'Keys: next_state, controller_say_tpl (optional), off_schema (optional), intent (optional).', - '', - 'CLASSIFY INTENT: Determine if pilot_utterance is PILOT_REQUEST (pilot initiates a call or request), PILOT_READBACK (acknowledging prior ATC instruction), SYS_INTERRUPT (system-driven transition, no pilot input), or OTHER.', - 'Use decision_hints.expecting_pilot_call, state_summary.role and candidates[].requires_atc_reply to guide the choice.', - '', - 'ROUTING: Choose next_state from candidates[].id that best fits the intent and keeps the flow consistent with state_summary.next/ok_next/bad_next.', - 'If unsure, prefer GEN_NO_REPLY (set off_schema=true) or the first logical candidate.', - '', - 'ATC RESPONSES: Only include controller_say_tpl when the chosen candidate requires an ATC reply (requires_atc_reply=true), has template variables, or the pilot is off schema.', - 'Never speak for pilot states. Always include {callsign} in ATC responses and prefer provided variables such as {runway}, {squawk}, {dest}.', - '', - `Available variables: {${optimizedInput.available_variables.join('}, {')}}`, - `Common candidate variables: {${optimizedInput.candidate_variables.join('}, {')}}`, - '', - 'INTERRUPTS: If an interrupt state (id starts with INT_) best matches the intent, select it and answer accordingly.', - 'Do not invent state ids. If nothing fits, respond with next_state "GEN_NO_REPLY" and off_schema=true.' - ].join(' ') + if (workingSet.length === 0) { + return { decision: { next_state: input.state_id } } + } - // Update optimized input to indicate which candidates need ATC responses - optimizedInput.atc_candidates = atcCandidates.map(c => c.id) + const context = { variables: input.variables || {}, flags: input.flags || {} } + const survivors = workingSet.filter(candidate => + evaluateConditionList(candidate.state?.conditions, context, utterance).passed + ) - const user = JSON.stringify(optimizedInput) + if (survivors.length === 1) { + return { decision: { next_state: survivors[0].id } } + } - const body = { + if (survivors.length === 0) { + return { decision: { next_state: input.state_id } } + } + + const trace: LLMDecisionTrace = { calls: [] } + const llmCandidates = survivors.map(candidate => ({ + id: candidate.id, + flow: candidate.flow, + state: candidate.state, + })) + + const payload = { + state_id: input.state_id, + flow_slug: activeFlowSlug, + pilot_utterance: utterance, + variables: input.variables, + flags: input.flags, + candidates: llmCandidates.map(candidate => ({ + id: candidate.id, + flow: candidate.flow, + role: candidate.state?.role, + phase: candidate.state?.phase, + summary: candidate.state?.summary, + triggers: candidate.state?.triggers || [], + conditions: candidate.state?.conditions || [], + say_tpl: candidate.state?.say_tpl, + })), + } + + const requestBody = { model: getModel(), - response_format: {type: 'json_object'}, + response_format: { + type: 'json_schema', + json_schema: { + name: 'decision', + schema: { + type: 'object', + additionalProperties: false, + properties: { + next_state: { type: 'string' }, + }, + required: ['next_state'], + }, + }, + }, messages: [ - {role: 'system', content: system}, - {role: 'user', content: user} - ] + { + role: 'system', + content: 'You are an ATC decision assistant. Choose the best next state from the provided candidates based on the pilot utterance and context. Return JSON.', + }, + { + role: 'user', + content: JSON.stringify(payload), + }, + ], } const callTrace: LLMDecisionTraceCall = { stage: 'decision', - request: JSON.parse(JSON.stringify(body)) + request: JSON.parse(JSON.stringify(requestBody)), } + let chosen: string | null = null try { const client = ensureOpenAI() - - console.log("calling LLM with body:", body) - - const r = await client.chat.completions.create(body) - - const raw = r.choices?.[0]?.message?.content || '{}' - callTrace.response = JSON.parse(JSON.stringify(r)) + const response = await client.chat.completions.create(requestBody) + const raw = response.choices?.[0]?.message?.content?.trim() || '' + callTrace.response = JSON.parse(JSON.stringify(response)) callTrace.rawResponseText = raw + + if (raw) { + const parsed = JSON.parse(raw) as { next_state?: string } + if (typeof parsed.next_state === 'string' && parsed.next_state.trim().length) { + chosen = parsed.next_state.trim() + } + } + } catch (err) { + callTrace.error = err instanceof Error ? err.message : String(err) + } finally { trace.calls.push(callTrace) - - const parsed = JSON.parse(raw) - - // Minimal validation - if (!parsed.next_state || typeof parsed.next_state !== 'string') { - throw new Error('Invalid next_state') - } - - console.log("LLM decision:", parsed) - - return finalize(parsed as LLMDecision) - - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e) - callTrace.error = errorMessage - trace.calls.push(callTrace) - const fallbackInfo = {used: true, reason: errorMessage} as NonNullable - trace.fallback = fallbackInfo - console.error('LLM JSON parse error, using smart fallback:', e) - - // Smart keyword-based fallback - mit Template-Variablen - const callsign = input.variables.callsign || '' - - // Pilot braucht Clearance → ATC muss antworten - if (pilotText.includes('clearance') || pilotText.includes('request clearance')) { - fallbackInfo.selected = 'clearance' - return finalize({ - next_state: 'CD_ISSUE_CLR', - off_schema: true, - controller_say_tpl: `{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.` - }) - } - - // Pilot fragt nach Taxi → ATC muss antworten - if (pilotText.includes('taxi') || pilotText.includes('pushback')) { - fallbackInfo.selected = 'taxi' - return finalize({ - next_state: 'GRD_TAXI_INSTR', - off_schema: true, - controller_say_tpl: `{callsign}, taxi to runway {runway} via {taxi_route}, hold short runway {runway}.` - }) - } - - // Pilot ready for takeoff → ATC muss antworten - if (pilotText.includes('takeoff') || pilotText.includes('ready')) { - fallbackInfo.selected = 'takeoff' - return finalize({ - next_state: 'TWR_TAKEOFF_CLR', - off_schema: true, - controller_say_tpl: `{callsign}, wind {remarks}, runway {runway} cleared for take-off.` - }) - } - - // Pilot readback or acknowledgment → no ATC response required - if (pilotText.includes('wilco') || pilotText.includes('roger') || - pilotText.includes('cleared') || pilotText.includes('copied')) { - fallbackInfo.selected = 'acknowledge' - return finalize({ - next_state: input.candidates[0]?.id || 'GEN_NO_REPLY' - // Keine controller_say_tpl - Pilot hat nur acknowledged - }) - } - - // Generic fallback - mit Template - fallbackInfo.selected = 'generic' - return finalize({ - next_state: 'GEN_NO_REPLY', - off_schema: true, - controller_say_tpl: `{callsign}, say again your last transmission.` - }) } + + if (chosen && survivors.some(candidate => candidate.id === chosen)) { + return { decision: { next_state: chosen }, trace } + } + + return { decision: { next_state: survivors[0].id }, trace } } From 648003184e9ea4622c21566796cbf7609f6a2000 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:35:23 +0200 Subject: [PATCH 6/7] Align regex auto selection trace with LLM decisions --- server/utils/openai.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 421f256..62e9e57 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -955,6 +955,8 @@ export async function routeDecision(input: LLMDecisionInput): Promise evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) ) + const trace: LLMDecisionTrace = { calls: [] } + let workingSet = regexMatches if (workingSet.length === 0) { workingSet = candidates.filter(candidate => candidate.regexTriggers.length === 0) @@ -970,14 +972,29 @@ export async function routeDecision(input: LLMDecisionInput): Promise 0 && winner.regexTriggers.length > 0) { + const patterns = winner.regexTriggers + .map(trigger => trigger?.pattern ? `/${trigger.pattern}/${trigger.patternFlags || 'i'}` : '') + .filter(pattern => Boolean(pattern)) + trace.autoSelection = { + id: winner.id, + flow: winner.flow, + reason: patterns.length + ? `Regex trigger matched ${patterns.join(', ')}` + : 'Regex trigger matched pilot utterance' + } + return { decision: { next_state: winner.id }, trace } + } + + return { decision: { next_state: winner.id } } } if (survivors.length === 0) { return { decision: { next_state: input.state_id } } } - const trace: LLMDecisionTrace = { calls: [] } const llmCandidates = survivors.map(candidate => ({ id: candidate.id, flow: candidate.flow, From 5e519cfd0357548202c7d592cc06f7537ef23d7e Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:47:05 +0200 Subject: [PATCH 7/7] Simplify decision routing to avoid unnecessary LLM calls --- server/utils/openai.ts | 96 +++++++----------------------------------- 1 file changed, 15 insertions(+), 81 deletions(-) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 62e9e57..0627ea3 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -9,7 +9,6 @@ import type { LLMDecisionInput, LLMDecisionResult, LLMDecisionTrace, - LLMDecisionTraceCall, } from '../../shared/types/llm' import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision' import { buildRuntimeDecisionSystem } from '../services/decisionFlowService' @@ -955,7 +954,10 @@ export async function routeDecision(input: LLMDecisionInput): Promise evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) ) - const trace: LLMDecisionTrace = { calls: [] } + let trace: LLMDecisionTrace | undefined + if (regexMatches.length > 0) { + trace = { calls: [] } + } let workingSet = regexMatches if (workingSet.length === 0) { @@ -975,6 +977,9 @@ export async function routeDecision(input: LLMDecisionInput): Promise 0 && winner.regexTriggers.length > 0) { + if (!trace) { + trace = { calls: [] } + } const patterns = winner.regexTriggers .map(trigger => trigger?.pattern ? `/${trigger.pattern}/${trigger.patternFlags || 'i'}` : '') .filter(pattern => Boolean(pattern)) @@ -995,86 +1000,15 @@ export async function routeDecision(input: LLMDecisionInput): Promise ({ - id: candidate.id, - flow: candidate.flow, - state: candidate.state, - })) - - const payload = { - state_id: input.state_id, - flow_slug: activeFlowSlug, - pilot_utterance: utterance, - variables: input.variables, - flags: input.flags, - candidates: llmCandidates.map(candidate => ({ - id: candidate.id, - flow: candidate.flow, - role: candidate.state?.role, - phase: candidate.state?.phase, - summary: candidate.state?.summary, - triggers: candidate.state?.triggers || [], - conditions: candidate.state?.conditions || [], - say_tpl: candidate.state?.say_tpl, - })), - } - - const requestBody = { - model: getModel(), - response_format: { - type: 'json_schema', - json_schema: { - name: 'decision', - schema: { - type: 'object', - additionalProperties: false, - properties: { - next_state: { type: 'string' }, - }, - required: ['next_state'], - }, - }, - }, - messages: [ - { - role: 'system', - content: 'You are an ATC decision assistant. Choose the best next state from the provided candidates based on the pilot utterance and context. Return JSON.', - }, - { - role: 'user', - content: JSON.stringify(payload), - }, - ], - } - - const callTrace: LLMDecisionTraceCall = { - stage: 'decision', - request: JSON.parse(JSON.stringify(requestBody)), - } - - let chosen: string | null = null - try { - const client = ensureOpenAI() - const response = await client.chat.completions.create(requestBody) - const raw = response.choices?.[0]?.message?.content?.trim() || '' - callTrace.response = JSON.parse(JSON.stringify(response)) - callTrace.rawResponseText = raw - - if (raw) { - const parsed = JSON.parse(raw) as { next_state?: string } - if (typeof parsed.next_state === 'string' && parsed.next_state.trim().length) { - chosen = parsed.next_state.trim() - } + const [first] = survivors + if (trace) { + trace.fallback = { + used: true, + reason: 'Multiple candidates matched after filtering; defaulting to first match.', + selected: first.id, } - } catch (err) { - callTrace.error = err instanceof Error ? err.message : String(err) - } finally { - trace.calls.push(callTrace) + return { decision: { next_state: first.id }, trace } } - if (chosen && survivors.some(candidate => candidate.id === chosen)) { - return { decision: { next_state: chosen }, trace } - } - - return { decision: { next_state: survivors[0].id }, trace } + return { decision: { next_state: first.id } } }