mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 19:36:04 +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:
@@ -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'}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user