mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
new version of pm using decision tree
This commit is contained in:
1280
app/pages/pm.vue
1280
app/pages/pm.vue
File diff suppressed because it is too large
Load Diff
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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): "<number> 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 <frequency>."
|
||||
- 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<string, string> = {
|
||||
"0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower",
|
||||
"5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner",
|
||||
};
|
||||
|
||||
const NATO: Record<string, string> = {
|
||||
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<string,string> = {
|
||||
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<string> {
|
||||
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<string,string>) => {
|
||||
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<string,string>; }
|
||||
) {
|
||||
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<string, any>
|
||||
flags: Record<string, any>
|
||||
pilot_utterance: string
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Beispiele
|
||||
=========================
|
||||
export interface LLMDecision {
|
||||
next_state: string
|
||||
updates?: Record<string, any>
|
||||
flags?: Record<string, any>
|
||||
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<LLMDecision> {
|
||||
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'}
|
||||
}
|
||||
}
|
||||
|
||||
468
shared/utils/communicationsEngine.ts
Normal file
468
shared/utils/communicationsEngine.ts
Normal file
@@ -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<string, any>
|
||||
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<string, string>
|
||||
}
|
||||
hooks: Record<string, boolean | string>
|
||||
roles: Role[]
|
||||
phases: Phase[]
|
||||
states: Record<string, DTState>
|
||||
}
|
||||
|
||||
// --- 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<string, string> = {
|
||||
A: 'Alpha', B: 'Bravo', C: 'Charlie', D: 'Delta', E: 'Echo', F: 'Foxtrot',
|
||||
G: 'Golf', H: 'Hotel', I: 'India', J: 'Juliett', K: 'Kilo', L: 'Lima',
|
||||
M: 'Mike', N: 'November', O: 'Oscar', P: 'Papa', Q: 'Quebec', R: 'Romeo',
|
||||
S: 'Sierra', T: 'Tango', U: 'Uniform', V: 'Victor', W: 'Whiskey',
|
||||
X: 'X-ray', Y: 'Yankee', Z: 'Zulu'
|
||||
}
|
||||
const ICAO_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
|
||||
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, 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]
|
||||
return (cur ?? '').toString()
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------- Engine -----------------
|
||||
export default function useCommunicationsEngine() {
|
||||
// Decision Tree in-Memory
|
||||
const tree = ref<DecisionTree>(atcDecisionTree as DecisionTree)
|
||||
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[]>([])
|
||||
|
||||
const currentState = computed<DTState>(() => {
|
||||
const s = states.value[currentStateId.value]
|
||||
return { ...s, id: currentStateId.value }
|
||||
})
|
||||
|
||||
const nextCandidates = computed<string[]>(() => {
|
||||
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<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 })
|
||||
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<string, any>; flags?: Record<string, any>; 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<string, any>, 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user