mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
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:
255
server/api/atc/route.post.ts
Normal file
255
server/api/atc/route.post.ts
Normal 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
285
shared/atc/engine.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user