From 6c9f467b94f1223900d1ad3242b9e14b2a3854da 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] Enable flow-aware decision routing --- app/pages/pm.vue | 74 ++++- server/api/atc/ptt.post.ts | 5 +- server/middleware/auth.global.ts | 3 + server/utils/openai.ts | 336 +++++++++++++++++++++++ shared/types/decision.ts | 40 +++ shared/types/llm.ts | 5 +- shared/utils/communicationsEngine.ts | 390 ++++++++++++++++++++++----- 7 files changed, 771 insertions(+), 82 deletions(-) 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/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/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/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