diff --git a/app/pages/pm.vue b/app/pages/pm.vue index d6445ea..fc1ddef 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -1753,6 +1753,8 @@ const sendPilotText = async () => { await handlePilotTransmission(text, 'text') } +const FREQUENCY_PLACEHOLDER = '---' + const frequencyTypeMap: Record = { ATIS: 'atis_freq', DEL: 'delivery_freq', @@ -1862,8 +1864,10 @@ const fetchAirportFrequencies = async (icao: string | undefined) => { } const setActiveFrequencyFromList = (entry: AirportFrequencyEntry) => { - if (!entry?.frequency) return - if (frequencies.value.active !== entry.frequency) { + if (!entry) return + const isPlaceholder = !entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER + + if (!isPlaceholder && frequencies.value.active !== entry.frequency) { frequencies.value.standby = frequencies.value.active frequencies.value.active = entry.frequency } @@ -1872,8 +1876,12 @@ const setActiveFrequencyFromList = (entry: AirportFrequencyEntry) => { } const setStandbyFrequencyFromList = (entry: AirportFrequencyEntry) => { - if (!entry?.frequency) return - frequencies.value.standby = entry.frequency + if (!entry) return + const isPlaceholder = !entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER + + if (!isPlaceholder) { + frequencies.value.standby = entry.frequency + } updateEngineFrequencyFromEntry(entry) } @@ -1909,7 +1917,11 @@ const buildAtisAnnouncement = (entry: AirportFrequencyEntry, fallback?: string): parts.push(fallback) } - parts.push(`Frequency ${entry.frequency}`) + if (!entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) { + parts.push('Frequency unavailable') + } else { + parts.push(`Frequency ${entry.frequency}`) + } return parts .map(segment => segment.trim()) diff --git a/server/api/airports/[icao]/frequencies.get.ts b/server/api/airports/[icao]/frequencies.get.ts index 4e41cee..b4fbf8d 100644 --- a/server/api/airports/[icao]/frequencies.get.ts +++ b/server/api/airports/[icao]/frequencies.get.ts @@ -37,6 +37,8 @@ const TYPE_LABELS: Record = { const TYPE_ORDER = ['ATIS', 'DEL', 'CLD', 'GND', 'TWR', 'DEP', 'APP', 'CTR', 'ACC', 'FSS'] +const ATIS_FREQUENCY_PLACEHOLDER = '---' + function toTypeLabel(rawType: string | undefined, fallback?: string): { type: string; label: string } { const type = (rawType || 'UNK').toUpperCase() const label = TYPE_LABELS[type] || fallback || type @@ -133,8 +135,8 @@ export default defineEventHandler(async (event): Promise => { for (const atis of atisEntries) { const callsign = String(atis?.callsign || '') if (!callsign.toUpperCase().startsWith(prefix)) continue - const frequency = normalizeFrequency(atis?.frequency) - if (!frequency) continue + const normalizedFrequency = normalizeFrequency(atis?.frequency) + const frequency = normalizedFrequency || ATIS_FREQUENCY_PLACEHOLDER const { type, label } = toTypeLabel('ATIS', atis?.name) addFrequencyEntry(frequencyMap, { type, diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 979f3e3..03a3247 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -64,6 +64,130 @@ export interface LLMDecision { radio_check?: boolean } +type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' + +const READBACK_REQUIREMENTS: Record = { + CD_READBACK_CHECK: ['dest', 'sid', 'runway', 'initial_altitude_ft', 'squawk'] +} + +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() +} + +const ICAO_LETTERS: Record = { + A: 'Alpha', B: 'Bravo', C: 'Charlie', D: 'Delta', E: 'Echo', F: 'Foxtrot', + G: 'Golf', H: 'Hotel', I: 'India', J: 'Juliett', K: 'Kilo', L: 'Lima', + M: 'Mike', N: 'November', O: 'Oscar', P: 'Papa', Q: 'Quebec', R: 'Romeo', + S: 'Sierra', T: 'Tango', U: 'Uniform', V: 'Victor', W: 'Whiskey', + X: 'X-ray', Y: 'Yankee', Z: 'Zulu' +} + +const ICAO_DIGITS: Record = { + '0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower', + '5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner' +} + +function toPhonetic(value: string): string { + return value + .toUpperCase() + .split('') + .map((ch) => ICAO_LETTERS[ch] || ICAO_DIGITS[ch] || ch) + .join(' ') +} + +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 (/^[A-Z]{3,4}$/.test(normalized.toUpperCase())) { + variants.add(toPhonetic(normalized)) + } + + if (/^\d{4}$/.test(normalized)) { + variants.add(normalized.split('').join(' ')) + variants.add(normalized.split('').map((d) => ICAO_DIGITS[d] || d).join(' ')) + } + + if (/^\d{1,2}[LCR]?$/i.test(normalized)) { + const digits = normalized.match(/\d+/)?.[0] ?? '' + const spelledDigits = digits + .split('') + .map((d) => ICAO_DIGITS[d] || d) + .join(' ') + 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(digits.split('').map((d) => ICAO_DIGITS[d] || d).join(' ')) + } + } + + return Array.from(variants) +} + +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' +} + // Extrahiere verwendete Variablen aus Templates function extractTemplateVariables(text?: string): string[] { if (!text) return [] @@ -82,14 +206,44 @@ function optimizeInputForLLM(input: LLMDecisionInput) { '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 + 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 } }) @@ -101,10 +255,18 @@ function optimizeInputForLLM(input: LLMDecisionInput) { state_id: input.state_id, current_phase: input.state.phase, current_role: input.state.role, + state_summary: stateSummary, candidates: candidates, available_variables: availableVariables, // Alle verfügbaren Variablen 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) + }, // Nur aktueller Context ohne Werte (für Token-Sparen) context: { callsign: input.variables.callsign, @@ -116,7 +278,102 @@ function optimizeInputForLLM(input: LLMDecisionInput) { } export async function routeDecision(input: LLMDecisionInput): Promise { - const pilotText = input.pilot_utterance.toLowerCase().trim() + const pilotUtterance = (input.pilot_utterance || '').trim() + const pilotText = pilotUtterance.toLowerCase() + + async function handleReadbackCheck(): Promise { + const requiredKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || [] + const expectedItems = requiredKeys + .map(key => ({ key, value: input.variables?.[key] })) + .filter(item => item.value !== undefined && item.value !== null && `${item.value}`.trim().length > 0) + .map(item => ({ + key: item.key, + value: String(item.value), + spoken_variants: buildSpokenVariants(item.key, String(item.value)) + })) + + const okNext = pickTransition(input.state.ok_next, input.candidates) + const badNext = pickTransition(input.state.bad_next, input.candidates) + const defaultNext = fallbackNextState(input) + + if (!expectedItems.length) { + return { next_state: okNext ?? defaultNext } + } + + const sanitizedPilot = sanitizeForQuickMatch(pilotUtterance) + const heuristicsOk = expectedItems.every(item => { + const sanitizedValue = sanitizeForQuickMatch(item.value) + return sanitizedValue ? sanitizedPilot.includes(sanitizedValue) : true + }) + + if (heuristicsOk && okNext) { + return { next_state: okNext } + } + + try { + const client = ensureOpenAI() + const model = getModel() + const payload = { + state_id: input.state_id, + callsign: input.variables?.callsign, + pilot_utterance: pilotUtterance, + expected_items: expectedItems, + controller_instruction: input.state.say_tpl ?? null + } + + const response = await client.chat.completions.create({ + model, + response_format: { type: 'json_schema', json_schema: READBACK_JSON_SCHEMA }, + messages: [ + { + role: 'system', + content: [ + 'You are an aviation clearance readback checker.', + 'Evaluate if the pilot_utterance correctly repeats every item in expected_items.', + 'Return JSON with keys: status (ok, missing, incorrect, uncertain), missing (array), incorrect (array), notes (optional).', + 'Treat reasonable phonetic variations as correct.' + ].join(' ') + }, + { role: 'user', content: JSON.stringify(payload) } + ] + }) + + const raw = response.choices?.[0]?.message?.content || '{}' + const parsed = JSON.parse(raw) as { status?: ReadbackStatus } + const status: ReadbackStatus = parsed.status || 'uncertain' + + if (status === 'ok') { + return { next_state: okNext ?? defaultNext } + } + + if ((status === 'missing' || status === 'incorrect') && badNext) { + return { next_state: badNext } + } + + if (status === 'uncertain' && okNext) { + return { next_state: okNext } + } + + return { next_state: badNext ?? defaultNext } + } catch (err) { + console.warn('[ATC] Readback check failed, using fallback:', err) + return { next_state: okNext ?? defaultNext } + } + } + + if (input.state?.auto === 'check_readback') { + return await handleReadbackCheck() + } + + if (!pilotUtterance) { + const interruptCandidate = input.candidates.find(c => c.id.startsWith('INT_')) + || input.candidates.find(c => c.state?.auto === 'monitor') + || input.candidates.find(c => c.state?.role === 'system') + + if (interruptCandidate) { + return { next_state: interruptCandidate.id } + } + } // Sofortige Erkennung ohne LLM für häufige Cases if (pilotText.includes('radio check') || pilotText.includes('signal test') || @@ -151,24 +408,22 @@ export async function routeDecision(input: LLMDecisionInput): Promise