feat(atc): add engine orchestrator and LLM route endpoint

- 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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-14 19:10:10 +01:00
parent f213933db2
commit 5a1c06d037
2 changed files with 540 additions and 0 deletions

View File

@@ -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<string, any> | 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<string, any>,
): 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<RouteResponse> => {
const body = await readBody<RouteRequest>(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'}`,
})
}
})

285
shared/atc/engine.ts Normal file
View File

@@ -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<EngineState>(makeDefaultState())
const currentPhase = computed<Phase | undefined>(() => 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<string, any>): Record<string, any> {
const applied: Record<string, any> = {}
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<string> {
// 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<RouteResponse>('/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<string, any> = {}
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<TelemetryState>): 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,
}
}