From 5a1c06d037d5e2fbe410fbe45fe22ae4f9d4f7a4 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sat, 14 Feb 2026 19:10:10 +0100 Subject: [PATCH] feat(atc): add engine orchestrator and LLM route endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/atc/engine.ts: useAtcEngine() composable — reactive state machine with initFlight, handlePilotInput, updateTelemetry, declareEmergency, reset - server/api/atc/route.post.ts: Token-efficient LLM router with single-candidate fast path, heuristic readback checking, and fallback to LLM Co-Authored-By: Claude Opus 4.6 --- server/api/atc/route.post.ts | 255 +++++++++++++++++++++++++++++++ shared/atc/engine.ts | 285 +++++++++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 server/api/atc/route.post.ts create mode 100644 shared/atc/engine.ts diff --git a/server/api/atc/route.post.ts b/server/api/atc/route.post.ts new file mode 100644 index 0000000..d9970b5 --- /dev/null +++ b/server/api/atc/route.post.ts @@ -0,0 +1,255 @@ +// server/api/atc/route.post.ts — LLM router: picks the best matching interaction from candidates +import { createError, readBody } from 'h3' +import { getOpenAIClient } from '../../utils/normalize' +import { getServerRuntimeConfig } from '../../utils/runtimeConfig' +import type { RouteRequest, RouteResponse, RouteCandidate } from '../../../shared/atc/types' + +// ── System prompt (~150 tokens, cached by OpenAI on repeated calls) ── +const SYSTEM_PROMPT = `You are an ATC communication router. Given the pilot's radio transmission and a list of possible intents for the current flight phase, choose the best matching intent. + +Respond ONLY with valid JSON: +{"chosen":"interaction_id","reason":"brief reason","pilotIntent":"what pilot meant","confidence":"high|medium|low"} + +If nothing matches well, use chosen: "off_schema".` + +// ── Helpers ── + +function buildUserPrompt(req: RouteRequest): string { + const candidateList = req.candidates + .map((c, i) => `${i + 1}. [${c.id}] ${c.intent}${c.example ? ` (e.g. "${c.example}")` : ''}`) + .join('\n') + + const vars = req.vars || {} + const contextParts = [ + vars.callsign ? `callsign=${vars.callsign}` : null, + vars.runway ? `runway=${vars.runway}` : null, + vars.dest ? `dest=${vars.dest}` : null, + ].filter(Boolean).join(', ') + + const recent = (req.recentTransmissions || []).slice(-2).join(' | ') + + return [ + `Phase: ${req.phase}`, + `Pilot said: "${req.pilotSaid}"`, + '', + 'Possible intents:', + candidateList, + '', + contextParts ? `Flight context: ${contextParts}` : null, + recent ? `Recent: ${recent}` : null, + ].filter((line) => line !== null).join('\n') +} + +function tryParseJSON(text: string): Record | null { + // Strip markdown code fences if present + let cleaned = text.trim() + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '') + } + try { + return JSON.parse(cleaned) + } catch { + // Try to extract JSON object from the text + const match = cleaned.match(/\{[\s\S]*\}/) + if (match) { + try { + return JSON.parse(match[0]) + } catch { + return null + } + } + return null + } +} + +function validateConfidence(val: unknown): 'high' | 'medium' | 'low' { + if (val === 'high' || val === 'medium' || val === 'low') return val + return 'low' +} + +/** Simple heuristic readback check: does the pilot text contain the required values? */ +function heuristicReadbackCheck( + pilotSaid: string, + candidates: RouteCandidate[], + vars: Record, +): RouteResponse | null { + if (candidates.length === 0) return null + + const text = pilotSaid.toLowerCase() + + // Check if key values from vars appear in pilot speech + const valuesToCheck: string[] = [] + for (const key of ['runway', 'squawk', 'initial_alt', 'flight_level', 'qnh', 'taxi_route']) { + if (vars[key]) valuesToCheck.push(String(vars[key]).toLowerCase()) + } + + if (valuesToCheck.length === 0) return null + + const found: string[] = [] + const missing: string[] = [] + + for (const val of valuesToCheck) { + // Normalize: remove spaces for comparison (e.g. "25 R" vs "25R") + const normalizedVal = val.replace(/\s+/g, '') + const normalizedText = text.replace(/\s+/g, '') + if (normalizedText.includes(normalizedVal)) { + found.push(val) + } else { + missing.push(val) + } + } + + const ratio = found.length / valuesToCheck.length + + // Clearly good readback (>= 70% of values present) + if (ratio >= 0.7) { + const readbackOk = candidates.find((c) => c.intent.toLowerCase().includes('correct') || c.id.includes('ok')) + const chosen = readbackOk || candidates[0] + return { + chosen: chosen.id, + reason: 'Heuristic: readback contains required values', + pilotIntent: 'readback', + confidence: ratio === 1 ? 'high' : 'medium', + tokensUsed: 0, + durationMs: 0, + model: 'heuristic', + readbackResult: { + complete: missing.length === 0, + missing: missing.length > 0 ? missing : undefined, + }, + } + } + + // Clearly bad readback (< 30% of values present) + if (ratio < 0.3 && valuesToCheck.length >= 2) { + const readbackBad = candidates.find((c) => c.intent.toLowerCase().includes('incorrect') || c.id.includes('bad')) + const chosen = readbackBad || candidates[0] + return { + chosen: chosen.id, + reason: 'Heuristic: readback missing most required values', + pilotIntent: 'readback', + confidence: 'medium', + tokensUsed: 0, + durationMs: 0, + model: 'heuristic', + readbackResult: { + complete: false, + missing, + }, + } + } + + // Uncertain — let LLM decide + return null +} + +// ── Handler ── + +export default defineEventHandler(async (event): Promise => { + const body = await readBody(event) + + // Validate required fields + if (!body || !body.pilotSaid || !body.phase || !Array.isArray(body.candidates)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid request: pilotSaid, phase, and candidates[] are required', + }) + } + + const startMs = Date.now() + + // ── Fast path: single candidate → auto-select ── + if (body.candidates.length === 1) { + return { + chosen: body.candidates[0].id, + reason: 'Only one candidate available', + pilotIntent: body.candidates[0].intent, + confidence: 'high', + tokensUsed: 0, + durationMs: Date.now() - startMs, + model: 'auto', + } + } + + // ── Fast path: no candidates ── + if (body.candidates.length === 0) { + return { + chosen: 'off_schema', + reason: 'No candidates provided', + pilotIntent: body.pilotSaid, + confidence: 'low', + tokensUsed: 0, + durationMs: Date.now() - startMs, + model: 'auto', + } + } + + // ── Readback heuristic check ── + if (body.waitingFor === 'readback') { + const heuristicResult = heuristicReadbackCheck(body.pilotSaid, body.candidates, body.vars || {}) + if (heuristicResult) { + heuristicResult.durationMs = Date.now() - startMs + return heuristicResult + } + } + + // ── LLM call ── + const client = getOpenAIClient() + const { llmModel } = getServerRuntimeConfig() + const model = llmModel || 'gpt-5-nano' + + try { + const completion = await client.chat.completions.create({ + model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildUserPrompt(body) }, + ], + temperature: 0.1, + max_tokens: 150, + // @ts-expect-error -- reasoning_effort supported by OpenAI API but not yet in all type defs + reasoning_effort: 'low', + }) + + const durationMs = Date.now() - startMs + const rawContent = completion.choices?.[0]?.message?.content || '' + const tokensUsed = completion.usage?.total_tokens ?? 0 + + // Parse LLM JSON response + const parsed = tryParseJSON(rawContent) + + if (parsed && parsed.chosen) { + // Validate that chosen ID exists in candidates (or is off_schema) + const validId = + parsed.chosen === 'off_schema' || body.candidates.some((c) => c.id === parsed.chosen) + + return { + chosen: validId ? parsed.chosen : body.candidates[0].id, + reason: String(parsed.reason || 'LLM selected'), + pilotIntent: String(parsed.pilotIntent || body.pilotSaid), + confidence: validId ? validateConfidence(parsed.confidence) : 'low', + tokensUsed, + durationMs, + model, + } + } + + // Malformed JSON fallback + console.warn('[route.post] LLM returned malformed JSON, falling back to first candidate:', rawContent) + return { + chosen: body.candidates[0].id, + reason: 'LLM response was malformed, defaulting to first candidate', + pilotIntent: body.pilotSaid, + confidence: 'low', + tokensUsed, + durationMs, + model, + } + } catch (error: any) { + console.error('[route.post] LLM call failed:', error.message || error) + throw createError({ + statusCode: 502, + statusMessage: `LLM routing failed: ${error.message || 'Unknown error'}`, + }) + } +}) diff --git a/shared/atc/engine.ts b/shared/atc/engine.ts new file mode 100644 index 0000000..95a6b8c --- /dev/null +++ b/shared/atc/engine.ts @@ -0,0 +1,285 @@ +import { reactive, computed } from 'vue' +import type { EngineState, FlightPlan, FlightVars, TelemetryState, Transmission, + TransmissionDebug, RouteRequest, RouteCandidate, RouteResponse, Phase } from './types' +import { getPhase } from './phases' +import { renderTemplate } from './templateRenderer' +import { evaluateTelemetry } from './telemetryWatcher' + +let txCounter = 0 +function nextTxId(): string { + return `tx-${Date.now()}-${++txCounter}` +} + +function makeDefaultVars(): FlightVars { + return { + callsign: '', aircraft: '', dep: '', dest: '', stand: '', runway: '', + sid: '', squawk: '', atis_code: '', initial_alt: '', flight_level: '', + qnh: '', taxi_route: '', ground_freq: '', tower_freq: '', + departure_freq: '', approach_freq: '', center_freq: '', atis_freq: '', + wind: '', arrival_runway: '', arrival_stand: '', arrival_taxi_route: '', + star: '', approach_type: '', + } +} + +function makeDefaultTelemetry(): TelemetryState { + return { + altitude_ft: 0, speed_kts: 0, groundspeed_kts: 0, + vertical_speed_fpm: 0, heading_deg: 0, + latitude_deg: 0, longitude_deg: 0, on_ground: true, + } +} + +function makeDefaultState(): EngineState { + return { + currentPhase: 'clearance', + currentInteraction: null, + waitingFor: 'pilot', + vars: makeDefaultVars(), + flags: { inAir: false, emergencyActive: false, previousPhase: null }, + telemetry: makeDefaultTelemetry(), + sessionId: '', + transmissions: [], + } +} + +export function useAtcEngine() { + const state = reactive(makeDefaultState()) + + const currentPhase = computed(() => getPhase(state.currentPhase)) + + // --- Internal helpers --- + + function logTransmission( + speaker: Transmission['speaker'], + message: string, + debug: TransmissionDebug = {}, + ): Transmission { + const phase = currentPhase.value + const tx: Transmission = { + id: nextTxId(), + timestamp: new Date(), + speaker, + message, + phase: state.currentPhase, + frequency: phase?.frequency ?? '', + debug, + } + state.transmissions.push(tx) + return tx + } + + function advancePhase(toPhase: string, trigger?: string): void { + const from = state.currentPhase + state.currentPhase = toPhase + state.currentInteraction = null + state.waitingFor = 'pilot' + const phase = getPhase(toPhase) + logTransmission('system', `Phase: ${from} → ${toPhase}${trigger ? ` (${trigger})` : ''}`, { + engineAction: { + templateUsed: '', + variablesUpdated: {}, + phaseChanged: { from, to: toPhase }, + }, + }) + if (phase && !state.flags.inAir && toPhase === 'departure') { + state.flags.inAir = true + } + } + + function getCandidates(): RouteCandidate[] { + const phase = currentPhase.value + if (!phase) return [] + return phase.interactions + .filter((i) => { + if (!i.when) return true + return !!state.vars[i.when] + }) + .map((i) => ({ + id: i.id, + intent: i.pilotIntent, + example: i.pilotExample ? renderTemplate(i.pilotExample, state.vars) : undefined, + })) + } + + function checkReadback( + text: string, + required: string[], + vars: FlightVars, + ): { complete: boolean; missing: string[] } { + const lower = text.toLowerCase() + const missing: string[] = [] + for (const field of required) { + const val = vars[field] + if (!val) continue + if (!lower.includes(val.toLowerCase())) { + missing.push(field) + } + } + return { complete: missing.length === 0, missing } + } + + function applyUpdates(updates: Record): Record { + const applied: Record = {} + for (const [k, v] of Object.entries(updates)) { + state.vars[k] = String(v) + applied[k] = v + } + return applied + } + + function handleHandoff(handoff: { toPhase: string; say?: string }, vars: FlightVars): string | null { + if (handoff.say) { + const msg = renderTemplate(handoff.say, vars) + logTransmission('atc', msg, { + engineAction: { + templateUsed: handoff.say, + variablesUpdated: {}, + handoff: { from: state.currentPhase, to: handoff.toPhase }, + }, + }) + advancePhase(handoff.toPhase, 'handoff') + return msg + } + advancePhase(handoff.toPhase, 'handoff') + return null + } + + // --- Public API --- + + function initFlight(plan: FlightPlan): void { + Object.assign(state, makeDefaultState()) + state.sessionId = `ses-${Date.now()}` + state.vars.callsign = plan.callsign + state.vars.dep = plan.dep + state.vars.dest = plan.arr + if (plan.aircraft) state.vars.aircraft = plan.aircraft + if (plan.altitude) state.vars.flight_level = plan.altitude + if (plan.squawk) state.vars.squawk = plan.squawk + if (plan.assignedsquawk) state.vars.squawk = plan.assignedsquawk + logTransmission('system', `Flight initialized: ${plan.callsign} ${plan.dep}→${plan.arr}`) + } + + async function handlePilotInput(text: string, sttRaw?: string): Promise { + // 1. Log pilot transmission + logTransmission('pilot', text, { sttRaw }) + + // 2. Build candidates + const candidates = getCandidates() + const recentTx = state.transmissions.slice(-6).map(t => `[${t.speaker}] ${t.message}`) + + // 3. Route via LLM + const req: RouteRequest = { + pilotSaid: text, + phase: state.currentPhase, + interaction: state.currentInteraction, + waitingFor: state.waitingFor, + candidates, + vars: { ...state.vars }, + recentTransmissions: recentTx, + } + + const res = await $fetch('/api/atc/route', { + method: 'POST', + body: req, + }) + + // 4. Find chosen interaction + const phase = currentPhase.value + if (!phase) throw new Error(`Phase not found: ${state.currentPhase}`) + + const interaction = phase.interactions.find(i => i.id === res.chosen) + if (!interaction) throw new Error(`Interaction not found: ${res.chosen}`) + + // 5. Apply updates + let variablesUpdated: Record = {} + if (interaction.updates) { + variablesUpdated = applyUpdates(interaction.updates) + } + + // 6. Render ATC response + const atcText = renderTemplate(interaction.atcResponse, state.vars) + state.currentInteraction = interaction.id + + // 7. Handle readback + let readbackResult: TransmissionDebug['readbackResult'] + if (state.waitingFor === 'readback' && interaction.readback) { + readbackResult = res.readbackResult + ?? checkReadback(text, interaction.readback.required, state.vars) + } + if (interaction.readback && state.waitingFor !== 'readback') { + state.waitingFor = 'readback' + } else { + state.waitingFor = 'pilot' + } + + // 8. Log ATC transmission + logTransmission('atc', atcText, { + llmRequest: { + currentPhase: state.currentPhase, + currentInteraction: state.currentInteraction, + pilotSaid: text, + candidates: candidates.map(c => ({ id: c.id, intent: c.intent })), + contextSent: req.vars, + }, + llmResponse: { + chosenInteraction: res.chosen, + confidence: res.confidence, + reason: res.reason, + tokensUsed: res.tokensUsed, + durationMs: res.durationMs, + model: res.model, + }, + engineAction: { + templateUsed: interaction.atcResponse, + variablesUpdated, + }, + readbackResult, + }) + + // 9. Handle handoff (after logging the main response) + if (interaction.handoff) { + const handoffMsg = handleHandoff(interaction.handoff, state.vars) + if (handoffMsg) return `${atcText}\n${handoffMsg}` + } + + return atcText + } + + function updateTelemetry(data: Partial): void { + Object.assign(state.telemetry, data) + const phase = currentPhase.value + if (!phase) return + const event = evaluateTelemetry(state.telemetry, phase) + if (event?.type === 'phase_advance' && event.toPhase) { + logTransmission('system', `Telemetry auto-advance: ${event.trigger.condition}`, { + telemetryTrigger: { + parameter: event.trigger.parameter, + condition: event.trigger.condition, + value: event.trigger.value as number, + }, + }) + advancePhase(event.toPhase, `telemetry: ${event.trigger.condition}`) + } + } + + function declareEmergency(type: 'mayday' | 'panpan'): void { + state.flags.previousPhase = state.currentPhase + state.flags.emergencyActive = true + advancePhase('emergency', type) + logTransmission('pilot', `${type.toUpperCase()}, ${type.toUpperCase()}, ${type.toUpperCase()}, ${state.vars.callsign}`) + } + + function reset(): void { + Object.assign(state, makeDefaultState()) + } + + return { + state, + currentPhase, + initFlight, + handlePilotInput, + updateTelemetry, + declareEmergency, + reset, + } +}