diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 03a3247..6c61ded 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -67,7 +67,10 @@ export interface LLMDecision { type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' const READBACK_REQUIREMENTS: Record = { - CD_READBACK_CHECK: ['dest', 'sid', 'runway', 'initial_altitude_ft', 'squawk'] + CD_READBACK_CHECK: ['dest', 'sid', 'runway', 'initial_altitude_ft', 'squawk'], + GRD_TAXI_READBACK_CHECK: ['runway', 'taxi_route', 'hold_short'], + TWR_TAKEOFF_READBACK_CHECK: ['runway', 'cleared_takeoff'], + GRD_TAXI_IN_READBACK_CHECK: ['gate', 'taxi_route'] } const READBACK_JSON_SCHEMA = { @@ -134,6 +137,19 @@ function buildSpokenVariants(key: string, value: string): string[] { variants.add(normalized) variants.add(normalized.toUpperCase()) + if (key === 'hold_short') { + const base = normalized.replace(/^holding\s+short/i, 'hold short') + variants.add(base) + if (!/\brunway\b/i.test(base)) { + variants.add(base.replace(/^(hold short)/i, '$1 runway')) + } + } + + if (key === 'cleared_takeoff') { + variants.add(normalized.replace(/take-off/gi, 'takeoff')) + variants.add(normalized.replace(/take-off/gi, 'take off')) + } + if (/^[A-Z]{3,4}$/.test(normalized.toUpperCase())) { variants.add(toPhonetic(normalized)) } @@ -188,6 +204,42 @@ function fallbackNextState(input: LLMDecisionInput): string { return input.candidates[0]?.id || input.state_id || 'GEN_NO_REPLY' } +function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null { + const rawValue = input.variables?.[key] + if (rawValue !== undefined && rawValue !== null) { + const trimmed = `${rawValue}`.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + switch (key) { + case 'hold_short': { + const runway = input.variables?.runway + if (typeof runway === 'string' && runway.trim().length > 0) { + return `holding short ${runway}`.trim() + } + return 'holding short' + } + case 'cleared_takeoff': { + const runway = input.variables?.runway + if (typeof runway === 'string' && runway.trim().length > 0) { + return `cleared for take-off ${runway}`.trim() + } + return 'cleared for take-off' + } + case 'cleared_to_land': { + const runway = input.variables?.runway + if (typeof runway === 'string' && runway.trim().length > 0) { + return `cleared to land runway ${runway}`.trim() + } + return 'cleared to land' + } + default: + return null + } +} + // Extrahiere verwendete Variablen aus Templates function extractTemplateVariables(text?: string): string[] { if (!text) return [] @@ -283,14 +335,24 @@ export async function routeDecision(input: LLMDecisionInput): 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 expectedItems = requiredKeys.reduce>((acc, key) => { + const value = resolveReadbackValue(key, input) + if (!value) { + return acc + } + + const normalizedValue = String(value) + if (!normalizedValue.trim().length) { + return acc + } + + acc.push({ + key, + value: normalizedValue, + spoken_variants: buildSpokenVariants(key, normalizedValue) + }) + return acc + }, []) const okNext = pickTransition(input.state.ok_next, input.candidates) const badNext = pickTransition(input.state.bad_next, input.candidates) diff --git a/shared/data/atcDecisionTree.ts b/shared/data/atcDecisionTree.ts index 4941556..38a3f9d 100644 --- a/shared/data/atcDecisionTree.ts +++ b/shared/data/atcDecisionTree.ts @@ -114,6 +114,7 @@ const atcDecisionTree = { "role": "atc", "phase": "Clearance", "auto": "check_readback", + "readback_required": ["dest","sid","runway","initial_altitude_ft","squawk"], "ok_next": [{"to": "CD_CLR_COMPLETE"}], "bad_next": [{"to": "CD_READBACK_CORRECT"}], "on_timeout": [{"after_s": 6, "action": "say", "say_tpl": "{callsign}, read back clearance."}] @@ -179,7 +180,21 @@ const atcDecisionTree = { "role": "pilot", "phase": "TaxiOut", "utterance_tpl": "{callsign} taxi to {runway} via {taxi_route}, holding short {runway}.", - "next": [{"to": "TWR_CONTACT"}] + "next": [{"to": "GRD_TAXI_READBACK_CHECK"}] + }, + "GRD_TAXI_READBACK_CHECK": { + "role": "atc", + "phase": "TaxiOut", + "auto": "check_readback", + "readback_required": ["runway","taxi_route","hold_short"], + "ok_next": [{"to": "TWR_CONTACT"}], + "bad_next": [{"to": "GRD_TAXI_READBACK_CORRECT"}] + }, + "GRD_TAXI_READBACK_CORRECT": { + "role": "atc", + "phase": "TaxiOut", + "say_tpl": "{callsign}, negative; taxi to runway {runway} via {taxi_route}, hold short runway {runway}.", + "next": [{"to": "GRD_TAXI_READBACK"}] }, "TWR_CONTACT": { @@ -216,7 +231,21 @@ const atcDecisionTree = { "role": "pilot", "phase": "Departure", "utterance_tpl": "{callsign} cleared for take-off {runway}.", - "next": [{"to": "DEP_CONTACT"}] + "next": [{"to": "TWR_TAKEOFF_READBACK_CHECK"}] + }, + "TWR_TAKEOFF_READBACK_CHECK": { + "role": "atc", + "phase": "Departure", + "auto": "check_readback", + "readback_required": ["runway","cleared_takeoff"], + "ok_next": [{"to": "DEP_CONTACT"}], + "bad_next": [{"to": "TWR_TAKEOFF_READBACK_CORRECT"}] + }, + "TWR_TAKEOFF_READBACK_CORRECT": { + "role": "atc", + "phase": "Departure", + "say_tpl": "{callsign}, negative; runway {runway}, cleared for take-off.", + "next": [{"to": "TWR_TAKEOFF_READBACK"}] }, "DEP_CONTACT": { @@ -373,7 +402,21 @@ const atcDecisionTree = { "role": "pilot", "phase": "TaxiIn", "utterance_tpl": "{callsign} taxi to stand {gate} via {taxi_route}.", - "next": [{"to": "FLOW_COMPLETE"}] + "next": [{"to": "GRD_TAXI_IN_READBACK_CHECK"}] + }, + "GRD_TAXI_IN_READBACK_CHECK": { + "role": "atc", + "phase": "TaxiIn", + "auto": "check_readback", + "readback_required": ["gate","taxi_route"], + "ok_next": [{"to": "FLOW_COMPLETE"}], + "bad_next": [{"to": "GRD_TAXI_IN_READBACK_CORRECT"}] + }, + "GRD_TAXI_IN_READBACK_CORRECT": { + "role": "atc", + "phase": "TaxiIn", + "say_tpl": "{callsign}, negative; taxi to stand {gate} via {taxi_route}.", + "next": [{"to": "GRD_TAXI_IN_READBACK"}] }, "FLOW_COMPLETE": { "role": "system",