mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-15 03:25:40 +08:00
Refine ATC decision routing and ATIS handling
This commit is contained in:
@@ -1753,6 +1753,8 @@ const sendPilotText = async () => {
|
||||
await handlePilotTransmission(text, 'text')
|
||||
}
|
||||
|
||||
const FREQUENCY_PLACEHOLDER = '---'
|
||||
|
||||
const frequencyTypeMap: Record<string, keyof FrequencyVariableUpdate> = {
|
||||
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())
|
||||
|
||||
@@ -37,6 +37,8 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
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<FrequencyResponse> => {
|
||||
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,
|
||||
|
||||
@@ -64,6 +64,130 @@ export interface LLMDecision {
|
||||
radio_check?: boolean
|
||||
}
|
||||
|
||||
type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain'
|
||||
|
||||
const READBACK_REQUIREMENTS: Record<string, string[]> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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<string>()
|
||||
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<LLMDecision> {
|
||||
const pilotText = input.pilot_utterance.toLowerCase().trim()
|
||||
const pilotUtterance = (input.pilot_utterance || '').trim()
|
||||
const pilotText = pilotUtterance.toLowerCase()
|
||||
|
||||
async function handleReadbackCheck(): Promise<LLMDecision> {
|
||||
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<LLMDecisio
|
||||
// Kompakter aber informativer Prompt - mit Variable-Info für intelligente Responses
|
||||
const system = [
|
||||
'You are an ATC state router. Return strict JSON.',
|
||||
'Keys: next_state, controller_say_tpl (optional), off_schema (optional).',
|
||||
'Keys: next_state, controller_say_tpl (optional), off_schema (optional), intent (optional).',
|
||||
'',
|
||||
'ROUTING: Pick next_state from candidates[].id when pilot matches expected flow.',
|
||||
'ATC RESPONSES: Only include controller_say_tpl if:',
|
||||
'- Next state role is "atc" OR has template_vars',
|
||||
'- OR off_schema=true (pilot needs response but no candidate fits)',
|
||||
'- OR pilot needs acknowledgment/correction',
|
||||
'CLASSIFY INTENT: Determine if pilot_utterance is PILOT_REQUEST (pilot initiates a call or request), PILOT_READBACK (acknowledging prior ATC instruction), SYS_INTERRUPT (system-driven transition, no pilot input), or OTHER.',
|
||||
'Use decision_hints.expecting_pilot_call, state_summary.role and candidates[].requires_atc_reply to guide the choice.',
|
||||
'',
|
||||
'TEMPLATE VARIABLES: When generating controller_say_tpl, you can use these variables:',
|
||||
`Available: {${optimizedInput.available_variables.join('}, {')}}`,
|
||||
`Common in candidates: {${optimizedInput.candidate_variables.join('}, {')}}`,
|
||||
'Always include {callsign} in ATC responses. Use variables like {runway}, {squawk}, {dest} as needed.',
|
||||
'ROUTING: Choose next_state from candidates[].id that best fits the intent and keeps the flow consistent with state_summary.next/ok_next/bad_next.',
|
||||
'If unsure, prefer GEN_NO_REPLY (set off_schema=true) or the first logical candidate.',
|
||||
'',
|
||||
'PILOT STATES: If next state role is "pilot", NO controller_say_tpl needed.',
|
||||
'DEFAULT: Use "GEN_NO_REPLY" if unclear.',
|
||||
'ATC RESPONSES: Only include controller_say_tpl when the chosen candidate requires an ATC reply (requires_atc_reply=true), has template variables, or the pilot is off schema.',
|
||||
'Never speak for pilot states. Always include {callsign} in ATC responses and prefer provided variables such as {runway}, {squawk}, {dest}.',
|
||||
'',
|
||||
'Examples: "{callsign}, taxi to runway {runway} via {taxi_route}"',
|
||||
'"{callsign}, cleared to {dest} via {sid} departure, squawk {squawk}"'
|
||||
`Available variables: {${optimizedInput.available_variables.join('}, {')}}`,
|
||||
`Common candidate variables: {${optimizedInput.candidate_variables.join('}, {')}}`,
|
||||
'',
|
||||
'INTERRUPTS: If an interrupt state (id starts with INT_) best matches the intent, select it and answer accordingly.',
|
||||
'Do not invent state ids. If nothing fits, respond with next_state "GEN_NO_REPLY" and off_schema=true.'
|
||||
].join(' ')
|
||||
|
||||
// Update optimized input to indicate which candidates need ATC responses
|
||||
|
||||
Reference in New Issue
Block a user