diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 81ad213..430d9e8 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -1,1104 +1,270 @@ diff --git a/server/api/atc/generate.post.ts b/server/api/atc/generate.post.ts index 2f193f7..d460e45 100644 --- a/server/api/atc/generate.post.ts +++ b/server/api/atc/generate.post.ts @@ -1,7 +1,7 @@ // server/api/atc/generate.post.ts import { createError, readBody } from "h3"; import { generateATCPhrase, getRandomPhraseForLesson, getPhrasesForLesson } from "../../utils/atcPhrases"; -import { normalizeATC } from "../../utils/openai"; +import { normalizeATC } from "../../utils/openaiOld"; export default defineEventHandler(async (event) => { const body = await readBody<{ diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 133a58e..836c2aa 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { openai, LLM_MODEL, normalizeATC } from "../../utils/openai"; +import { openaiOld, LLM_MODEL, normalizeATC } from "../../utils/openaiOld"; import { createReadStream } from "node:fs"; // ← Diesen Import hinzufügen! interface PTTRequest { @@ -141,7 +141,7 @@ MISTAKES: [list mistakes or "None"] ASSESSMENT: [excellent/good/needs_improvement/unacceptable]`; try { - const completion = await openai.chat.completions.create({ + const completion = await openaiOld.chat.completions.create({ model: LLM_MODEL, messages: [{ role: "user", content: prompt }], }); @@ -237,7 +237,7 @@ export default defineEventHandler(async (event) => { const audioFileForWhisper = body.format === 'wav' ? tmpAudioInput : tmpAudioWav; // 3. OpenAI Whisper für Transkription - const transcription = await openai.audio.transcriptions.create({ + const transcription = await openaiOld.audio.transcriptions.create({ file: createReadStream(audioFileForWhisper), // ReadStream model: "whisper-1", language: "en", // ATC ist standardmäßig auf Englisch diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index ba8bd15..bfc9dcb 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -4,7 +4,7 @@ import {writeFile, mkdir} from "node:fs/promises"; import {existsSync} from "node:fs"; import {join} from "node:path"; import {randomUUID} from "node:crypto"; -import {openai, TTS_MODEL, normalizeATC} from "../../utils/openai"; +import {openaiOld, TTS_MODEL, normalizeATC} from "../../utils/openaiOld"; import {request} from "node:http"; // dotenv config @@ -100,7 +100,7 @@ export default defineEventHandler(async (event) => { audioBuffer = await piperTTS(normalized, voice); } else { // --- OpenAI Fallback --- - const tts = await openai.audio.speech.create({ + const tts = await openaiOld.audio.speech.create({ model: TTS_MODEL, voice, format: "wav", diff --git a/server/utils/openai.ts b/server/utils/openai.ts index ea13b2c..8ff83e3 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,294 +1,90 @@ -// yarn add openai dotenv -import OpenAI from "openai"; -import dotenv from "dotenv"; -import fs from "node:fs"; +// server/utils/openai.ts +import OpenAI from 'openai' -dotenv.config(); +// Nutzt OPENAI_API_KEY & LLM_MODEL aus Env +const MODEL = process.env.LLM_MODEL || 'gpt-5-nano' +export const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY!}) -export const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY_PROD!, - project: process.env.OPENAI_PROJECT, // optional -}); - -export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano"; -export const TTS_MODEL = process.env.TTS_MODEL || "tts-1"; - -/* ========================= - LLM PROMPTS (überarbeitet) - ========================= - Ziel: LLM liefert kompakte, maschinenfreundliche ICAO-Zeile, die unser Normalizer→TTS perfekt erweitert. - WICHTIG: Zahlen/Marker exakt im unten definierten Output-Format, keine ausgeschriebenen Wörter. -*/ - -export const ATC_OUTPUT_SPEC = ` -OUTPUT RULES (STRICT): -- ONE instruction only. No chit-chat. No readback. No explanations. -- English ICAO phraseology; keep it concise. -- Use the following tokens exactly; numbers as digits: - * Callsign: AAA123[Letter] (e.g., DLH359, BAW12A). - * Runway: "RWY" + two digits + optional L/C/R (e.g., RWY 25R, RWY 08L). - * Heading: "HDG" + 3 digits (e.g., HDG 270). - * Flight level: "FL" + 2–3 digits (e.g., FL120, FL90). - * Altitude (feet): " ft" (e.g., 2000 ft, 5000 ft). - * Squawk: "squawk" + 4 digits (e.g., squawk 4723). - * QNH: "QNH" + 3–4 digits (e.g., QNH 1013). - * Frequency: 3 digits "." 3 digits (e.g., 112.955, 121.800). Use 3+3 format. - * ICAO airport: 4 capital letters (e.g., EDDF, EHAM). - * Taxi: "via" + space-separated TWY designators (e.g., via A3 A N2). -- Separate segments by ". " (period + space). Keep line to <= 220 chars. -- Only include items relevant to current phase (clearance/taxi/line-up/dep/approach/landing). -- If you must give a contact instruction: "Contact ." -- Use standard order for the phase (e.g., taxi: destination RWY first, then route, then hold short). -`.trim(); - -/** System-Prompt: legt Rolle/Regeln fest */ -export function atcSystemPrompt(opts?: { - regionHint?: "EUR" | "US" | "INTL"; // nur als Soft-Hinweis, default INTL -}) { - const region = opts?.regionHint ?? "INTL"; - return [ - `You are an ICAO-compliant ATC controller for ${region}.`, - `Adhere to standard phraseology, brevity, and safety-critical ordering.`, - `Assume you have access to current airport config (active runway, SIDs), unless contradicted by user context.`, - ATC_OUTPUT_SPEC, - ].join("\n\n"); -} - -/** Seed-ATC ohne Pilot-Input (rückwärtskompatible Signatur, aber reicherer Prompt) */ -export function atcSeedPrompt(s: { - airport: string; // e.g., "EDDF" - aircraft: string; // e.g., "A320" - type: string; // e.g., "IFR" - stand: string; // e.g., "V155" - dep: string; // destination ICAO, e.g., "EHAM" - sid?: string; // e.g., "MARUN 7F" - squawk?: string; // "4723" - freq?: string; // "121.800" - runway?: string; // "25R" (optional: falls bekannt) - phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; - notes?: string; // z.B. "TWY N closed between N2–N4" -}) { - // Default-Phase: clearance - const phase = s.phase || "clearance"; - const ctx = [ - `Airport ${s.airport}`, - `${s.aircraft} ${s.type} at stand ${s.stand}`, - `IFR departure ${s.dep}`, - s.runway ? `planned RWY ${s.runway}` : null, - s.sid ? `planned SID ${s.sid}` : null, - s.squawk ? `preassigned squawk ${s.squawk}` : null, - s.freq ? `next frequency ${s.freq}` : null, - s.notes ? `NOTAM/ATC notes: ${s.notes}` : null, - ].filter(Boolean).join(", "); - - const need = phase === "clearance" - ? "Issue an IFR clearance (route/SID if given, initial altitude, squawk, and current QNH if applicable)." - : phase === "taxi" - ? "Issue a taxi instruction to the departure RWY with a realistic taxi route and 'hold short'." - : phase === "lineup" - ? "Issue line-up and wait (or immediate takeoff if appropriate)." - : phase === "departure" - ? "Issue initial heading/speed/altitude or 'climb via SID' as appropriate." - : phase === "handoff" - ? "Issue handoff to next frequency." - : phase === "approach" - ? "Issue approach clearance with runway, altitude/FL, QNH if appropriate." - : "Issue landing clearance with runway and any exit/roll-out instructions."; - - return [ - `Generate ONE realistic ICAO ATC instruction in English. No extra commentary.`, - `Context: ${ctx}`, - `Phase: ${phase}`, - `Task: ${need}`, - ATC_OUTPUT_SPEC, - ].join("\n"); -} - -/** Pilot→ATC (rückwärtskompatibler Name, aber mit robustem Rahmen) */ -export function atcReplyPrompt(userText: string, state?: { - airport?: string; runway?: string; sid?: string; dep?: string; - lastSquawk?: string; lastFreq?: string; lastQNH?: string; - phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; - constraints?: string; // z.B. "TWY N closed", "no intersection deps on 25C" -}) { - const ctx = [ - state?.airport ? `Airport ${state.airport}` : null, - state?.runway ? `Active RWY ${state.runway}` : null, - state?.dep ? `Destination ${state.dep}` : null, - state?.sid ? `SID ${state.sid}` : null, - state?.lastSquawk ? `Last squawk ${state.lastSquawk}` : null, - state?.lastFreq ? `Next/last freq ${state.lastFreq}` : null, - state?.lastQNH ? `QNH ${state.lastQNH}` : null, - state?.constraints ? `Constraints: ${state.constraints}` : null, - ].filter(Boolean).join(", "); - - const phase = state?.phase ?? "clearance"; - - return [ - `You are an ICAO-compliant ATC controller. Reply concisely in standard phraseology.`, - ctx ? `Context: ${ctx}` : null, - `Pilot said: "${userText}"`, - `Phase: ${phase}`, - `Respond with ONE instruction following the rules.`, - ATC_OUTPUT_SPEC, - ].filter(Boolean).join("\n"); -} - -/* ========================= - Normalizer → TTS (wie zuvor) - ========================= */ - -const DIGIT: Record = { - "0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower", - "5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner", -}; - -const NATO: Record = { - A:"Alfa",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" -}; - -// Airline-Telephony (erweiterbar) -export const CALLSIGN_MAP: Record = { - DLH: "Lufthansa", - BAW: "Speedbird", - AFR: "Air France", - KLM: "KLM", - AAL: "American", - UAL: "United", - DAL: "Delta", - RYR: "Ryanair", - EZY: "Easy", -}; - -const spellDigits = (s: string) => - s.split("").map(ch => DIGIT[ch] ?? ch).join(" "); - -const toNato = (s: string) => - s.toUpperCase().split("").map(ch => NATO[ch] ?? ch).join("-"); - -const runwaySpeak = (rw: string) => { - const m = rw.match(/^(\d{2})([LCR])?$/i); - if (!m) return rw; - const num = spellDigits(m[1]); - const side = m[2]?.toUpperCase() === "L" ? "left" - : m[2]?.toUpperCase() === "C" ? "center" - : m[2]?.toUpperCase() === "R" ? "right" : ""; - return `runway ${num}${side ? " " + side : ""}`; -}; - -const headingSpeak = (hdg: string) => `heading ${spellDigits(hdg.padStart(3, "0"))}`; -const squawkSpeak = (code: string) => `squawk ${spellDigits(code)}`; - -const freqSpeak = (f: string) => { - const [a,b] = f.split("."); - const left = spellDigits(a); - const right = b ? spellDigits(b) : ""; - return `${left}${b ? " decimal " + right : ""}`; -}; - -const altitudeSpeak = (ft: number) => { - if (!Number.isFinite(ft)) return `${ft} feet`; - const thousands = Math.floor(ft/1000); - const hundreds = Math.round((ft % 1000)/100)*100; - const parts: string[] = []; - if (thousands) parts.push(`${spellDigits(String(thousands))} thousand`); - if (hundreds) { - const h = hundreds === 900 ? "nine hundred" - : hundreds === 800 ? "eight hundred" - : hundreds === 700 ? "seven hundred" - : hundreds === 600 ? "six hundred" - : hundreds === 500 ? "five hundred" - : hundreds === 400 ? "fower hundred" - : hundreds === 300 ? "tree hundred" - : hundreds === 200 ? "too hundred" - : hundreds === 100 ? "wun hundred" - : spellDigits(String(hundreds)); - parts.push(h); +/** + * 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 { + const body = { + model: MODEL, + response_format: {type: 'text'}, + messages: [ + {role: 'system', content: system}, + {role: 'user', content: user} + ], } - return `${parts.join(" ")} feet`.trim(); -}; - -const flightLevelSpeak = (fl: string) => - `flight level ${spellDigits(fl.replace(/^0+/, ""))}`; - -const qnhSpeak = (q: string) => `QNH ${spellDigits(q)}`; - -const callsignSpeak = (raw: string, map: Record) => { - const up = raw.toUpperCase(); - const m = up.match(/^([A-Z]{2,3})(\d{1,4}[A-Z]?)$/); - if (!m) return raw; - const telephony = map[m[1]] ?? toNato(m[1]).replace(/-/g," "); - const suffix = spellDigits(m[2].replace(/[A-Z]$/, (l) => " " + (NATO[l] ?? l))); - return `${telephony} ${suffix}`; -}; - -const icaoAirportSpeak = (code: string) => - /^[A-Z]{4}$/.test(code) ? toNato(code) : code; - -// Public Normalizer -export function normalizeATC( - text: string, - opts?: { airlineMap?: Record; } -) { - let out = text; - - out = out.replace(/\b(\d{3})\.(\d{3})\b/g, (_,a,b)=> `${freqSpeak(`${a}.${b}`)}`); - out = out.replace(/\b(?:HDG|heading)\s*(\d{2,3})\b/gi, (_,h)=> headingSpeak(h)); - out = out.replace(/\b(?:RWY|runway)\s*(\d{2}[LCR]?)\b/gi, (_,rw)=> runwaySpeak(rw)); - out = out.replace(/\b(?:squawk|code)\s*(\d{4})\b/gi, (_,c)=> squawkSpeak(c)); - out = out.replace(/\bFL\s*(\d{2,3})\b/gi, (_,fl)=> flightLevelSpeak(fl)); - out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_,ft)=> altitudeSpeak(Number(ft))); - out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_,q)=> qnhSpeak(q)); - out = out.replace(/\b([A-Z]{4})\b/g, (_,code)=> icaoAirportSpeak(code)); - out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (m)=> callsignSpeak(m, opts?.airlineMap ?? CALLSIGN_MAP)); - - return out.replace(/\s+/g," ").trim(); + console.log('OpenAI request:', body) + const r = await openai.chat.completions.create() + console.log('OpenAI response:', r) + return r.choices?.[0]?.message?.content?.trim() || '' } -// TTS Wrapper (mp3) -export async function speakATC(text: string, filePath = "atc.mp3") { - const input = normalizeATC(text); - const resp = await (openai as any).audio.speech.create({ - model: TTS_MODEL, - voice: "alloy", - input, - format: "mp3", - }); - const buf = Buffer.from(await resp.arrayBuffer()); - fs.writeFileSync(filePath, buf); - return { filePath, spoken: input }; +/** Typen wie im Frontend */ +export interface LLMDecisionInput { + state_id: string + state: any + candidates: Array<{ id: string; state: any }> + variables: Record + flags: Record + pilot_utterance: string } -/* ========================= - Beispiele - ========================= +export interface LLMDecision { + next_state: string + updates?: Record + flags?: Record + controller_say_tpl?: string +} -— Seed (Clearance): -const sys = atcSystemPrompt(); -const usr = atcSeedPrompt({ - airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", - dep: "EHAM", sid: "MARUN 7F", runway: "25R", freq: "121.800" -}); -// → LLM antwortet z.B.: -// "DLH359, cleared to EHAM via MARUN 7F, initial 5000 ft, squawk 4723. QNH 1013." +/** + * 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 { + 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).', + ].join(' ') -— Taxi: -const usrTaxi = atcSeedPrompt({ - airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", - dep: "EHAM", runway: "25R", phase: "taxi", - notes: "TWY N closed between N2–N4" -}); -// → "DLH359, taxi to RWY 25R via A3 A N2, hold short." + const user = JSON.stringify(input) -— Pilot→ATC: -const usrReply = atcReplyPrompt( - "DLH359 ready for departure RWY 25R", - { airport: "EDDF", runway: "25R", phase: "lineup", lastFreq: "121.800" } -); -// → "DLH359, line up and wait RWY 25R." + const body = { + model: MODEL, + // JSON-Ausgabe erzwingen + response_format: {type: 'json_object'}, + messages: [ + {role: 'system', content: system}, + {role: 'user', content: user} + ], + } -Nach dem LLM-Output: `speakATC(llmText)` ruft Normalizer→TTS. -*/ + 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') + } + return parsed as LLMDecision + } catch (e) { + // Fallback: sichere Default-Transition + return {next_state: 'GEN_NO_REPLY'} + } +} diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts new file mode 100644 index 0000000..10b4a52 --- /dev/null +++ b/shared/utils/communicationsEngine.ts @@ -0,0 +1,468 @@ +// composables/communicationsEngine.ts +import { ref, computed, readonly } from 'vue' +import atcDecisionTree from "./atcDecisionTree"; + +// --- DecisionTree-Types (aus ~/data/atcDecisionTree.json abgeleitet) --- +type Role = 'pilot' | 'atc' | 'system' +type Phase = + | 'Preflight' | 'Clearance' | 'PushStart' | 'TaxiOut' | 'Departure' + | 'Climb' | 'Enroute' | 'Descent' | 'Approach' | 'Landing' + | 'TaxiIn' | 'Postflight' | 'Interrupt' | 'LostComms' | 'Missed' + +interface DTNext { to: string; when?: string } +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. + role: Role + phase: Phase + // Texte + say_tpl?: string + utterance_tpl?: string + else_say_tpl?: string + // Routing + next?: DTNext[] + ok_next?: DTNext[] + bad_next?: DTNext[] + timer_next?: { after_s: number; to: string }[] + handoff?: DTHandoff + condition?: string + guard?: string + trigger?: string + auto?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent' + readback_required?: string[] + actions?: (DTActionSet | string)[] +} + +interface DecisionTree { + schema_version: string + name: string + description: string + start_state: string + end_states: string[] + variables: Record + flags: { + in_air: boolean + emergency_active: boolean + current_unit: string + stack: string[] + } + policies: { + timeouts: { + pilot_readback_timeout_s: number + controller_ack_timeout_s: number + no_reply_retry_after_s: number + no_reply_max_retries: number + lost_comms_detect_after_s: number + } + no_reply_sequence: { after_s: number; controller_say_tpl: string }[] + interrupts_allowed_when: Record + } + hooks: Record + roles: Role[] + phases: Phase[] + states: Record +} + +// --- ATC Decition Tree laden --- + +// ----------------- Public API / Kontext ----------------- +export interface FlightContext { + callsign: string + aircraft: string + dep: string + dest: string + stand: string + runway: string + squawk: string + atis_code: string + sid: string + transition: string + flight_level: string // z.B. "FL360" + ground_freq: string + tower_freq: string + departure_freq: string + approach_freq: string + handoff_freq: string + qnh_hpa: number | string + taxi_route: string + remarks?: string + time_now?: string + // intern + lastTransmission?: string + awaitingResponse?: boolean +} + +export interface EngineLog { + timestamp: Date + frequency?: string + speaker: Role + message: string + normalized: string + state: string +} + +// ----------------- NATO/ICAO Normalizer ----------------- +const NATO_PHONETIC: 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_NUMBERS: Record = { + '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 { + let normalized = renderTpl(text, context) + + // runway 25L → two five left + 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 + 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 + 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 + 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 { + 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] + return (cur ?? '').toString() + }) +} + +// ----------------- Engine ----------------- +export default function useCommunicationsEngine() { + // Decision Tree in-Memory + const tree = ref(atcDecisionTree as DecisionTree) + const states = computed>(() => tree.value.states) + + // Laufzeitkontext + const variables = ref>({ ...tree.value.variables }) + const flags = ref({ ...tree.value.flags }) + const currentStateId = ref(tree.value.start_state) + + const communicationLog = ref([]) + + const currentState = computed(() => { + const s = states.value[currentStateId.value] + return { ...s, id: currentStateId.value } + }) + + const nextCandidates = computed(() => { + const s = currentState.value + const nxt = [ + ...(s.next ?? []), + ...(s.ok_next ?? []), + ...(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(() => { + 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 }) + default: return undefined + } + }) + + // ---------- öffentliche API ---------- + function initializeFlight(fpl: any) { + variables.value = { + ...variables.value, + callsign: fpl.callsign, + acf_type: fpl.aircraft?.split('/')[0] || 'A320', + dep: fpl.dep || fpl.departure || 'EDDF', + dest: fpl.arr || fpl.arrival || 'EDDM', + stand: genStand(), + runway: genRunway(), + squawk: fpl.assignedsquawk || genSquawk(), + atis_code: genATIS(), + sid: genSID(fpl.route || ''), + transition: 'DCT', + 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', + tower_freq: '118.700', + departure_freq: '125.350', + approach_freq: '120.800', + handoff_freq: '121.800', + qnh_hpa: 1015, + remarks: 'standard', + time_now: new Date().toISOString() + } + flags.value = { ...flags.value, in_air: false, emergency_active: false, current_unit: 'DEL', stack: [] } + currentStateId.value = tree.value.start_state + communicationLog.value = [] + } + + // Pilot-Input verarbeiten → LLM bekommt State + Candidates + function buildLLMContext(pilotTranscript: string) { + const s = currentState.value + return { + state_id: s.id, + state: s, + candidates: nextCandidates.value.map(id => ({ id, state: states.value[id] })), + variables: variables.value, + flags: flags.value, + pilot_utterance: pilotTranscript + } + } + + // LLM-Entscheidung anwenden + function applyLLMDecision(decision: { next_state: string; updates?: Record; flags?: Record; controller_say_tpl?: string }) { + // Variablen/Flags updaten + 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) + } + 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) + const t = transcript.toLowerCase() + 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 + } + + // 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) + + currentStateId.value = stateId + const s = currentState.value + + // Actions ausführen (nur SET/IF minimal) + 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 + 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() }) + } + } + + // Auto-Say (ATC/System) + if (s.say_tpl) speak(s.role, s.say_tpl, s.id!) + + // Auto-Ende / Monitor / Check-Readback werden vom LLM/Client gesteuert. + } + + // 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) { + const msg = renderTpl(tpl, exposeCtx()) + communicationLog.value.push({ + timestamp: new Date(), + frequency: activeFrequency.value, + speaker, + message: msg, + normalized: normalizeATCText(msg, exposeCtxFlat()), + state: stateId + }) + } + + 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 + } + } + + function unitFromHandoff(to: string) { + if (/GROUND/i.test(to)) return 'GROUND' + if (/TOWER/i.test(to)) return 'TOWER' + if (/DEPART/i.test(to)) return 'DEP' + if (/APPROACH/i.test(to)) return 'APP' + if (/CENTER|CTR/i.test(to)) return 'CTR' + if (/DEL|DELIVERY/i.test(to)) return 'DEL' + return flags.value.current_unit + } + + function genStand() { + 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 + const key = m[2] + const rhsRaw = m[3] + let rhs: any = rhsRaw + if (rhsRaw === 'true') rhs = true + else if (rhsRaw === 'false') rhs = false + else if (/^\d+$/.test(rhsRaw)) rhs = Number(rhsRaw) + else rhs = rhsRaw.replace(/^['"]|['"]$/g, '') + return bag?.[key] === rhs + } + + function setByPath(root: Record, path: string, val: any) { + const parts = path.split('.') + let cur = root + for (let i = 0; i < parts.length - 1; i++) { + const p = parts[i] + if (!(p in cur)) cur[p] = {} + cur = cur[p] + } + cur[parts[parts.length - 1]] = val + } + + return { + // state/vars/flags + currentState: computed(() => currentState.value), + currentStateId: readonly(currentStateId), + variables: readonly(variables), + flags: readonly(flags), + nextCandidates, + activeFrequency, + communicationLog: readonly(communicationLog), + + // lifecycle + initializeFlight, + + // Pilot in → baut Kontext für LLM + processPilotTransmission, + buildLLMContext, + applyLLMDecision, + + // Flow-Steuerung + moveTo, + resumePriorFlow, + + // utils + normalizeATCText + } +}