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/api/llm/decide.post.ts b/server/api/llm/decide.post.ts new file mode 100644 index 0000000..0641fa0 --- /dev/null +++ b/server/api/llm/decide.post.ts @@ -0,0 +1,18 @@ +// server/api/llm/decide.post.ts +import { readBody, createError } from 'h3' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + if (!body) { + throw createError({ statusCode: 400, statusMessage: 'Missing body' }) + } + 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 + } catch (err: any) { + throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' }) + } +}) 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/server/utils/openaiDecision.ts b/server/utils/openaiDecision.ts deleted file mode 100644 index 517edd1..0000000 --- a/server/utils/openaiDecision.ts +++ /dev/null @@ -1,27 +0,0 @@ -// utils/openaiDecision.ts -import { decide } from '~/server/utils/openai' // falls du schon nen Wrapper hast; sonst direkt OpenAI SDK - -export interface LLMDecisionInput { - state_id: string - state: any - candidates: Array<{ id: string; state: any }> - variables: Record - flags: Record - pilot_utterance: string -} - -export interface LLMDecision { - next_state: string - updates?: Record - flags?: Record - controller_say_tpl?: string -} - -export async function decideNextStateLLM(input: LLMDecisionInput): Promise { - // Prompt sehr kurz halten; Model bekommt alles als JSON - const system = 'You are an ATC state router. Return strict JSON with next_state, optional updates, flags, controller_say_tpl.' - const user = JSON.stringify(input) - const out = await decide(system, user) // → implementiere mit deinem OpenAI-Aufruf - // Erwartet reines JSON - return JSON.parse(out) as LLMDecision -} diff --git a/server/utils/openaiOld.ts b/server/utils/openaiOld.ts new file mode 100644 index 0000000..531ca7a --- /dev/null +++ b/server/utils/openaiOld.ts @@ -0,0 +1,294 @@ +// yarn add openai dotenv +import OpenAI from "openai"; +import dotenv from "dotenv"; +import fs from "node:fs"; + +dotenv.config(); + +export const openaiOld = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, + 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); + } + 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(); +} + +// TTS Wrapper (mp3) +export async function speakATC(text: string, filePath = "atc.mp3") { + const input = normalizeATC(text); + const resp = await (openaiOld 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 }; +} + +/* ========================= + Beispiele + ========================= + +— 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." + +— 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." + +— 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." + +Nach dem LLM-Output: `speakATC(llmText)` ruft Normalizer→TTS. +*/ diff --git a/public/data/atcDecisionTree.json b/shared/utils/atcDecisionTree.ts similarity index 99% rename from public/data/atcDecisionTree.json rename to shared/utils/atcDecisionTree.ts index c6ac9bf..81ce7f4 100644 --- a/public/data/atcDecisionTree.json +++ b/shared/utils/atcDecisionTree.ts @@ -1,4 +1,4 @@ -{ +const atcDecisionTree = { "schema_version": "1.0", "name": "icao_atc_decision_tree", "description": "Machine-readable ICAO/FAA-style ATC flow for IFR, suited for LLM+Whisper+TTS loop.", @@ -532,3 +532,6 @@ } } } + + +export default atcDecisionTree; diff --git a/app/composables/communicationsEngine.ts b/shared/utils/communicationsEngine.ts similarity index 98% rename from app/composables/communicationsEngine.ts rename to shared/utils/communicationsEngine.ts index b95f121..10b4a52 100644 --- a/app/composables/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,5 +1,6 @@ // 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' @@ -64,8 +65,7 @@ interface DecisionTree { states: Record } -// --- JSON laden (Vite unterstützt JSON-Import) --- -import decisionTreeJson from '~/data/atcDecisionTree.json' +// --- ATC Decition Tree laden --- // ----------------- Public API / Kontext ----------------- export interface FlightContext { @@ -206,7 +206,7 @@ function renderTpl(tpl: string, ctx: Record): string { // ----------------- Engine ----------------- export default function useCommunicationsEngine() { // Decision Tree in-Memory - const tree = ref(decisionTreeJson as DecisionTree) + const tree = ref(atcDecisionTree as DecisionTree) const states = computed>(() => tree.value.states) // Laufzeitkontext diff --git a/shared/utils/openaiDecision.ts b/shared/utils/openaiDecision.ts new file mode 100644 index 0000000..525e847 --- /dev/null +++ b/shared/utils/openaiDecision.ts @@ -0,0 +1,24 @@ +// utils/openaiDecision.ts +export interface LLMDecisionInput { + state_id: string + state: any + candidates: Array<{ id: string; state: any }> + variables: Record + flags: Record + pilot_utterance: string +} + +export interface LLMDecision { + next_state: string + updates?: Record + flags?: Record + controller_say_tpl?: string +} + +/** Client-seitig: ruft den Backend-Endpunkt auf */ +export async function decideNextStateLLM(input: LLMDecisionInput): Promise { + return await $fetch('/api/llm/decide', { + method: 'POST', + body: input + }) +}