diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 3a9d9a2..b777089 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -2,17 +2,26 @@
-
+

OpenSquawk

Pilot Monitoring

Alpha Build • Decision Tree • VATSIM

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

+ {{ activeFlowInfo.description }} +

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

Current node

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

@@ -652,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.

+
@@ -696,6 +771,14 @@

{{ entry.message }}

+ + {{ entry.flow }} + {{ entry.frequency || 'N/A' }} {{ entry.state }}
@@ -953,6 +1036,10 @@ const { flags, flightContext, currentStep, + availableFlows, + activeFlow, + sessionId: engineSessionId, + lastDecisionTrace, initializeFlight, updateFrequencyVariables, fetchRuntimeTree, @@ -1023,6 +1110,51 @@ const clearLog = () => { clearLastTransmission() } +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 } +}) + +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 '' + } + 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 const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login') const loading = ref(false) @@ -1476,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) { @@ -1519,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) { @@ -1562,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/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index d7f1a88..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"; @@ -17,9 +18,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; @@ -30,12 +32,9 @@ interface PTTRequest { interface PTTResponse { success: boolean; transcription: string; - decision?: { - next_state: string; - controller_say_tpl?: string; - off_schema?: boolean; - radio_check?: boolean; - }; + decision?: LLMDecisionResult['decision']; + trace?: LLMDecisionResult['trace']; + active_nodes?: LLMDecisionResult['active_nodes']; } async function sh(cmd: string, args: string[]) { @@ -225,6 +224,7 @@ export default defineEventHandler(async (event) => { return { id: candidate.id, + flow: candidate.flow || undefined, state: candidateState }; }) @@ -232,12 +232,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, @@ -267,6 +272,12 @@ export default defineEventHandler(async (event) => { if (decision) { result.decision = decision; } + if (decisionResult?.trace) { + result.trace = decisionResult.trace; + } + if (decisionResult?.active_nodes?.length) { + result.active_nodes = decisionResult.active_nodes; + } 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/middleware/auth.global.ts b/server/middleware/auth.global.ts index 52808e7..951c7a4 100644 --- a/server/middleware/auth.global.ts +++ b/server/middleware/auth.global.ts @@ -12,6 +12,9 @@ export default defineEventHandler(async (event) => { if (url.pathname.startsWith('/api/bridge/')) { return } + if (url.pathname === '/api/decision-flows/runtime') { + return + } if (event.node.req.method === 'OPTIONS') { return } 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/utils/openai.ts b/server/utils/openai.ts index c3961e9..0627ea3 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, + FlowActivationMode, + LLMDecisionInput, + LLMDecisionResult, + LLMDecisionTrace, +} 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 @@ -62,27 +72,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 -} type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' @@ -200,6 +189,562 @@ 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 + triggers: DecisionNodeTrigger[] + regexTriggers: DecisionNodeTrigger[] + noneTriggers: DecisionNodeTrigger[] +} + +interface PreparedCandidateResult { + finalCandidates: DecisionCandidate[] + candidateFlowMap: Map + candidateIndex: Map + finalCandidateIndex: Map + activeFlowSlug: string + flowEntryModes: Map + timeline: DecisionCandidateTimeline + autoSelected?: DecisionCandidate | null +} + +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): { + result: boolean + left: any + right: any + operator: string +} { + const normalizedLeft = normalizeComparable(left) + const normalizedRight = normalizeComparable(parseComparable(right)) + const op = operator || '==' + let result = false + switch (op) { + case '>': + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + ? normalizedLeft > normalizedRight + : false + break + case '>=': + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + ? normalizedLeft >= normalizedRight + : false + break + case '<': + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + ? normalizedLeft < normalizedRight + : false + break + case '<=': + result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' + ? normalizedLeft <= normalizedRight + : false + break + case '!==': + case '!=': + result = normalizedLeft !== normalizedRight + break + case '===': + case '==': + default: + result = normalizedLeft === normalizedRight + break + } + return { result, left: normalizedLeft, right: normalizedRight, operator: op } +} + +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 +): { passed: boolean; detail?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { + if (!condition) return { passed: true } + switch (condition.type) { + 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 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, + }, + } + } + } +} + +function evaluateConditionList( + conditions: DecisionNodeCondition[] | undefined, + context: { variables: Record; flags: Record }, + utterance: string +): { passed: boolean; failure?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { + if (!Array.isArray(conditions) || conditions.length === 0) { + return { passed: true } + } + const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0)) + for (const condition of ordered) { + 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 { passed: true } +} + +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 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 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 || []) { + 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 + addCandidate(raw.id, flow, state) + } + + for (const raw of input.candidates || []) { + if (!raw?.id || !raw.state) continue + if (!candidateMap.has(raw.id)) { + addCandidate(raw.id, raw.flow || activeFlowSlug, raw.state) + } + } + + for (const [flowSlug, tree] of Object.entries(system.flows || {})) { + const startStateId = tree.start_state + if (!startStateId) continue + const indexed = index.get(startStateId) + const state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId] + addCandidate(startStateId, flowSlug, state) + } + + const candidates = Array.from(candidateMap.values()) + const context = { variables: input.variables || {}, flags: input.flags || {} } + 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 finalCandidates) { + if (candidate.flow) { + candidateFlowMap.set(candidate.id, candidate.flow) + } + } + + const finalCandidateIndex = new Map() + for (const candidate of finalCandidates) { + finalCandidateIndex.set(candidate.id, candidate) + } + + const timeline: DecisionCandidateTimeline = { + steps: timelineSteps, + fallbackUsed, + autoSelected: autoSelected ? toTraceEntry(autoSelected) : null, + } + + return { + finalCandidates, + candidateFlowMap, + candidateIndex: candidateMap, + finalCandidateIndex, + activeFlowSlug, + flowEntryModes, + timeline, + autoSelected, + } +} + function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null { const rawValue = input.variables?.[key] if (rawValue !== undefined && rawValue !== null) { @@ -325,294 +870,145 @@ 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: []} + const utterance = (input.pilot_utterance || '').trim() + const { system, index } = await getRuntimeSystemIndex() - const finalize = (decision: LLMDecision): LLMDecisionResult => { - if (!trace.calls.length && !trace.fallback) { - return {decision} - } - return {decision, trace} - } + const currentEntry = index.get(input.state_id) + const activeFlowSlug = + input.flow_slug + || currentEntry?.flow + || system.main + || Object.keys(system.flows || {})[0] + || '' - 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 candidateMap = new Map() + const addCandidate = ( + id?: string, + flow?: string, + providedState?: RuntimeDecisionState + ) => { + if (!id || candidateMap.has(id)) { + return } - const sanitizedPilot = sanitizeForQuickMatch(pilotUtterance) - const heuristicsOk = expectedItems.every(item => { - const sanitizedValue = sanitizeForQuickMatch(item.value) - return sanitizedValue ? sanitizedPilot.includes(sanitizedValue) : true - }) - - if (heuristicsOk && okNext) { - return finalize({next_state: okNext}) + const indexed = index.get(id) + const state = providedState || indexed?.state + if (!state) { + return } - 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 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 - 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 (input.state?.auto === 'check_readback') { - return await handleReadbackCheck() - } - - 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}) - } - } - - // Instant detection without the LLM for common cases - if (pilotText.includes('radio check') || pilotText.includes('signal test') || - (pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) { - return finalize({ - next_state: input.state_id, - radio_check: true, - controller_say_tpl: `${input.variables.callsign}, read you five by five.` + candidateMap.set(id, { + id, + flow: flowSlug, + state, + triggers, + regexTriggers, + noneTriggers, }) } - // Emergency ohne LLM - if (pilotText.startsWith('mayday') && input.flags.in_air) { - return finalize({next_state: 'INT_MAYDAY'}) - } - if (pilotText.startsWith('pan pan') && input.flags.in_air) { - return finalize({next_state: 'INT_PANPAN'}) + 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) + } } - const optimizedInput = optimizeInputForLLM(input) + for (const raw of input.candidates || []) { + if (!raw?.id) continue + addCandidate(raw.id, raw.flow, raw.state) + } - // 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_') + 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) + } + + let candidates = Array.from(candidateMap.values()) + if (candidates.length === 0) { + return { decision: { next_state: input.state_id } } + } + + 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 trace: LLMDecisionTrace | undefined + if (regexMatches.length > 0) { + trace = { calls: [] } } - // 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(' ') - - // Update optimized input to indicate which candidates need ATC responses - optimizedInput.atc_candidates = atcCandidates.map(c => c.id) - - const user = JSON.stringify(optimizedInput) - - const body = { - model: getModel(), - response_format: {type: 'json_object'}, - messages: [ - {role: 'system', content: system}, - {role: 'user', content: user} - ] + let workingSet = regexMatches + if (workingSet.length === 0) { + workingSet = candidates.filter(candidate => candidate.regexTriggers.length === 0) } - const callTrace: LLMDecisionTraceCall = { - stage: 'decision', - request: JSON.parse(JSON.stringify(body)) + if (workingSet.length === 0) { + return { decision: { next_state: input.state_id } } } - try { - const client = ensureOpenAI() + const context = { variables: input.variables || {}, flags: input.flags || {} } + const survivors = workingSet.filter(candidate => + evaluateConditionList(candidate.state?.conditions, context, utterance).passed + ) - console.log("calling LLM with body:", body) + if (survivors.length === 1) { + const [winner] = survivors - const r = await client.chat.completions.create(body) - - const raw = r.choices?.[0]?.message?.content || '{}' - callTrace.response = JSON.parse(JSON.stringify(r)) - callTrace.rawResponseText = raw - 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') + if (regexMatches.length > 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)) + 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 } } - 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.` - }) + return { decision: { next_state: winner.id } } } + + if (survivors.length === 0) { + return { decision: { next_state: input.state_id } } + } + + const [first] = survivors + if (trace) { + trace.fallback = { + used: true, + reason: 'Multiple candidates matched after filtering; defaulting to first match.', + selected: first.id, + } + return { decision: { next_state: first.id }, trace } + } + + return { decision: { next_state: first.id } } } diff --git a/shared/types/decision.ts b/shared/types/decision.ts index a5d5f31..70ed290 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 @@ -97,6 +126,8 @@ export interface DecisionNodeMetadata { complexity?: 'low' | 'medium' | 'high' } +export type DecisionFlowEntryMode = 'parallel' | 'linear' + export interface DecisionNodeModel { stateId: string title?: string @@ -114,6 +145,8 @@ export interface DecisionNodeModel { trigger?: string frequency?: string frequencyName?: string + triggers?: DecisionNodeTrigger[] + conditions?: DecisionNodeCondition[] transitions: DecisionNodeTransition[] layout?: DecisionNodeLayout metadata?: DecisionNodeMetadata @@ -166,6 +199,8 @@ export interface DecisionFlowModel { createdAt: string updatedAt: string nodeCount?: number + entryMode?: DecisionFlowEntryMode + isMain?: boolean } export interface RuntimeDecisionAutoTransition { @@ -182,6 +217,8 @@ export interface RuntimeDecisionAutoTransition { export interface RuntimeDecisionState { role: DecisionNodeRole phase: string + name?: string + summary?: string say_tpl?: string utterance_tpl?: string else_say_tpl?: string @@ -198,10 +235,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 @@ -214,6 +254,13 @@ export interface RuntimeDecisionTree { roles: DecisionNodeRole[] phases: string[] states: Record + entry_mode?: 'main' | DecisionFlowEntryMode +} + +export interface RuntimeDecisionSystem { + main: string + order: string[] + flows: Record } export interface DecisionFlowSummary { @@ -225,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 baad63a..a443eca 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -1,10 +1,83 @@ +import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision' + 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 type FlowActivationMode = 'main' | 'parallel' | 'linear' + +export interface FlowActivationInstruction { + slug: string + 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 + 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 { @@ -14,4 +87,37 @@ export interface LLMDecision { controller_say_tpl?: string off_schema?: boolean radio_check?: boolean + 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 + } +} + +export interface LLMDecisionResult { + decision: LLMDecision + trace?: LLMDecisionTrace + active_nodes?: ActiveNodeSummary[] } diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index bac5a37..252da90 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,11 +1,13 @@ // communicationsEngine composable -import { ref, computed, readonly } from 'vue' +import { ref, computed, readonly, reactive } from 'vue' import type { RuntimeDecisionTree, + RuntimeDecisionSystem, RuntimeDecisionState, RuntimeDecisionAutoTransition, DecisionNodeAutoTrigger, } from '../types/decision' +import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm' import { normalizeRadioPhrase } from './radioSpeech' // --- DecisionTree runtime types --- @@ -19,6 +21,7 @@ interface EngineFlags { stack: string[] off_schema_count: number radio_checks_done: number + session_id: string [key: string]: any } @@ -93,6 +96,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 = { @@ -106,11 +122,42 @@ 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) } +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,8 +168,17 @@ function renderTpl(tpl: string, ctx: Record): string { } 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>({}) const states = computed>(() => tree.value?.states ?? {}) @@ -148,31 +204,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 +216,198 @@ 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 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 + } + } + + 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, + 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, + 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 + } + + 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 [] @@ -210,69 +435,141 @@ 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) + } + + 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] + ? 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, + session_id: ensureSessionValue(), + }) + 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 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 + .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, + mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'), + } + }) + }) + 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 +714,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 +745,7 @@ export default function useCommunicationsEngine() { remarks: 'standard', time_now: new Date().toISOString() } + assignActiveVariables(nextVariables) // Update flight context Object.assign(flightContext.value, { @@ -455,19 +753,21 @@ export default function useCommunicationsEngine() { phase: 'clearance' }) - flags.value = { + const nextFlags: EngineFlags = { ...flags.value, in_air: false, emergency_active: false, 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) - 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 +795,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,14 +806,18 @@ export default function useCommunicationsEngine() { flags: { ...flags.value }, pilot_utterance: pilotTranscript, tree: runtime.name, + flow_slug: runtime.slug, } } - 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) } @@ -530,6 +834,17 @@ export default function useCommunicationsEngine() { flags.value.stack = decision.stack.slice() } + 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) + } + } + } + if (decision.off_schema) { flags.value.off_schema_count++ console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`) @@ -555,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) } @@ -569,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 { @@ -624,7 +919,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 +988,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 @@ -749,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 = { @@ -764,7 +1079,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 +1171,11 @@ export default function useCommunicationsEngine() { nextCandidates, activeFrequency, communicationLog: readonly(communicationLog), - clearCommunicationLog: () => { communicationLog.value = [] }, + clearCommunicationLog: () => { assignCommunicationLog([]) }, + activeFlow, + availableFlows, + sessionId: readonly(sessionId), + lastDecisionTrace: readonly(lastDecisionTrace), // pm_alt.vue integration flightContext: readonly(flightContext), @@ -865,7 +1185,9 @@ export default function useCommunicationsEngine() { initializeFlight, updateFrequencyVariables, loadRuntimeTree, + loadRuntimeSystem, fetchRuntimeTree, + setActiveFlow, isReady, // Communication @@ -876,7 +1198,6 @@ export default function useCommunicationsEngine() { // Flow Control moveTo, - resumePriorFlow, // Utilities normalizeATCText,