From ea4b1e0b24fa448b98c0ef5bb6ba3bbca3663486 Mon Sep 17 00:00:00 2001 From: leubeem Date: Wed, 20 May 2026 14:13:26 +0200 Subject: [PATCH] Cleanup old unused code and add id sessionId to /api/atc/ptt --- app/pages/pm.vue | 14 +- server/api/atc/ptt.post.ts | 183 +---- server/middleware/auth.global.ts | 3 - server/utils/openai.ts | 1102 +----------------------------- 4 files changed, 29 insertions(+), 1273 deletions(-) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index a16bd6a..692940f 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -2126,17 +2126,10 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { if (isIntercom) { const result = await api.post('/api/atc/ptt', { audio: base64Audio, - context: { - state_id: currentState.value?.id || 'INTERCOM', - state: {}, - candidates: [], - variables: { callsign: vars.value.callsign }, - flags: {} - }, moduleId: 'pilot-monitoring-intercom', lessonId: 'intercom', format: 'webm', - autoDecide: false + sessionId: backendSessionId.value || undefined, }) if (result.success) { @@ -2152,15 +2145,12 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { } } } else { - const ctx = buildLLMContext('') - const result = await api.post('/api/atc/ptt', { audio: base64Audio, - context: ctx, moduleId: 'pilot-monitoring', lessonId: currentState.value?.id || 'general', format: 'webm', - autoDecide: false + sessionId: backendSessionId.value || undefined, }) if (result.success) { diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 7674853..6760663 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -5,8 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { getOpenAIClient, routeDecision } from "../../utils/openai"; -import type { LLMDecisionResult } from "~~/shared/types/llm"; +import { getOpenAIClient } from "../../utils/openai"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; @@ -15,26 +14,20 @@ type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm' interface PTTRequest { audio: string; // Base64 encoded audio - context: { - state_id: string; - state: any; - candidates: Array<{ id: string; state: any; flow?: string }>; - variables: Record; - flags: Record; - flow_slug?: string; - }; moduleId: string; lessonId: string; format?: AudioFormat; - autoDecide?: boolean; + sessionId?: string; // Python backend session ID — used for TransmissionLog correlation + context?: { // Legacy field; kept for backwards compat but not used for routing + state_id?: string; + flags?: Record; + [key: string]: any; + }; } interface PTTResponse { success: boolean; transcription: string; - decision?: LLMDecisionResult['decision']; - trace?: LLMDecisionResult['trace']; - active_nodes?: LLMDecisionResult['active_nodes']; } async function sh(cmd: string, args: string[]) { @@ -46,14 +39,11 @@ async function sh(cmd: string, args: string[]) { } const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; -const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono -const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm']; -const AUDIO_FORMAT_SET = new Set(ALLOWED_AUDIO_FORMATS); +const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 seconds 16kHz mono +const AUDIO_FORMAT_SET = new Set(['wav', 'mp3', 'ogg', 'webm']); function resolveAudioFormat(format?: string | null): AudioFormat { - if (!format) { - return 'wav'; - } + if (!format) return 'wav'; const normalized = format.trim().toLowerCase() as AudioFormat; return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav'; } @@ -76,37 +66,23 @@ function decodeAudioPayload(encoded: string): Buffer { return buffer; } -// Convert audio to WAV for better Whisper compatibility async function convertToWav(inputPath: string, outputPath: string) { await sh("ffmpeg", [ "-y", "-i", inputPath, - "-ar", "16000", // 16 kHz for Whisper - "-ac", "1", // Mono + "-ar", "16000", + "-ac", "1", "-f", "wav", outputPath ]); } -function safeClone(value: T): T | undefined { - if (value === undefined) { - return undefined; - } - - try { - return JSON.parse(JSON.stringify(value)); - } catch (err) { - console.warn("Failed to clone value for transmission metadata", err); - return undefined; - } -} - export default defineEventHandler(async (event) => { const body = await readBody(event); - if (!body.audio || !body.context || !body.moduleId || !body.lessonId) { + if (!body.audio || !body.moduleId || !body.lessonId) { throw createError({ statusCode: 400, - statusMessage: "audio, context, moduleId, and lessonId are required" + statusMessage: "audio, moduleId, and lessonId are required" }); } @@ -116,11 +92,9 @@ export default defineEventHandler(async (event) => { const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`); try { - // 1. Decode audio from base64 and save const audioBuffer = decodeAudioPayload(body.audio); await writeFile(tmpAudioInput, audioBuffer); - // 2. Convert to WAV if needed (only when FFmpeg is available) let audioFileForWhisper = tmpAudioInput; if (format !== 'wav') { try { @@ -131,7 +105,6 @@ export default defineEventHandler(async (event) => { } } - // 3. OpenAI Whisper for transcription const openai = getOpenAIClient(); const transcription = await openai.audio.transcriptions.create({ file: createReadStream(audioFileForWhisper), @@ -143,98 +116,20 @@ export default defineEventHandler(async (event) => { const transcribedText = transcription.text.trim(); if (!transcribedText) { - throw createError({ - statusCode: 400, - statusMessage: "No speech detected in audio" - }); + throw createError({ statusCode: 400, statusMessage: "No speech detected in audio" }); } - const shouldAutoDecide = body.autoDecide !== false; - - let decisionResult: LLMDecisionResult | null = null; - let decision: PTTResponse['decision']; - - if (shouldAutoDecide) { - // 4. Call the LLM decision directly with the transcribed text - const decisionInput = { - ...body.context, - pilot_utterance: transcribedText - }; - - decisionResult = await routeDecision(decisionInput); - decision = decisionResult.decision; - } - - // 5. Cleanup await rm(tmpAudioInput).catch(() => {}); if (audioFileForWhisper !== tmpAudioInput) { await rm(tmpAudioWav).catch(() => {}); } try { - const user = await getUserFromEvent(event) - - const llmCallCount = decisionResult?.trace?.calls?.length || 0; - const fallbackUsed = Boolean(decisionResult?.trace?.fallback?.used); - - let llmStrategy: 'manual' | 'openai' | 'heuristic' | 'fallback' = 'manual'; - if (shouldAutoDecide) { - if (llmCallCount > 0) { - llmStrategy = 'openai'; - } else if (fallbackUsed) { - llmStrategy = 'fallback'; - } else { - llmStrategy = 'heuristic'; - } - } - - const llmUsage = { - autoDecide: shouldAutoDecide, - openaiUsed: llmStrategy === 'openai', - callCount: llmCallCount, - fallbackUsed, - strategy: llmStrategy, - reason: - llmStrategy === 'manual' - ? 'Automatic decision disabled in request.' - : llmStrategy === 'openai' - ? `Decision derived from OpenAI with ${llmCallCount} call(s).` - : llmStrategy === 'fallback' - ? (decisionResult?.trace?.fallback?.reason || 'Fallback triggered after OpenAI failure.') - : 'Decision resolved locally without calling OpenAI.' - }; - - const contextState = safeClone(body.context.state); - if (contextState && typeof contextState === 'object' && contextState !== null) { - const stateRecord = contextState as Record; - if (!('id' in stateRecord)) { - stateRecord.id = body.context.state_id; - } - } - - const contextCandidates = Array.isArray(body.context.candidates) - ? body.context.candidates.map(candidate => { - const candidateState = safeClone(candidate.state); - if (candidateState && typeof candidateState === 'object' && candidateState !== null) { - const candidateRecord = candidateState as Record; - if (!('id' in candidateRecord)) { - candidateRecord.id = candidate.id; - } - } - - return { - id: candidate.id, - flow: candidate.flow || undefined, - state: candidateState - }; - }) - : undefined; - - 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; + const user = await getUserFromEvent(event); + // Prefer the explicit top-level sessionId (Python backend session). + // Fall back to the legacy context.flags.session_id for older clients. + const sessionId = body.sessionId + ?? (typeof body.context?.flags?.session_id === 'string' ? body.context.flags.session_id : undefined); await TransmissionLog.create({ user: user?._id, @@ -246,49 +141,19 @@ export default defineEventHandler(async (event) => { metadata: { moduleId: body.moduleId, lessonId: body.lessonId, - decision, - decisionTrace: decisionResult?.trace, - autoDecide: shouldAutoDecide, - llm: llmUsage, - context: { - stateId: body.context.state_id, - state: contextState, - candidates: contextCandidates, - selectedCandidate, - variables: safeClone(body.context.variables), - flags: safeClone(body.context.flags) - } }, - }) + }); } catch (logError) { - console.warn("Transmission logging failed", logError) + console.warn("Transmission logging failed", logError); } - const result: PTTResponse = { - success: true, - transcription: transcribedText - }; - - 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; + return { success: true, transcription: transcribedText } satisfies PTTResponse; } catch (error: any) { - // Cleanup on error await rm(tmpAudioInput).catch(() => {}); await rm(tmpAudioWav).catch(() => {}); - if (error.statusCode) { - throw error; - } + if (error.statusCode) throw error; throw createError({ statusCode: 500, diff --git a/server/middleware/auth.global.ts b/server/middleware/auth.global.ts index ba90abf..9f030c6 100644 --- a/server/middleware/auth.global.ts +++ b/server/middleware/auth.global.ts @@ -18,9 +18,6 @@ export default defineEventHandler(async (event) => { if (url.pathname.startsWith('/api/copilot/')) { 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 9352424..74f01e7 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,29 +1,17 @@ // server/utils/openai.ts import OpenAI from 'openai' -import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' -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 -let cachedModel: string | null = null function ensureOpenAI(): OpenAI { if (!openaiClient) { - const {openaiKey, openaiProject, openaiBaseUrl, llmModel} = getServerRuntimeConfig() + const {openaiKey, openaiProject, openaiBaseUrl} = getServerRuntimeConfig() if (!openaiKey) { throw new Error('OPENAI_API_KEY is missing. Please set the key before using AI features.') } - const clientOptions: ConstructorParameters[0] = {apiKey: openaiKey, + const clientOptions: ConstructorParameters[0] = { + apiKey: openaiKey, defaultHeaders: { 'Connection': 'keep-alive' }, } if (openaiProject) { @@ -32,1095 +20,11 @@ function ensureOpenAI(): OpenAI { if (openaiBaseUrl) { clientOptions.baseURL = openaiBaseUrl } - console.log("using connection opened client") openaiClient = new OpenAI(clientOptions) - cachedModel = llmModel } - console.log("returning existing openai client") return openaiClient } -function getModel(): string { - if (!cachedModel) { - const {llmModel} = getServerRuntimeConfig() - cachedModel = llmModel - } - return cachedModel -} - export function getOpenAIClient(): OpenAI { return ensureOpenAI() } - -export async function decide(system: string, user: string): Promise { - const client = ensureOpenAI() - const model = getModel() - const r = await client.chat.completions.create({ - model, - messages: [ - {role: 'system', content: system}, - {role: 'user', content: user} - ] - }) - return r.choices?.[0]?.message?.content?.trim() || '' -} - - -type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' - -const READBACK_REQUIREMENTS: Record = { - CD_READBACK_CHECK: ['dest', 'sid', 'runway', 'initial_altitude_ft', 'squawk'], - GRD_TAXI_READBACK_CHECK: ['runway', 'taxi_route', 'hold_short'], - TWR_TAKEOFF_READBACK_CHECK: ['runway', 'cleared_takeoff'], - GRD_TAXI_IN_READBACK_CHECK: ['gate', 'taxi_route'] -} - -const READBACK_JSON_SCHEMA = { - name: 'readback_check', - schema: { - type: 'object', - additionalProperties: false, - properties: { - status: { - type: 'string', - enum: ['ok', 'missing', 'incorrect', 'uncertain'] - }, - missing: { - type: 'array', - items: {type: 'string'}, - default: [] - }, - incorrect: { - type: 'array', - items: {type: 'string'}, - default: [] - }, - confidence: { - type: 'number' - }, - notes: { - type: 'string' - } - }, - required: ['status'] - } -} as const - -function sanitizeForQuickMatch(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() -} - -function buildSpokenVariants(key: string, value: string): string[] { - const normalized = String(value ?? '').trim() - if (!normalized) return [] - - const variants = new Set() - variants.add(normalized) - variants.add(normalized.toUpperCase()) - - if (key === 'hold_short') { - const base = normalized.replace(/^holding\s+short/i, 'hold short') - variants.add(base) - if (!/\brunway\b/i.test(base)) { - variants.add(base.replace(/^(hold short)/i, '$1 runway')) - } - } - - if (key === 'cleared_takeoff') { - variants.add(normalized.replace(/take-off/gi, 'takeoff')) - variants.add(normalized.replace(/take-off/gi, 'take off')) - } - - if (/^[A-Z]{3,4}$/.test(normalized.toUpperCase())) { - variants.add(toIcaoPhonetic(normalized)) - } - - if (/^\d{4}$/.test(normalized)) { - variants.add(normalized.split('').join(' ')) - variants.add(spellIcaoDigits(normalized)) - } - - if (/^\d{1,2}[LCR]?$/i.test(normalized)) { - const digits = normalized.match(/\d+/)?.[0] ?? '' - const spelledDigits = spellIcaoDigits(digits) - const suffix = normalized.replace(/\d+/g, '').toUpperCase() - const suffixWord = suffix === 'L' ? 'left' : suffix === 'R' ? 'right' : suffix === 'C' ? 'center' : '' - - variants.add(`runway ${normalized}`) - if (spelledDigits) { - variants.add(`runway ${spelledDigits}${suffixWord ? ` ${suffixWord}` : ''}`) - } - } - - if (key.includes('altitude') || key.includes('level')) { - const digits = normalized.replace(/[^0-9]/g, '') - if (digits) { - const spaced = digits.split('').join(' ') - variants.add(spaced) - variants.add(digits) - variants.add(spellIcaoDigits(digits)) - } - } - - return Array.from(variants) -} - -/** - * Quick heuristic readback check: verifies that the pilot's utterance - * contains the required fields (dest, runway, squawk, etc.) by matching - * against spoken variants of the expected values. - * Returns 'ok' if all required fields are present, 'missing' with the - * list of missing keys otherwise. - */ -function quickReadbackCheck( - utterance: string, - readbackKeys: string[], - variables: Record -): { status: 'ok' | 'missing'; missing: string[] } { - if (!readbackKeys.length) return { status: 'ok', missing: [] } - - const sanitized = sanitizeForQuickMatch(utterance) - const missing: string[] = [] - - for (const key of readbackKeys) { - const expected = resolveReadbackValue(key, { variables } as any) - if (!expected) continue // Can't verify if no expected value - - const variants = buildSpokenVariants(key, expected) - const found = variants.some(variant => - sanitized.includes(sanitizeForQuickMatch(variant)) - ) - if (!found) { - missing.push(key) - } - } - - return { - status: missing.length === 0 ? 'ok' : 'missing', - missing, - } -} - -function pickTransition( - transitions: Array<{ to: string }> | undefined, - candidates: Array<{ id: string; state: any }> -): string | null { - if (!transitions?.length) return null - for (const option of transitions) { - if (candidates.some(c => c.id === option.to)) { - return option.to - } - } - return null -} - -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) - } - } - - // Note: Previously, all flow start states were added as candidates here. - // This was removed because it polluted the candidate pool and caused the - // LLM to pick unrelated flow starts. Flow switches should be defined via - // explicit transitions in the decision tree instead. - - 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) { - const trimmed = `${rawValue}`.trim() - if (trimmed.length > 0) { - return trimmed - } - } - - switch (key) { - case 'hold_short': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `holding short ${runway}`.trim() - } - return 'holding short' - } - case 'cleared_takeoff': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `cleared for take-off ${runway}`.trim() - } - return 'cleared for take-off' - } - case 'cleared_to_land': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `cleared to land runway ${runway}`.trim() - } - return 'cleared to land' - } - default: - return null - } -} - -// Extrahiere verwendete Variablen aus Templates -function extractTemplateVariables(text?: string): string[] { - if (!text) return [] - const matches = text.match(/\{([^}]+)\}/g) || [] - return matches.map(match => match.slice(1, -1)) // Remove { } -} - -// Optimized yet sufficient input for reliable decisions -function optimizeInputForLLM(input: LLMDecisionInput) { - // Collect all available variables from the decision tree - const availableVariables = [ - 'callsign', 'dest', 'dep', 'runway', 'squawk', 'sid', 'transition', - 'initial_altitude_ft', 'climb_altitude_ft', 'cruise_flight_level', - 'taxi_route', 'stand', 'gate', 'atis_code', 'qnh_hpa', - 'ground_freq', 'tower_freq', 'departure_freq', 'approach_freq', 'handoff_freq', - 'star', 'approach_type', 'remarks', 'acf_type' - ] - - const readbackKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || [] - - const stateSummary = { - id: input.state_id, - role: input.state.role, - phase: input.state.phase, - auto: input.state.auto ?? null, - say_tpl: input.state.say_tpl ?? null, - utterance_tpl: input.state.utterance_tpl ?? null, - readback_keys: readbackKeys, - next: (input.state.next ?? []).map((n: any) => n.to), - ok_next: (input.state.ok_next ?? []).map((n: any) => n.to), - bad_next: (input.state.bad_next ?? []).map((n: any) => n.to) - } - - // Relevante Candidate-Daten mit Template-Variablen - const candidates = input.candidates.map(c => { - const templateVars = extractTemplateVariables(c.state.say_tpl) - const candidateReadback = READBACK_REQUIREMENTS[c.id] || c.state.readback_required || [] - const requiresResponse = - c.state.role === 'atc' || - Boolean(c.state.say_tpl) || - Boolean(candidateReadback.length) || - c.id.startsWith('INT_') - return { - id: c.id, - role: c.state.role, - phase: c.state.phase, - template_vars: templateVars, // Welche Variablen dieser State verwendet - auto: c.state.auto ?? null, - requires_atc_reply: requiresResponse, - readback_keys: candidateReadback, - has_say_tpl: Boolean(c.state.say_tpl), - has_utterance_tpl: Boolean(c.state.utterance_tpl), - handoff: c.state.handoff ? { - to: c.state.handoff.to, - freq: c.state.handoff.freq ?? null - } : null - } - }) - - // Sammle alle Template-Variablen aus den Candidates - const candidateVars = new Set() - candidates.forEach(c => c.template_vars?.forEach(v => candidateVars.add(v))) - - return { - state_id: input.state_id, - current_phase: input.state.phase, - current_role: input.state.role, - state_summary: stateSummary, - candidates: candidates, - available_variables: availableVariables, // All available variables - candidate_variables: Array.from(candidateVars), // Variablen die Candidates verwenden - pilot_utterance: input.pilot_utterance, - decision_hints: { - expecting_pilot_call: input.state.role === 'pilot', - state_auto: input.state.auto ?? null, - current_unit: input.flags.current_unit, - has_interrupt_candidate: input.candidates.some(c => c.id.startsWith('INT_')), - readback_check_state: Boolean(readbackKeys.length) - }, - // Current context only without values (to save tokens) - context: { - callsign: input.variables.callsign, - current_unit: input.flags.current_unit, - in_air: input.flags.in_air, - phase: input.state.phase - } - } -} - - -function summarizeCandidateForPrompt(candidate: DecisionCandidate) { - const { state } = candidate - return { - id: candidate.id, - flow: candidate.flow, - role: state?.role, - phase: state?.phase, - summary: state?.summary, - say_tpl: state?.say_tpl, - utterance_tpl: state?.utterance_tpl, - handoff: state?.handoff, - } -} - -function extractJsonObject(text: string): any | null { - if (!text) return null - const trimmed = text.trim() - try { - return JSON.parse(trimmed) - } catch {} - - const match = trimmed.match(/\{[\s\S]*\}/) - if (!match) { - return null - } - try { - return JSON.parse(match[0]) - } catch { - return null - } -} - -function buildDecisionObject(stateId: string, candidate: DecisionCandidate | undefined, index: Map): LLMDecisionResult['decision'] { - const decision: LLMDecisionResult['decision'] = { next_state: stateId } - // Attach the say_tpl from the chosen state so the frontend can speak it - // without an extra lookup. Checks the candidate first, then the runtime index. - const sayTpl = candidate?.state?.say_tpl ?? index.get(stateId)?.state?.say_tpl - if (sayTpl) { - decision.controller_say_tpl = sayTpl - } - return decision -} - -export async function routeDecision(input: LLMDecisionInput): Promise { - const utterance = (input.pilot_utterance || '').trim() - const prepared = await prepareDecisionCandidates(input, utterance) - const { index } = await getRuntimeSystemIndex() - - const trace: LLMDecisionTrace = { - calls: [], - candidateTimeline: prepared.timeline, - } - let pilotIntent: string | null = null - - // Heuristic readback check: if the current state requires a readback - // (e.g. pilot just read back a clearance), verify the required fields - // are present in the utterance BEFORE routing. This catches obvious - // readback errors without needing an LLM call. - const readbackKeys = READBACK_REQUIREMENTS[input.state_id] || input.state?.readback_required || [] - if (readbackKeys.length > 0 && utterance) { - const check = quickReadbackCheck(utterance, readbackKeys, input.variables || {}) - if (check.status === 'missing' && check.missing.length > 0) { - // Readback incomplete — try to route to bad_next (repeat instruction) - const badTargets = (input.state?.bad_next ?? []).map((t: any) => t?.to).filter(Boolean) - const badCandidate = badTargets.length > 0 - ? prepared.candidateIndex.get(badTargets[0]) ?? null - : null - if (badCandidate) { - trace.autoSelection = { - id: badCandidate.id, - flow: badCandidate.flow, - reason: `Readback missing fields: ${check.missing.join(', ')}`, - } - return { - decision: buildDecisionObject(badCandidate.id, badCandidate, index), - trace, - pilot_intent: 'incomplete_readback', - } - } - } - } - - if (prepared.autoSelected) { - trace.autoSelection = { - id: prepared.autoSelected.id, - flow: prepared.autoSelected.flow, - reason: 'Heuristic routing resolved a single remaining candidate.', - } - return { - decision: buildDecisionObject(prepared.autoSelected.id, prepared.autoSelected, index), - trace, - pilot_intent: pilotIntent, - } - } - - const candidatePool = prepared.finalCandidates.length > 0 - ? prepared.finalCandidates - : Array.from(prepared.candidateIndex.values()) - - if (candidatePool.length === 0) { - const fallbackState = fallbackNextState(input) - trace.fallback = { - used: true, - reason: 'No viable candidates after heuristic evaluation; falling back to default transition.', - selected: fallbackState, - } - return { decision: buildDecisionObject(fallbackState, undefined, index), trace, pilot_intent: pilotIntent } - } - - const optimizedInput = optimizeInputForLLM({ - ...input, - candidates: candidatePool.map(candidate => ({ - id: candidate.id, - flow: candidate.flow, - state: candidate.state, - })), - }) - - const candidateSummaries = candidatePool - .map(candidate => { - const summary = [ - `${candidate.id}`, - candidate.state?.summary || candidate.state?.say_tpl || candidate.state?.utterance_tpl || '', - ] - .filter(Boolean) - .join(' — ') - return `- ${summary}` - }) - .join('\n') - - const systemPrompt = [ - 'You are an assistant that selects the correct next state in an aviation decision tree.', - 'Evaluate the pilot transmission and choose the most appropriate candidate state id from the provided list.', - [ - 'Respond strictly with a JSON object whose first property is "pilot_intent"', - 'followed by "next_state" and "reason": {"pilot_intent": "intent", "next_state": "STATE_ID", "reason": "short rationale"}.', - ].join(' '), - 'Only use state ids that were provided. If you cannot decide, choose the best heuristic option.', - ].join(' ') - - const userPrompt = [ - `Pilot transmission: "${utterance || '(silence)'}"`, - 'Candidate options:', - candidateSummaries, - 'Context (JSON):', - JSON.stringify(optimizedInput, null, 2), - ].join('\n') - - const callEntry: LLMDecisionTrace['calls'][number] = { - stage: 'decision' as const, - request: { - systemPrompt, - userPrompt, - candidates: candidatePool.map(summarizeCandidateForPrompt), - }, - } - trace.calls.push(callEntry) - - try { - const rawResponse = await decide(systemPrompt, userPrompt) - callEntry.rawResponseText = rawResponse - const parsed = extractJsonObject(rawResponse) - - if (parsed && typeof parsed === 'object') { - callEntry.response = parsed - - if (typeof (parsed as any).pilot_intent === 'string') { - const intentValue = ((parsed as any).pilot_intent as string).trim() - if (intentValue) { - pilotIntent = intentValue - } - } - } - - const nextState = - parsed && typeof parsed === 'object' && typeof (parsed as any).next_state === 'string' - ? ((parsed as any).next_state as string).trim() - : '' - - if (nextState.length > 0) { - const resolved = - prepared.finalCandidateIndex.get(nextState) - || prepared.candidateIndex.get(nextState) - - if (resolved) { - return { - decision: buildDecisionObject(resolved.id, resolved, index), - trace, - pilot_intent: pilotIntent, - } - } - - return { - decision: buildDecisionObject(nextState, undefined, index), - trace, - pilot_intent: pilotIntent, - } - } - - throw new Error('LLM response missing next_state field') - } catch (err: any) { - callEntry.error = err?.message || String(err) - - const fallbackCandidate = prepared.finalCandidates[0] || candidatePool[0] - const fallbackState = fallbackCandidate?.id || fallbackNextState(input) - - trace.fallback = { - used: true, - reason: 'OpenAI decision failed or was inconclusive; falling back to heuristic selection.', - selected: fallbackState, - } - - return { - decision: buildDecisionObject(fallbackState, fallbackCandidate ?? undefined, index), - trace, - pilot_intent: pilotIntent, - } - } -} - -export function __setRuntimeDecisionSystemForTests(system: RuntimeDecisionSystem) { - runtimeSystemCache = { - system, - index: buildRuntimeIndex(system), - timestamp: Date.now(), - } -}