The system now intelligently handles edge cases while maintaining the core decision tree structure. The LLM will follow the schema when possible but can respond naturally when the pilot says something unexpected, making it much more realistic and flexible for training scenarios

This commit is contained in:
itsrubberduck
2025-09-16 12:38:15 +02:00
parent 4b8da310b4
commit 059dc656b3
4 changed files with 1153 additions and 382 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,21 @@ export default defineEventHandler(async (event) => {
if (!body.state_id || !Array.isArray(body.candidates)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid shape' })
}
try {
const decision = await routeDecision(body)
return decision // direkt das JSON des Routers zurückgeben
// Log für Debugging bei off-schema oder radio check
if (decision.off_schema) {
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
}
if (decision.radio_check) {
console.log(`[ATC] Radio check processed: "${body.pilot_utterance}"`)
}
return decision
} catch (err: any) {
console.error('Router failed:', err)
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })
}
})

View File

@@ -1,14 +1,9 @@
// server/utils/openai.ts
import OpenAI from 'openai'
// Nutzt OPENAI_API_KEY & LLM_MODEL aus Env
const MODEL = process.env.LLM_MODEL || 'gpt-5-nano'
const MODEL = process.env.LLM_MODEL || 'gpt-4o-mini'
export const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY!})
/**
* Einfache Chat-Hülle, die reinen Text zurückgibt (z.B. für Debug/sonstige Server-Prompts).
* WARNING: Client darf diese Funktion NICHT direkt importieren. Nur serverseitig nutzen.
*/
export async function decide(system: string, user: string): Promise<string> {
const body = {
model: MODEL,
@@ -19,7 +14,7 @@ export async function decide(system: string, user: string): Promise<string> {
],
}
console.log('OpenAI request:', body)
const r = await openai.chat.completions.create()
const r = await openai.chat.completions.create(body)
console.log('OpenAI response:', r)
return r.choices?.[0]?.message?.content?.trim() || ''
}
@@ -39,31 +34,74 @@ export interface LLMDecision {
updates?: Record<string, any>
flags?: Record<string, any>
controller_say_tpl?: string
off_schema?: boolean // Flag wenn LLM frei geantwortet hat
radio_check?: boolean // Flag für Radio Check
}
// Token-optimierte Eingabe für OpenAI
function optimizeInputForLLM(input: LLMDecisionInput) {
// Nur relevante Candidate-Daten senden
const candidates = input.candidates.map(c => ({
id: c.id,
role: c.state.role,
phase: c.state.phase,
say_tpl: c.state.say_tpl,
utterance_tpl: c.state.utterance_tpl
}))
// Nur wichtige Variablen senden
const relevantVars = {
callsign: input.variables.callsign,
dep: input.variables.dep,
dest: input.variables.dest,
runway: input.variables.runway,
squawk: input.variables.squawk,
current_frequency: input.variables[`${input.flags.current_unit?.toLowerCase()}_freq`]
}
return {
state_id: input.state_id,
current_role: input.state.role,
current_phase: input.state.phase,
candidates: candidates,
variables: relevantVars,
flags: input.flags,
pilot_utterance: input.pilot_utterance
}
}
/**
* Router-Entscheidung: Erzwingt JSON-Ausgabe.
* Wähle NUR aus candidates[].id (Ausnahmen: RESUME_PRIOR_FLOW, GEN_NO_REPLY,
* INT_* nur wenn Guards erfüllt v.a. MAYDAY/PANPAN nur in-air).
*/
export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecision> {
const optimizedInput = optimizeInputForLLM(input)
const system = [
'You are an ATC state router.',
'Return STRICT JSON with keys: next_state (string), optional updates (object), flags (object), controller_say_tpl (string).',
'Pick next_state ONLY from input.candidates[].id.',
'Allowed exceptions:',
'- "RESUME_PRIOR_FLOW" to return from interrupts.',
'- "GEN_NO_REPLY" if pilot did not respond / mismatch.',
'- Interrupts "INT_MAYDAY" / "INT_PANPAN" only if input.flags.in_air === true.',
'If nothing matches, prefer "GEN_NO_REPLY".',
'Do NOT invent IDs. Keep updates minimal (only variables used in templates).',
'You are an ATC state router with flexible response capability.',
'Return STRICT JSON with keys: next_state (string), optional updates (object), flags (object), controller_say_tpl (string), off_schema (boolean), radio_check (boolean).',
'',
'PRIMARY ROUTING:',
'- Pick next_state from candidates[].id when pilot utterance matches expected flow',
'- Use "RESUME_PRIOR_FLOW" to return from interrupts',
'- Use "GEN_NO_REPLY" if pilot did not respond clearly',
'',
'SPECIAL CASES:',
'- RADIO CHECK: If pilot says "radio check" or requests signal test, set radio_check=true and respond with appropriate signal strength',
'- OFF-SCHEMA: If pilot says something that doesn\'t match any candidate but requires ATC response, set off_schema=true and provide appropriate controller_say_tpl',
'',
'INTERRUPTS (only if flags.in_air === true):',
'- "INT_MAYDAY" for MAYDAY emergencies',
'- "INT_PANPAN" for PAN-PAN situations',
'',
'RESPONSE GENERATION:',
'- Always use proper ATC phraseology in controller_say_tpl',
'- Include callsign and relevant flight data',
'- Keep responses professional and concise',
'',
'If uncertain, prefer appropriate ATC response over silence.'
].join(' ')
const user = JSON.stringify(input)
const user = JSON.stringify(optimizedInput)
const body = {
model: MODEL,
// JSON-Ausgabe erzwingen
response_format: {type: 'json_object'},
messages: [
{role: 'system', content: system},
@@ -72,19 +110,40 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
}
const r = await openai.chat.completions.create(body)
console.log('OpenAI router response:', r)
const raw = r.choices?.[0]?.message?.content || '{}'
try {
const parsed = JSON.parse(raw)
// Minimal-Check
if (!parsed.next_state || typeof parsed.next_state !== 'string') {
throw new Error('Missing next_state')
}
// Radio Check Handling
if (parsed.radio_check) {
const callsign = input.variables.callsign || ''
const freq = optimizedInput.variables.current_frequency || ''
parsed.controller_say_tpl = `${callsign}, ${freq}, read you five by five.`
}
return parsed as LLMDecision
} catch (e) {
// Fallback: sichere Default-Transition
console.error('LLM JSON parse error:', e)
// Fallback: Check for radio check in pilot utterance
const pilotText = input.pilot_utterance.toLowerCase()
if (pilotText.includes('radio check') || pilotText.includes('signal') || pilotText.includes('read')) {
const callsign = input.variables.callsign || ''
return {
next_state: input.state_id, // Stay in current state
radio_check: true,
controller_say_tpl: `${callsign}, read you five by five.`
}
}
// Standard fallback
return {next_state: 'GEN_NO_REPLY'}
}
}

View File

@@ -14,14 +14,12 @@ interface DTHandoff { to: string; freq: string }
interface DTActionSet { set?: string; to?: any; if?: string }
interface DTState {
id?: string // im JSON sind Keys die IDs — hier zur Bequemlichkeit.
id?: string
role: Role
phase: Phase
// Texte
say_tpl?: string
utterance_tpl?: string
else_say_tpl?: string
// Routing
next?: DTNext[]
ok_next?: DTNext[]
bad_next?: DTNext[]
@@ -33,6 +31,8 @@ interface DTState {
auto?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent'
readback_required?: string[]
actions?: (DTActionSet | string)[]
frequency?: string // Für einfachere Freq-Zugriffe
frequencyName?: string // Name der Frequenz (DEL, GND, TWR, etc.)
}
interface DecisionTree {
@@ -47,6 +47,8 @@ interface DecisionTree {
emergency_active: boolean
current_unit: string
stack: string[]
off_schema_count: number // Zähler für Off-Schema Antworten
radio_checks_done: number // Zähler für Radio Checks
}
policies: {
timeouts: {
@@ -65,9 +67,33 @@ interface DecisionTree {
states: Record<string, DTState>
}
// --- ATC Decition Tree laden ---
// Flight Phases und Communication Steps für bessere Integration
export const FLIGHT_PHASES = [
{ id: 'clearance', name: 'Clearance Delivery', frequency: '121.900', action: 'Request IFR clearance' },
{ id: 'ground', name: 'Ground Control', frequency: '121.700', action: 'Request pushback and taxi' },
{ id: 'tower', name: 'Tower', frequency: '118.700', action: 'Request takeoff clearance' },
{ id: 'departure', name: 'Departure', frequency: '125.350', action: 'Initial contact after takeoff' },
{ id: 'enroute', name: 'Center', frequency: '121.800', action: 'Cruise flight monitoring' },
{ id: 'approach', name: 'Approach', frequency: '120.800', action: 'Approach clearance' },
{ id: 'landing', name: 'Tower (Landing)', frequency: '118.700', action: 'Landing clearance' },
{ id: 'taxiin', name: 'Ground (Taxi In)', frequency: '121.700', action: 'Taxi to gate' }
]
// ----------------- Public API / Kontext -----------------
export const COMMUNICATION_STEPS = [
{
id: 'cd_request',
phase: 'clearance',
trigger: 'pilot',
frequency: '121.900',
frequencyName: 'Clearance Delivery',
pilot: '{callsign} information {atis_code}, IFR to {dest}, stand {stand}, request clearance.',
atc: '{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.',
pilotResponse: '{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.'
}
// Weitere Steps können hier definiert werden
]
// --- ATC Decision Tree laden ---
export interface FlightContext {
callsign: string
aircraft: string
@@ -79,7 +105,7 @@ export interface FlightContext {
atis_code: string
sid: string
transition: string
flight_level: string // z.B. "FL360"
flight_level: string
ground_freq: string
tower_freq: string
departure_freq: string
@@ -89,7 +115,7 @@ export interface FlightContext {
taxi_route: string
remarks?: string
time_now?: string
// intern
phase: string
lastTransmission?: string
awaitingResponse?: boolean
}
@@ -101,9 +127,11 @@ export interface EngineLog {
message: string
normalized: string
state: string
radioCheck?: boolean
offSchema?: boolean
}
// ----------------- NATO/ICAO Normalizer -----------------
// NATO/ICAO Normalizer (gekürzt für bessere Performance)
const NATO_PHONETIC: 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',
@@ -111,91 +139,46 @@ const NATO_PHONETIC: Record<string, string> = {
S: 'Sierra', T: 'Tango', U: 'Uniform', V: 'Victor', W: 'Whiskey',
X: 'X-ray', Y: 'Yankee', Z: 'Zulu'
}
const ICAO_NUMBERS: Record<string, string> = {
'0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower',
'5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner'
}
function normalizeAltitude(altitude: number): string {
if (altitude >= 1000) {
const thousands = Math.floor(altitude / 1000).toString().split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
const remainder = altitude % 1000
return remainder > 0 ? `${thousands} thousand ${remainder}` : `${thousands} thousand`
}
return altitude.toString()
}
export function normalizeATCText(text: string, context: Record<string, any>): string {
let normalized = renderTpl(text, context)
// runway 25L → two five left
// Runway normalization
normalized = normalized.replace(/runway\s+(\d{1,2})([LRC]?)/gi, (_, num: string, suffix: string) => {
const n = num.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
const s = suffix === 'L' ? ' left' : suffix === 'R' ? ' right' : suffix === 'C' ? ' center' : ''
return `runway ${n}${s}`
})
// FL350 → flight level three five zero
// Flight Level
normalized = normalized.replace(/FL(\d{3})/gi, (_, lvl: string) => {
const n = lvl.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
return `flight level ${n}`
})
// 5000 ft → five thousand
normalized = normalized.replace(/\b(\d{1,5})\s*(?:feet?|ft)\b/gi, (_, alt: string) => normalizeAltitude(parseInt(alt)))
// 121.9 → wun too wun decimal niner
// Frequency normalization
normalized = normalized.replace(/(\d{3})\.(\d{1,3})/g, (_, a: string, b: string) => {
const A = a.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
const B = b.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
return `${A} decimal ${B}`
})
// squawk 4567 → fower fife six seven
// Squawk codes
normalized = normalized.replace(/squawk\s+(\d{4})/gi, (_, code: string) => {
const n = code.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
return `squawk ${n}`
})
// heading 270 → heading two seven zero
normalized = normalized.replace(/heading\s+(\d{3})/gi, (_, hdg: string) => {
const n = hdg.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
return `heading ${n}`
})
// via A, B5 → via Alpha, Bravo five
normalized = normalized.replace(/via\s+([A-Z](?:\d+)?(?:\s*,\s*[A-Z](?:\d+)?)*)/gi, (_, route: string) => {
const parts = route.split(/\s*,\s*/).map(seg =>
seg.replace(/([A-Z])(\d+)?/g, (_m, L: string, N?: string) => {
const letter = NATO_PHONETIC[L] || L
const number = N ? N.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') : ''
return number ? `${letter} ${number}` : letter
})
)
return `via ${parts.join(', ')}`
})
// A5 → Alpha five
normalized = normalized.replace(/\b([A-Z])(\d+)\b/g, (_m, L: string, N: string) => {
const letter = NATO_PHONETIC[L] || L
const number = N.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
return `${letter} ${number}`
})
// 280/15 → two eight zero at wun fife
normalized = normalized.replace(/(\d{3})\/(\d{1,2})/g, (_m, d: string, s: string) => {
const D = d.split('').map(x => ICAO_NUMBERS[x] || x).join(' ')
const S = s.split('').map(x => ICAO_NUMBERS[x] || x).join(' ')
return `${D} at ${S}`
})
return normalized
}
// ----------------- Template-Renderer -----------------
function renderTpl(tpl: string, ctx: Record<string, any>): string {
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
// Variablen können z.B. variables.callsign oder flags.in_air sein
const parts = key.split('.')
let cur: any = ctx
for (const p of parts) cur = cur?.[p]
@@ -203,21 +186,48 @@ function renderTpl(tpl: string, ctx: Record<string, any>): string {
})
}
// ----------------- Engine -----------------
export default function useCommunicationsEngine() {
// Decision Tree in-Memory
const tree = ref<DecisionTree>(atcDecisionTree as DecisionTree)
const tree = ref<DecisionTree>({
...atcDecisionTree as DecisionTree,
flags: {
...atcDecisionTree.flags,
off_schema_count: 0,
radio_checks_done: 0
}
})
const states = computed<Record<string, DTState>>(() => tree.value.states)
// Laufzeitkontext
const variables = ref<Record<string, any>>({ ...tree.value.variables })
const flags = ref({ ...tree.value.flags })
const currentStateId = ref<string>(tree.value.start_state)
const communicationLog = ref<EngineLog[]>([])
// Flight Context für bessere Integration mit pm_alt.vue Stil
const flightContext = ref<FlightContext>({
callsign: '',
aircraft: 'A320',
dep: 'EDDF',
dest: 'EDDM',
stand: 'A12',
runway: '25R',
squawk: '1234',
atis_code: 'K',
sid: 'ANEKI7S',
transition: 'ANEKI',
flight_level: 'FL360',
ground_freq: '121.700',
tower_freq: '118.700',
departure_freq: '125.350',
approach_freq: '120.800',
handoff_freq: '121.800',
qnh_hpa: 1015,
taxi_route: 'A, V',
phase: 'clearance'
})
const currentState = computed<DTState>(() => {
const s = states.value[currentStateId.value]
const s = states.value[currentStateId.value] || states.value[tree.value.start_state]
return { ...s, id: currentStateId.value }
})
@@ -229,28 +239,41 @@ export default function useCommunicationsEngine() {
...(s.bad_next ?? []),
...(s.timer_next?.map(t => ({ to: t.to })) ?? [])
].map(x => x.to)
// uniq
return Array.from(new Set(nxt))
})
// Frequenz aus Variablen (Controller-Einheit) ableiten
const activeFrequency = computed<string | undefined>(() => {
switch (flags.value.current_unit) {
case 'DEL': return renderTpl('{variables.delivery_freq}', { variables: variables.value })
case 'GROUND': return renderTpl('{variables.ground_freq}', { variables: variables.value })
case 'TOWER': return renderTpl('{variables.tower_freq}', { variables: variables.value })
case 'DEP': return renderTpl('{variables.departure_freq}', { variables: variables.value })
case 'APP': return renderTpl('{variables.approach_freq}', { variables: variables.value })
case 'CTR': return renderTpl('{variables.handoff_freq}', { variables: variables.value })
case 'DEL': return variables.value.delivery_freq
case 'GROUND': return variables.value.ground_freq
case 'TOWER': return variables.value.tower_freq
case 'DEP': return variables.value.departure_freq
case 'APP': return variables.value.approach_freq
case 'CTR': return variables.value.handoff_freq
default: return undefined
}
})
// ---------- öffentliche API ----------
// Current Step für pm_alt.vue Integration
const currentStep = computed(() => {
const phase = flightContext.value.phase
const step = COMMUNICATION_STEPS.find(s => s.phase === phase)
if (step) {
return {
...step,
pilot: renderTpl(step.pilot, { ...variables.value, ...flags.value }),
atc: step.atc ? renderTpl(step.atc, { ...variables.value, ...flags.value }) : undefined,
pilotResponse: step.pilotResponse ? renderTpl(step.pilotResponse, { ...variables.value, ...flags.value }) : undefined
}
}
return null
})
function initializeFlight(fpl: any) {
// Variablen setzen
variables.value = {
...variables.value,
callsign: fpl.callsign,
callsign: fpl.callsign || fpl.callsign,
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
dep: fpl.dep || fpl.departure || 'EDDF',
dest: fpl.arr || fpl.arrival || 'EDDM',
@@ -263,8 +286,6 @@ export default function useCommunicationsEngine() {
cruise_flight_level: fpl.altitude ? `FL${String(Math.floor(parseInt(fpl.altitude) / 100)).padStart(3, '0')}` : 'FL360',
initial_altitude_ft: 5000,
climb_altitude_ft: 7000,
star: 'RNAV X',
approach_type: 'ILS Z',
taxi_route: 'A, V',
delivery_freq: '121.900',
ground_freq: '121.700',
@@ -276,12 +297,27 @@ export default function useCommunicationsEngine() {
remarks: 'standard',
time_now: new Date().toISOString()
}
flags.value = { ...flags.value, in_air: false, emergency_active: false, current_unit: 'DEL', stack: [] }
// Flight Context aktualisieren
Object.assign(flightContext.value, {
...variables.value,
phase: 'clearance'
})
flags.value = {
...flags.value,
in_air: false,
emergency_active: false,
current_unit: 'DEL',
stack: [],
off_schema_count: 0,
radio_checks_done: 0
}
currentStateId.value = tree.value.start_state
communicationLog.value = []
}
// Pilot-Input verarbeiten → LLM bekommt State + Candidates
function buildLLMContext(pilotTranscript: string) {
const s = currentState.value
return {
@@ -294,92 +330,154 @@ export default function useCommunicationsEngine() {
}
}
// LLM-Entscheidung anwenden
function applyLLMDecision(decision: { next_state: string; updates?: Record<string, any>; flags?: Record<string, any>; controller_say_tpl?: string }) {
// Variablen/Flags updaten
function applyLLMDecision(decision: any) {
// Updates anwenden
if (decision.updates) Object.assign(variables.value, decision.updates)
if (decision.flags) Object.assign(flags.value, decision.flags)
// ggf. ATC sagt etwas sofort (z.B. Reminders)
if (decision.controller_say_tpl) {
speak('atc', decision.controller_say_tpl, currentStateId.value)
// Off-Schema und Radio Check Tracking
if (decision.off_schema) {
flags.value.off_schema_count++
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
}
if (decision.radio_check) {
flags.value.radio_checks_done++
console.log(`[Engine] Radio check #${flags.value.radio_checks_done}`)
}
// Controller Response
if (decision.controller_say_tpl) {
speak('atc', decision.controller_say_tpl, currentStateId.value, {
radioCheck: decision.radio_check,
offSchema: decision.off_schema
})
}
// State Transition (nur wenn nicht Radio Check)
if (!decision.radio_check) {
moveTo(decision.next_state)
}
moveTo(decision.next_state)
}
// Direkter Pilotenspruch → einfache Router-Heuristik (Interrupts/Readbacks). Für echte Logik nutzt ihr LLM:
function processPilotTransmission(transcript: string) {
// Interrupt-Shortcuts (nur in der Luft)
function processPilotTransmission(transcript: string): string | null {
// Log pilot input
speak('pilot', transcript, currentStateId.value)
// Radio Check Detection (fallback falls LLM es nicht erkennt)
const t = transcript.toLowerCase()
if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) {
const callsign = variables.value.callsign || ''
const response = `${callsign}, read you five by five.`
flags.value.radio_checks_done++
setTimeout(() => {
speak('atc', response, currentStateId.value, { radioCheck: true })
}, 500)
return response
}
// Emergency Interrupts
if (flags.value.in_air && /^(mayday|pan\s*pan)/.test(t)) {
const intId = t.startsWith('mayday') ? 'INT_MAYDAY' : 'INT_PANPAN'
moveTo(intId)
speak('pilot', transcript, intId)
return null
}
return null // LLM soll entscheiden
}
function processUserTransmission(transcript: string): string | null {
return processPilotTransmission(transcript)
}
function moveTo(stateId: string) {
if (!states.value[stateId]) {
console.warn(`[Engine] Unknown state: ${stateId}`)
return
}
// Standard: Loggen & LLM-Context anbieten
speak('pilot', transcript, currentStateId.value)
}
// Zum State springen und Auto-Aktionen ausführen
function moveTo(stateId: string) {
if (!states.value[stateId]) return
// Stack-Mechanik (vereinfacht): Interrupts pushen aktuellen State
if (stateId.startsWith('INT_')) flags.value.stack.push(currentStateId.value)
if (stateId.startsWith('INT_')) {
flags.value.stack.push(currentStateId.value)
}
currentStateId.value = stateId
const s = currentState.value
// Actions ausführen (nur SET/IF minimal)
// Actions ausführen
for (const act of s.actions ?? []) {
if (typeof act === 'string') continue
if (act.if && !safeEvalBoolean(act.if)) continue
if (act.set) setByPath({ variables: variables.value, flags: flags.value }, act.set, act.to)
}
// Handoff → Frequenz/Unit setzen
// Handoff
if (s.handoff?.to) {
flags.value.current_unit = unitFromHandoff(s.handoff.to)
if (s.handoff.freq) {
// Frequenz ins variables-Objekt aufnehmen, falls dynamisch
variables.value.handoff_freq = renderTpl(s.handoff.freq, { ...exposeCtx() })
variables.value.handoff_freq = renderTpl(s.handoff.freq, exposeCtx())
}
}
// Auto-Say (ATC/System)
if (s.say_tpl) speak(s.role, s.say_tpl, s.id!)
// Auto-Say
if (s.say_tpl) {
speak(s.role, s.say_tpl, s.id!)
}
// Auto-Ende / Monitor / Check-Readback werden vom LLM/Client gesteuert.
// Phase Update für Flight Context
updateFlightPhase(s.phase)
}
function updateFlightPhase(phase: Phase) {
const phaseMap: Record<Phase, string> = {
'Clearance': 'clearance',
'PushStart': 'ground',
'TaxiOut': 'ground',
'Departure': 'tower',
'Climb': 'departure',
'Enroute': 'enroute',
'Descent': 'enroute',
'Approach': 'approach',
'Landing': 'landing',
'TaxiIn': 'taxiin',
'Preflight': 'clearance',
'Postflight': 'taxiin',
'Interrupt': flightContext.value.phase,
'LostComms': flightContext.value.phase,
'Missed': 'approach'
}
if (phaseMap[phase]) {
flightContext.value.phase = phaseMap[phase]
}
}
// Letzten Flow wieder aufnehmen (z.B. nach Interrupt)
function resumePriorFlow() {
const prev = flags.value.stack.pop()
if (prev) moveTo(prev)
}
// Hilfsfunktionen
function speak(speaker: Role, tpl: string, stateId: string) {
function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) {
const msg = renderTpl(tpl, exposeCtx())
communicationLog.value.push({
const entry: EngineLog = {
timestamp: new Date(),
frequency: activeFrequency.value,
speaker,
message: msg,
normalized: normalizeATCText(msg, exposeCtxFlat()),
state: stateId
})
state: stateId,
radioCheck: options.radioCheck,
offSchema: options.offSchema
}
communicationLog.value.push(entry)
}
function exposeCtx() {
return { variables: variables.value, flags: flags.value }
}
function exposeCtxFlat() {
// Für den Normalizer wollen wir einfache Platzhalter-Namen
return {
...variables.value,
...flags.value
}
return { ...variables.value, ...flags.value }
}
function unitFromHandoff(to: string) {
@@ -396,26 +494,28 @@ export default function useCommunicationsEngine() {
const arr = ['A12','B15','C23','D8','E41','F18','G7','H33']
return arr[Math.floor(Math.random() * arr.length)]
}
function genRunway() {
const arr = ['25L','25R','07L','07R','18','36','09','27']
return arr[Math.floor(Math.random() * arr.length)]
}
function genSquawk() {
return String(Math.floor(Math.random() * 8000 + 1000)).padStart(4, '0')
}
function genATIS() {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
return letters[Math.floor(Math.random() * letters.length)]
}
function genSID(_route: string) {
const sids = ['SULUS5S', 'TOBAK2E', 'MARUN7F', 'CINDY1A', 'HELEN4B']
return sids[Math.floor(Math.random() * sids.length)]
}
// einfache bool-Guards (nur Variablen/Flags, keine eval unsicherer Ausdrücke)
function safeEvalBoolean(expr?: string): boolean {
if (!expr) return true
// sehr einfache Auswertung: flags.in_air === true
const m = expr.match(/^(variables|flags)\.([A-Za-z0-9_]+)\s*===\s*(true|false|'[^']*'|"[^"]*"|\d+)$/)
if (!m) return false
const bag = m[1] === 'variables' ? variables.value : flags.value as any
@@ -441,7 +541,7 @@ export default function useCommunicationsEngine() {
}
return {
// state/vars/flags
// State
currentState: computed(() => currentState.value),
currentStateId: readonly(currentStateId),
variables: readonly(variables),
@@ -450,19 +550,24 @@ export default function useCommunicationsEngine() {
activeFrequency,
communicationLog: readonly(communicationLog),
// lifecycle
// pm_alt.vue Integration
flightContext: readonly(flightContext),
currentStep,
// Lifecycle
initializeFlight,
// Pilot in → baut Kontext für LLM
// Communication
processPilotTransmission,
processUserTransmission,
buildLLMContext,
applyLLMDecision,
// Flow-Steuerung
// Flow Control
moveTo,
resumePriorFlow,
// utils
// Utilities
normalizeATCText
}
}