mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
The system now intelligently handles edge cases while maintaining the core decision tree structure. The LLM will follow the schema when possible but can respond naturally when the pilot says something unexpected, making it much more realistic and flexible for training scenarios
This commit is contained in:
1062
app/pages/pm.vue
1062
app/pages/pm.vue
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,21 @@ export default defineEventHandler(async (event) => {
|
||||
if (!body.state_id || !Array.isArray(body.candidates)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid shape' })
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = await routeDecision(body)
|
||||
return decision // direkt das JSON des Routers zurückgeben
|
||||
|
||||
// Log für Debugging bei off-schema oder radio check
|
||||
if (decision.off_schema) {
|
||||
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
|
||||
}
|
||||
if (decision.radio_check) {
|
||||
console.log(`[ATC] Radio check processed: "${body.pilot_utterance}"`)
|
||||
}
|
||||
|
||||
return decision
|
||||
} catch (err: any) {
|
||||
console.error('Router failed:', err)
|
||||
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// server/utils/openai.ts
|
||||
import OpenAI from 'openai'
|
||||
|
||||
// Nutzt OPENAI_API_KEY & LLM_MODEL aus Env
|
||||
const MODEL = process.env.LLM_MODEL || 'gpt-5-nano'
|
||||
const MODEL = process.env.LLM_MODEL || 'gpt-4o-mini'
|
||||
export const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY!})
|
||||
|
||||
/**
|
||||
* Einfache Chat-Hülle, die reinen Text zurückgibt (z.B. für Debug/sonstige Server-Prompts).
|
||||
* WARNING: Client darf diese Funktion NICHT direkt importieren. Nur serverseitig nutzen.
|
||||
*/
|
||||
export async function decide(system: string, user: string): Promise<string> {
|
||||
const body = {
|
||||
model: MODEL,
|
||||
@@ -19,7 +14,7 @@ export async function decide(system: string, user: string): Promise<string> {
|
||||
],
|
||||
}
|
||||
console.log('OpenAI request:', body)
|
||||
const r = await openai.chat.completions.create()
|
||||
const r = await openai.chat.completions.create(body)
|
||||
console.log('OpenAI response:', r)
|
||||
return r.choices?.[0]?.message?.content?.trim() || ''
|
||||
}
|
||||
@@ -39,31 +34,74 @@ export interface LLMDecision {
|
||||
updates?: Record<string, any>
|
||||
flags?: Record<string, any>
|
||||
controller_say_tpl?: string
|
||||
off_schema?: boolean // Flag wenn LLM frei geantwortet hat
|
||||
radio_check?: boolean // Flag für Radio Check
|
||||
}
|
||||
|
||||
// Token-optimierte Eingabe für OpenAI
|
||||
function optimizeInputForLLM(input: LLMDecisionInput) {
|
||||
// Nur relevante Candidate-Daten senden
|
||||
const candidates = input.candidates.map(c => ({
|
||||
id: c.id,
|
||||
role: c.state.role,
|
||||
phase: c.state.phase,
|
||||
say_tpl: c.state.say_tpl,
|
||||
utterance_tpl: c.state.utterance_tpl
|
||||
}))
|
||||
|
||||
// Nur wichtige Variablen senden
|
||||
const relevantVars = {
|
||||
callsign: input.variables.callsign,
|
||||
dep: input.variables.dep,
|
||||
dest: input.variables.dest,
|
||||
runway: input.variables.runway,
|
||||
squawk: input.variables.squawk,
|
||||
current_frequency: input.variables[`${input.flags.current_unit?.toLowerCase()}_freq`]
|
||||
}
|
||||
|
||||
return {
|
||||
state_id: input.state_id,
|
||||
current_role: input.state.role,
|
||||
current_phase: input.state.phase,
|
||||
candidates: candidates,
|
||||
variables: relevantVars,
|
||||
flags: input.flags,
|
||||
pilot_utterance: input.pilot_utterance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Router-Entscheidung: Erzwingt JSON-Ausgabe.
|
||||
* Wähle NUR aus candidates[].id (Ausnahmen: RESUME_PRIOR_FLOW, GEN_NO_REPLY,
|
||||
* INT_* nur wenn Guards erfüllt – v.a. MAYDAY/PANPAN nur in-air).
|
||||
*/
|
||||
export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecision> {
|
||||
const optimizedInput = optimizeInputForLLM(input)
|
||||
|
||||
const system = [
|
||||
'You are an ATC state router.',
|
||||
'Return STRICT JSON with keys: next_state (string), optional updates (object), flags (object), controller_say_tpl (string).',
|
||||
'Pick next_state ONLY from input.candidates[].id.',
|
||||
'Allowed exceptions:',
|
||||
'- "RESUME_PRIOR_FLOW" to return from interrupts.',
|
||||
'- "GEN_NO_REPLY" if pilot did not respond / mismatch.',
|
||||
'- Interrupts "INT_MAYDAY" / "INT_PANPAN" only if input.flags.in_air === true.',
|
||||
'If nothing matches, prefer "GEN_NO_REPLY".',
|
||||
'Do NOT invent IDs. Keep updates minimal (only variables used in templates).',
|
||||
'You are an ATC state router with flexible response capability.',
|
||||
'Return STRICT JSON with keys: next_state (string), optional updates (object), flags (object), controller_say_tpl (string), off_schema (boolean), radio_check (boolean).',
|
||||
'',
|
||||
'PRIMARY ROUTING:',
|
||||
'- Pick next_state from candidates[].id when pilot utterance matches expected flow',
|
||||
'- Use "RESUME_PRIOR_FLOW" to return from interrupts',
|
||||
'- Use "GEN_NO_REPLY" if pilot did not respond clearly',
|
||||
'',
|
||||
'SPECIAL CASES:',
|
||||
'- RADIO CHECK: If pilot says "radio check" or requests signal test, set radio_check=true and respond with appropriate signal strength',
|
||||
'- OFF-SCHEMA: If pilot says something that doesn\'t match any candidate but requires ATC response, set off_schema=true and provide appropriate controller_say_tpl',
|
||||
'',
|
||||
'INTERRUPTS (only if flags.in_air === true):',
|
||||
'- "INT_MAYDAY" for MAYDAY emergencies',
|
||||
'- "INT_PANPAN" for PAN-PAN situations',
|
||||
'',
|
||||
'RESPONSE GENERATION:',
|
||||
'- Always use proper ATC phraseology in controller_say_tpl',
|
||||
'- Include callsign and relevant flight data',
|
||||
'- Keep responses professional and concise',
|
||||
'',
|
||||
'If uncertain, prefer appropriate ATC response over silence.'
|
||||
].join(' ')
|
||||
|
||||
const user = JSON.stringify(input)
|
||||
const user = JSON.stringify(optimizedInput)
|
||||
|
||||
const body = {
|
||||
model: MODEL,
|
||||
// JSON-Ausgabe erzwingen
|
||||
response_format: {type: 'json_object'},
|
||||
messages: [
|
||||
{role: 'system', content: system},
|
||||
@@ -72,19 +110,40 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
}
|
||||
|
||||
const r = await openai.chat.completions.create(body)
|
||||
|
||||
console.log('OpenAI router response:', r)
|
||||
|
||||
const raw = r.choices?.[0]?.message?.content || '{}'
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
// Minimal-Check
|
||||
if (!parsed.next_state || typeof parsed.next_state !== 'string') {
|
||||
throw new Error('Missing next_state')
|
||||
}
|
||||
|
||||
// Radio Check Handling
|
||||
if (parsed.radio_check) {
|
||||
const callsign = input.variables.callsign || ''
|
||||
const freq = optimizedInput.variables.current_frequency || ''
|
||||
parsed.controller_say_tpl = `${callsign}, ${freq}, read you five by five.`
|
||||
}
|
||||
|
||||
return parsed as LLMDecision
|
||||
} catch (e) {
|
||||
// Fallback: sichere Default-Transition
|
||||
console.error('LLM JSON parse error:', e)
|
||||
|
||||
// Fallback: Check for radio check in pilot utterance
|
||||
const pilotText = input.pilot_utterance.toLowerCase()
|
||||
if (pilotText.includes('radio check') || pilotText.includes('signal') || pilotText.includes('read')) {
|
||||
const callsign = input.variables.callsign || ''
|
||||
return {
|
||||
next_state: input.state_id, // Stay in current state
|
||||
radio_check: true,
|
||||
controller_say_tpl: `${callsign}, read you five by five.`
|
||||
}
|
||||
}
|
||||
|
||||
// Standard fallback
|
||||
return {next_state: 'GEN_NO_REPLY'}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,12 @@ interface DTHandoff { to: string; freq: string }
|
||||
interface DTActionSet { set?: string; to?: any; if?: string }
|
||||
|
||||
interface DTState {
|
||||
id?: string // im JSON sind Keys die IDs — hier zur Bequemlichkeit.
|
||||
id?: string
|
||||
role: Role
|
||||
phase: Phase
|
||||
// Texte
|
||||
say_tpl?: string
|
||||
utterance_tpl?: string
|
||||
else_say_tpl?: string
|
||||
// Routing
|
||||
next?: DTNext[]
|
||||
ok_next?: DTNext[]
|
||||
bad_next?: DTNext[]
|
||||
@@ -33,6 +31,8 @@ interface DTState {
|
||||
auto?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent'
|
||||
readback_required?: string[]
|
||||
actions?: (DTActionSet | string)[]
|
||||
frequency?: string // Für einfachere Freq-Zugriffe
|
||||
frequencyName?: string // Name der Frequenz (DEL, GND, TWR, etc.)
|
||||
}
|
||||
|
||||
interface DecisionTree {
|
||||
@@ -47,6 +47,8 @@ interface DecisionTree {
|
||||
emergency_active: boolean
|
||||
current_unit: string
|
||||
stack: string[]
|
||||
off_schema_count: number // Zähler für Off-Schema Antworten
|
||||
radio_checks_done: number // Zähler für Radio Checks
|
||||
}
|
||||
policies: {
|
||||
timeouts: {
|
||||
@@ -65,9 +67,33 @@ interface DecisionTree {
|
||||
states: Record<string, DTState>
|
||||
}
|
||||
|
||||
// --- ATC Decition Tree laden ---
|
||||
// Flight Phases und Communication Steps für bessere Integration
|
||||
export const FLIGHT_PHASES = [
|
||||
{ id: 'clearance', name: 'Clearance Delivery', frequency: '121.900', action: 'Request IFR clearance' },
|
||||
{ id: 'ground', name: 'Ground Control', frequency: '121.700', action: 'Request pushback and taxi' },
|
||||
{ id: 'tower', name: 'Tower', frequency: '118.700', action: 'Request takeoff clearance' },
|
||||
{ id: 'departure', name: 'Departure', frequency: '125.350', action: 'Initial contact after takeoff' },
|
||||
{ id: 'enroute', name: 'Center', frequency: '121.800', action: 'Cruise flight monitoring' },
|
||||
{ id: 'approach', name: 'Approach', frequency: '120.800', action: 'Approach clearance' },
|
||||
{ id: 'landing', name: 'Tower (Landing)', frequency: '118.700', action: 'Landing clearance' },
|
||||
{ id: 'taxiin', name: 'Ground (Taxi In)', frequency: '121.700', action: 'Taxi to gate' }
|
||||
]
|
||||
|
||||
// ----------------- Public API / Kontext -----------------
|
||||
export const COMMUNICATION_STEPS = [
|
||||
{
|
||||
id: 'cd_request',
|
||||
phase: 'clearance',
|
||||
trigger: 'pilot',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Clearance Delivery',
|
||||
pilot: '{callsign} information {atis_code}, IFR to {dest}, stand {stand}, request clearance.',
|
||||
atc: '{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.',
|
||||
pilotResponse: '{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.'
|
||||
}
|
||||
// Weitere Steps können hier definiert werden
|
||||
]
|
||||
|
||||
// --- ATC Decision Tree laden ---
|
||||
export interface FlightContext {
|
||||
callsign: string
|
||||
aircraft: string
|
||||
@@ -79,7 +105,7 @@ export interface FlightContext {
|
||||
atis_code: string
|
||||
sid: string
|
||||
transition: string
|
||||
flight_level: string // z.B. "FL360"
|
||||
flight_level: string
|
||||
ground_freq: string
|
||||
tower_freq: string
|
||||
departure_freq: string
|
||||
@@ -89,7 +115,7 @@ export interface FlightContext {
|
||||
taxi_route: string
|
||||
remarks?: string
|
||||
time_now?: string
|
||||
// intern
|
||||
phase: string
|
||||
lastTransmission?: string
|
||||
awaitingResponse?: boolean
|
||||
}
|
||||
@@ -101,9 +127,11 @@ export interface EngineLog {
|
||||
message: string
|
||||
normalized: string
|
||||
state: string
|
||||
radioCheck?: boolean
|
||||
offSchema?: boolean
|
||||
}
|
||||
|
||||
// ----------------- NATO/ICAO Normalizer -----------------
|
||||
// NATO/ICAO Normalizer (gekürzt für bessere Performance)
|
||||
const NATO_PHONETIC: Record<string, string> = {
|
||||
A: 'Alpha', B: 'Bravo', C: 'Charlie', D: 'Delta', E: 'Echo', F: 'Foxtrot',
|
||||
G: 'Golf', H: 'Hotel', I: 'India', J: 'Juliett', K: 'Kilo', L: 'Lima',
|
||||
@@ -111,91 +139,46 @@ const NATO_PHONETIC: Record<string, string> = {
|
||||
S: 'Sierra', T: 'Tango', U: 'Uniform', V: 'Victor', W: 'Whiskey',
|
||||
X: 'X-ray', Y: 'Yankee', Z: 'Zulu'
|
||||
}
|
||||
|
||||
const ICAO_NUMBERS: Record<string, string> = {
|
||||
'0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower',
|
||||
'5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner'
|
||||
}
|
||||
|
||||
function normalizeAltitude(altitude: number): string {
|
||||
if (altitude >= 1000) {
|
||||
const thousands = Math.floor(altitude / 1000).toString().split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
const remainder = altitude % 1000
|
||||
return remainder > 0 ? `${thousands} thousand ${remainder}` : `${thousands} thousand`
|
||||
}
|
||||
return altitude.toString()
|
||||
}
|
||||
|
||||
export function normalizeATCText(text: string, context: Record<string, any>): string {
|
||||
let normalized = renderTpl(text, context)
|
||||
|
||||
// runway 25L → two five left
|
||||
// Runway normalization
|
||||
normalized = normalized.replace(/runway\s+(\d{1,2})([LRC]?)/gi, (_, num: string, suffix: string) => {
|
||||
const n = num.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
const s = suffix === 'L' ? ' left' : suffix === 'R' ? ' right' : suffix === 'C' ? ' center' : ''
|
||||
return `runway ${n}${s}`
|
||||
})
|
||||
|
||||
// FL350 → flight level three five zero
|
||||
// Flight Level
|
||||
normalized = normalized.replace(/FL(\d{3})/gi, (_, lvl: string) => {
|
||||
const n = lvl.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
return `flight level ${n}`
|
||||
})
|
||||
|
||||
// 5000 ft → five thousand
|
||||
normalized = normalized.replace(/\b(\d{1,5})\s*(?:feet?|ft)\b/gi, (_, alt: string) => normalizeAltitude(parseInt(alt)))
|
||||
|
||||
// 121.9 → wun too wun decimal niner
|
||||
// Frequency normalization
|
||||
normalized = normalized.replace(/(\d{3})\.(\d{1,3})/g, (_, a: string, b: string) => {
|
||||
const A = a.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
const B = b.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
return `${A} decimal ${B}`
|
||||
})
|
||||
|
||||
// squawk 4567 → fower fife six seven
|
||||
// Squawk codes
|
||||
normalized = normalized.replace(/squawk\s+(\d{4})/gi, (_, code: string) => {
|
||||
const n = code.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
return `squawk ${n}`
|
||||
})
|
||||
|
||||
// heading 270 → heading two seven zero
|
||||
normalized = normalized.replace(/heading\s+(\d{3})/gi, (_, hdg: string) => {
|
||||
const n = hdg.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
return `heading ${n}`
|
||||
})
|
||||
|
||||
// via A, B5 → via Alpha, Bravo five
|
||||
normalized = normalized.replace(/via\s+([A-Z](?:\d+)?(?:\s*,\s*[A-Z](?:\d+)?)*)/gi, (_, route: string) => {
|
||||
const parts = route.split(/\s*,\s*/).map(seg =>
|
||||
seg.replace(/([A-Z])(\d+)?/g, (_m, L: string, N?: string) => {
|
||||
const letter = NATO_PHONETIC[L] || L
|
||||
const number = N ? N.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') : ''
|
||||
return number ? `${letter} ${number}` : letter
|
||||
})
|
||||
)
|
||||
return `via ${parts.join(', ')}`
|
||||
})
|
||||
|
||||
// A5 → Alpha five
|
||||
normalized = normalized.replace(/\b([A-Z])(\d+)\b/g, (_m, L: string, N: string) => {
|
||||
const letter = NATO_PHONETIC[L] || L
|
||||
const number = N.split('').map(d => ICAO_NUMBERS[d] || d).join(' ')
|
||||
return `${letter} ${number}`
|
||||
})
|
||||
|
||||
// 280/15 → two eight zero at wun fife
|
||||
normalized = normalized.replace(/(\d{3})\/(\d{1,2})/g, (_m, d: string, s: string) => {
|
||||
const D = d.split('').map(x => ICAO_NUMBERS[x] || x).join(' ')
|
||||
const S = s.split('').map(x => ICAO_NUMBERS[x] || x).join(' ')
|
||||
return `${D} at ${S}`
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ----------------- Template-Renderer -----------------
|
||||
function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
|
||||
// Variablen können z.B. variables.callsign oder flags.in_air sein
|
||||
const parts = key.split('.')
|
||||
let cur: any = ctx
|
||||
for (const p of parts) cur = cur?.[p]
|
||||
@@ -203,21 +186,48 @@ function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------- Engine -----------------
|
||||
export default function useCommunicationsEngine() {
|
||||
// Decision Tree in-Memory
|
||||
const tree = ref<DecisionTree>(atcDecisionTree as DecisionTree)
|
||||
const tree = ref<DecisionTree>({
|
||||
...atcDecisionTree as DecisionTree,
|
||||
flags: {
|
||||
...atcDecisionTree.flags,
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0
|
||||
}
|
||||
})
|
||||
|
||||
const states = computed<Record<string, DTState>>(() => tree.value.states)
|
||||
|
||||
// Laufzeitkontext
|
||||
const variables = ref<Record<string, any>>({ ...tree.value.variables })
|
||||
const flags = ref({ ...tree.value.flags })
|
||||
const currentStateId = ref<string>(tree.value.start_state)
|
||||
|
||||
const communicationLog = ref<EngineLog[]>([])
|
||||
|
||||
// Flight Context für bessere Integration mit pm_alt.vue Stil
|
||||
const flightContext = ref<FlightContext>({
|
||||
callsign: '',
|
||||
aircraft: 'A320',
|
||||
dep: 'EDDF',
|
||||
dest: 'EDDM',
|
||||
stand: 'A12',
|
||||
runway: '25R',
|
||||
squawk: '1234',
|
||||
atis_code: 'K',
|
||||
sid: 'ANEKI7S',
|
||||
transition: 'ANEKI',
|
||||
flight_level: 'FL360',
|
||||
ground_freq: '121.700',
|
||||
tower_freq: '118.700',
|
||||
departure_freq: '125.350',
|
||||
approach_freq: '120.800',
|
||||
handoff_freq: '121.800',
|
||||
qnh_hpa: 1015,
|
||||
taxi_route: 'A, V',
|
||||
phase: 'clearance'
|
||||
})
|
||||
|
||||
const currentState = computed<DTState>(() => {
|
||||
const s = states.value[currentStateId.value]
|
||||
const s = states.value[currentStateId.value] || states.value[tree.value.start_state]
|
||||
return { ...s, id: currentStateId.value }
|
||||
})
|
||||
|
||||
@@ -229,28 +239,41 @@ export default function useCommunicationsEngine() {
|
||||
...(s.bad_next ?? []),
|
||||
...(s.timer_next?.map(t => ({ to: t.to })) ?? [])
|
||||
].map(x => x.to)
|
||||
// uniq
|
||||
return Array.from(new Set(nxt))
|
||||
})
|
||||
|
||||
// Frequenz aus Variablen (Controller-Einheit) ableiten
|
||||
const activeFrequency = computed<string | undefined>(() => {
|
||||
switch (flags.value.current_unit) {
|
||||
case 'DEL': return renderTpl('{variables.delivery_freq}', { variables: variables.value })
|
||||
case 'GROUND': return renderTpl('{variables.ground_freq}', { variables: variables.value })
|
||||
case 'TOWER': return renderTpl('{variables.tower_freq}', { variables: variables.value })
|
||||
case 'DEP': return renderTpl('{variables.departure_freq}', { variables: variables.value })
|
||||
case 'APP': return renderTpl('{variables.approach_freq}', { variables: variables.value })
|
||||
case 'CTR': return renderTpl('{variables.handoff_freq}', { variables: variables.value })
|
||||
case 'DEL': return variables.value.delivery_freq
|
||||
case 'GROUND': return variables.value.ground_freq
|
||||
case 'TOWER': return variables.value.tower_freq
|
||||
case 'DEP': return variables.value.departure_freq
|
||||
case 'APP': return variables.value.approach_freq
|
||||
case 'CTR': return variables.value.handoff_freq
|
||||
default: return undefined
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- öffentliche API ----------
|
||||
// Current Step für pm_alt.vue Integration
|
||||
const currentStep = computed(() => {
|
||||
const phase = flightContext.value.phase
|
||||
const step = COMMUNICATION_STEPS.find(s => s.phase === phase)
|
||||
if (step) {
|
||||
return {
|
||||
...step,
|
||||
pilot: renderTpl(step.pilot, { ...variables.value, ...flags.value }),
|
||||
atc: step.atc ? renderTpl(step.atc, { ...variables.value, ...flags.value }) : undefined,
|
||||
pilotResponse: step.pilotResponse ? renderTpl(step.pilotResponse, { ...variables.value, ...flags.value }) : undefined
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function initializeFlight(fpl: any) {
|
||||
// Variablen setzen
|
||||
variables.value = {
|
||||
...variables.value,
|
||||
callsign: fpl.callsign,
|
||||
callsign: fpl.callsign || fpl.callsign,
|
||||
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
|
||||
dep: fpl.dep || fpl.departure || 'EDDF',
|
||||
dest: fpl.arr || fpl.arrival || 'EDDM',
|
||||
@@ -263,8 +286,6 @@ export default function useCommunicationsEngine() {
|
||||
cruise_flight_level: fpl.altitude ? `FL${String(Math.floor(parseInt(fpl.altitude) / 100)).padStart(3, '0')}` : 'FL360',
|
||||
initial_altitude_ft: 5000,
|
||||
climb_altitude_ft: 7000,
|
||||
star: 'RNAV X',
|
||||
approach_type: 'ILS Z',
|
||||
taxi_route: 'A, V',
|
||||
delivery_freq: '121.900',
|
||||
ground_freq: '121.700',
|
||||
@@ -276,12 +297,27 @@ export default function useCommunicationsEngine() {
|
||||
remarks: 'standard',
|
||||
time_now: new Date().toISOString()
|
||||
}
|
||||
flags.value = { ...flags.value, in_air: false, emergency_active: false, current_unit: 'DEL', stack: [] }
|
||||
|
||||
// Flight Context aktualisieren
|
||||
Object.assign(flightContext.value, {
|
||||
...variables.value,
|
||||
phase: 'clearance'
|
||||
})
|
||||
|
||||
flags.value = {
|
||||
...flags.value,
|
||||
in_air: false,
|
||||
emergency_active: false,
|
||||
current_unit: 'DEL',
|
||||
stack: [],
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0
|
||||
}
|
||||
|
||||
currentStateId.value = tree.value.start_state
|
||||
communicationLog.value = []
|
||||
}
|
||||
|
||||
// Pilot-Input verarbeiten → LLM bekommt State + Candidates
|
||||
function buildLLMContext(pilotTranscript: string) {
|
||||
const s = currentState.value
|
||||
return {
|
||||
@@ -294,92 +330,154 @@ export default function useCommunicationsEngine() {
|
||||
}
|
||||
}
|
||||
|
||||
// LLM-Entscheidung anwenden
|
||||
function applyLLMDecision(decision: { next_state: string; updates?: Record<string, any>; flags?: Record<string, any>; controller_say_tpl?: string }) {
|
||||
// Variablen/Flags updaten
|
||||
function applyLLMDecision(decision: any) {
|
||||
// Updates anwenden
|
||||
if (decision.updates) Object.assign(variables.value, decision.updates)
|
||||
if (decision.flags) Object.assign(flags.value, decision.flags)
|
||||
// ggf. ATC sagt etwas sofort (z.B. Reminders)
|
||||
if (decision.controller_say_tpl) {
|
||||
speak('atc', decision.controller_say_tpl, currentStateId.value)
|
||||
|
||||
// Off-Schema und Radio Check Tracking
|
||||
if (decision.off_schema) {
|
||||
flags.value.off_schema_count++
|
||||
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
|
||||
}
|
||||
if (decision.radio_check) {
|
||||
flags.value.radio_checks_done++
|
||||
console.log(`[Engine] Radio check #${flags.value.radio_checks_done}`)
|
||||
}
|
||||
|
||||
// Controller Response
|
||||
if (decision.controller_say_tpl) {
|
||||
speak('atc', decision.controller_say_tpl, currentStateId.value, {
|
||||
radioCheck: decision.radio_check,
|
||||
offSchema: decision.off_schema
|
||||
})
|
||||
}
|
||||
|
||||
// State Transition (nur wenn nicht Radio Check)
|
||||
if (!decision.radio_check) {
|
||||
moveTo(decision.next_state)
|
||||
}
|
||||
moveTo(decision.next_state)
|
||||
}
|
||||
|
||||
// Direkter Pilotenspruch → einfache Router-Heuristik (Interrupts/Readbacks). Für echte Logik nutzt ihr LLM:
|
||||
function processPilotTransmission(transcript: string) {
|
||||
// Interrupt-Shortcuts (nur in der Luft)
|
||||
function processPilotTransmission(transcript: string): string | null {
|
||||
// Log pilot input
|
||||
speak('pilot', transcript, currentStateId.value)
|
||||
|
||||
// Radio Check Detection (fallback falls LLM es nicht erkennt)
|
||||
const t = transcript.toLowerCase()
|
||||
if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) {
|
||||
const callsign = variables.value.callsign || ''
|
||||
const response = `${callsign}, read you five by five.`
|
||||
flags.value.radio_checks_done++
|
||||
|
||||
setTimeout(() => {
|
||||
speak('atc', response, currentStateId.value, { radioCheck: true })
|
||||
}, 500)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Emergency Interrupts
|
||||
if (flags.value.in_air && /^(mayday|pan\s*pan)/.test(t)) {
|
||||
const intId = t.startsWith('mayday') ? 'INT_MAYDAY' : 'INT_PANPAN'
|
||||
moveTo(intId)
|
||||
speak('pilot', transcript, intId)
|
||||
return null
|
||||
}
|
||||
|
||||
return null // LLM soll entscheiden
|
||||
}
|
||||
|
||||
function processUserTransmission(transcript: string): string | null {
|
||||
return processPilotTransmission(transcript)
|
||||
}
|
||||
|
||||
function moveTo(stateId: string) {
|
||||
if (!states.value[stateId]) {
|
||||
console.warn(`[Engine] Unknown state: ${stateId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Standard: Loggen & LLM-Context anbieten
|
||||
speak('pilot', transcript, currentStateId.value)
|
||||
}
|
||||
|
||||
// Zum State springen und Auto-Aktionen ausführen
|
||||
function moveTo(stateId: string) {
|
||||
if (!states.value[stateId]) return
|
||||
// Stack-Mechanik (vereinfacht): Interrupts pushen aktuellen State
|
||||
if (stateId.startsWith('INT_')) flags.value.stack.push(currentStateId.value)
|
||||
if (stateId.startsWith('INT_')) {
|
||||
flags.value.stack.push(currentStateId.value)
|
||||
}
|
||||
|
||||
currentStateId.value = stateId
|
||||
const s = currentState.value
|
||||
|
||||
// Actions ausführen (nur SET/IF minimal)
|
||||
// Actions ausführen
|
||||
for (const act of s.actions ?? []) {
|
||||
if (typeof act === 'string') continue
|
||||
if (act.if && !safeEvalBoolean(act.if)) continue
|
||||
if (act.set) setByPath({ variables: variables.value, flags: flags.value }, act.set, act.to)
|
||||
}
|
||||
|
||||
// Handoff → Frequenz/Unit setzen
|
||||
// Handoff
|
||||
if (s.handoff?.to) {
|
||||
flags.value.current_unit = unitFromHandoff(s.handoff.to)
|
||||
if (s.handoff.freq) {
|
||||
// Frequenz ins variables-Objekt aufnehmen, falls dynamisch
|
||||
variables.value.handoff_freq = renderTpl(s.handoff.freq, { ...exposeCtx() })
|
||||
variables.value.handoff_freq = renderTpl(s.handoff.freq, exposeCtx())
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-Say (ATC/System)
|
||||
if (s.say_tpl) speak(s.role, s.say_tpl, s.id!)
|
||||
// Auto-Say
|
||||
if (s.say_tpl) {
|
||||
speak(s.role, s.say_tpl, s.id!)
|
||||
}
|
||||
|
||||
// Auto-Ende / Monitor / Check-Readback werden vom LLM/Client gesteuert.
|
||||
// Phase Update für Flight Context
|
||||
updateFlightPhase(s.phase)
|
||||
}
|
||||
|
||||
function updateFlightPhase(phase: Phase) {
|
||||
const phaseMap: Record<Phase, string> = {
|
||||
'Clearance': 'clearance',
|
||||
'PushStart': 'ground',
|
||||
'TaxiOut': 'ground',
|
||||
'Departure': 'tower',
|
||||
'Climb': 'departure',
|
||||
'Enroute': 'enroute',
|
||||
'Descent': 'enroute',
|
||||
'Approach': 'approach',
|
||||
'Landing': 'landing',
|
||||
'TaxiIn': 'taxiin',
|
||||
'Preflight': 'clearance',
|
||||
'Postflight': 'taxiin',
|
||||
'Interrupt': flightContext.value.phase,
|
||||
'LostComms': flightContext.value.phase,
|
||||
'Missed': 'approach'
|
||||
}
|
||||
|
||||
if (phaseMap[phase]) {
|
||||
flightContext.value.phase = phaseMap[phase]
|
||||
}
|
||||
}
|
||||
|
||||
// Letzten Flow wieder aufnehmen (z.B. nach Interrupt)
|
||||
function resumePriorFlow() {
|
||||
const prev = flags.value.stack.pop()
|
||||
if (prev) moveTo(prev)
|
||||
}
|
||||
|
||||
// Hilfsfunktionen
|
||||
function speak(speaker: Role, tpl: string, stateId: string) {
|
||||
function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) {
|
||||
const msg = renderTpl(tpl, exposeCtx())
|
||||
communicationLog.value.push({
|
||||
const entry: EngineLog = {
|
||||
timestamp: new Date(),
|
||||
frequency: activeFrequency.value,
|
||||
speaker,
|
||||
message: msg,
|
||||
normalized: normalizeATCText(msg, exposeCtxFlat()),
|
||||
state: stateId
|
||||
})
|
||||
state: stateId,
|
||||
radioCheck: options.radioCheck,
|
||||
offSchema: options.offSchema
|
||||
}
|
||||
communicationLog.value.push(entry)
|
||||
}
|
||||
|
||||
function exposeCtx() {
|
||||
return { variables: variables.value, flags: flags.value }
|
||||
}
|
||||
|
||||
function exposeCtxFlat() {
|
||||
// Für den Normalizer wollen wir einfache Platzhalter-Namen
|
||||
return {
|
||||
...variables.value,
|
||||
...flags.value
|
||||
}
|
||||
return { ...variables.value, ...flags.value }
|
||||
}
|
||||
|
||||
function unitFromHandoff(to: string) {
|
||||
@@ -396,26 +494,28 @@ export default function useCommunicationsEngine() {
|
||||
const arr = ['A12','B15','C23','D8','E41','F18','G7','H33']
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
}
|
||||
|
||||
function genRunway() {
|
||||
const arr = ['25L','25R','07L','07R','18','36','09','27']
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
}
|
||||
|
||||
function genSquawk() {
|
||||
return String(Math.floor(Math.random() * 8000 + 1000)).padStart(4, '0')
|
||||
}
|
||||
|
||||
function genATIS() {
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
return letters[Math.floor(Math.random() * letters.length)]
|
||||
}
|
||||
|
||||
function genSID(_route: string) {
|
||||
const sids = ['SULUS5S', 'TOBAK2E', 'MARUN7F', 'CINDY1A', 'HELEN4B']
|
||||
return sids[Math.floor(Math.random() * sids.length)]
|
||||
}
|
||||
|
||||
// einfache bool-Guards (nur Variablen/Flags, keine eval unsicherer Ausdrücke)
|
||||
function safeEvalBoolean(expr?: string): boolean {
|
||||
if (!expr) return true
|
||||
// sehr einfache Auswertung: flags.in_air === true
|
||||
const m = expr.match(/^(variables|flags)\.([A-Za-z0-9_]+)\s*===\s*(true|false|'[^']*'|"[^"]*"|\d+)$/)
|
||||
if (!m) return false
|
||||
const bag = m[1] === 'variables' ? variables.value : flags.value as any
|
||||
@@ -441,7 +541,7 @@ export default function useCommunicationsEngine() {
|
||||
}
|
||||
|
||||
return {
|
||||
// state/vars/flags
|
||||
// State
|
||||
currentState: computed(() => currentState.value),
|
||||
currentStateId: readonly(currentStateId),
|
||||
variables: readonly(variables),
|
||||
@@ -450,19 +550,24 @@ export default function useCommunicationsEngine() {
|
||||
activeFrequency,
|
||||
communicationLog: readonly(communicationLog),
|
||||
|
||||
// lifecycle
|
||||
// pm_alt.vue Integration
|
||||
flightContext: readonly(flightContext),
|
||||
currentStep,
|
||||
|
||||
// Lifecycle
|
||||
initializeFlight,
|
||||
|
||||
// Pilot in → baut Kontext für LLM
|
||||
// Communication
|
||||
processPilotTransmission,
|
||||
processUserTransmission,
|
||||
buildLLMContext,
|
||||
applyLLMDecision,
|
||||
|
||||
// Flow-Steuerung
|
||||
// Flow Control
|
||||
moveTo,
|
||||
resumePriorFlow,
|
||||
|
||||
// utils
|
||||
// Utilities
|
||||
normalizeATCText
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user