diff --git a/app/components/editor/DecisionNodeCanvas.vue b/app/components/editor/DecisionNodeCanvas.vue deleted file mode 100644 index 14c08b6..0000000 --- a/app/components/editor/DecisionNodeCanvas.vue +++ /dev/null @@ -1,1027 +0,0 @@ - - - - - diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue deleted file mode 100644 index 0888412..0000000 --- a/app/pages/editor/index.vue +++ /dev/null @@ -1,3029 +0,0 @@ - - - - - diff --git a/app/pages/pm.vue b/app/pages/pm.vue deleted file mode 100644 index 4730e0f..0000000 --- a/app/pages/pm.vue +++ /dev/null @@ -1,2689 +0,0 @@ - - - - - diff --git a/scripts/import-decision-tree.ts b/scripts/import-decision-tree.ts deleted file mode 100644 index 56af60d..0000000 --- a/scripts/import-decision-tree.ts +++ /dev/null @@ -1,42 +0,0 @@ -import 'dotenv/config' -import mongoose from 'mongoose' -import { importATCDecisionTree, type ImportDecisionTreeOptions } from '../server/services/decisionImportService' - -function parseArgs(): ImportDecisionTreeOptions { - const options: ImportDecisionTreeOptions = {} - const args = process.argv.slice(2) - - for (const arg of args) { - if (arg.startsWith('--slug=')) { - options.slug = arg.slice('--slug='.length) - } else if (arg.startsWith('--name=')) { - options.name = arg.slice('--name='.length) - } else if (arg.startsWith('--description=')) { - options.description = arg.slice('--description='.length) - } - } - - return options -} - -async function main() { - const mongoUri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/opensquawk' - console.log(`Connecting to MongoDB at ${mongoUri}`) - await mongoose.connect(mongoUri) - - try { - const options = parseArgs() - const { flow, importedStates } = await importATCDecisionTree(options) - - console.log(`Imported decision flow "${flow.name}" (${flow.slug}) with ${importedStates} states.`) - console.log('Start state:', flow.startState) - console.log('Updated at:', flow.updatedAt) - } finally { - await mongoose.disconnect() - } -} - -main().catch((error) => { - console.error('Decision tree import failed:', error) - process.exit(1) -}) diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 7674853..1a1dc8c 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -1,12 +1,11 @@ -// server/api/atc/ptt.post.ts +// server/api/atc/ptt.post.ts — STT only (v2: decision routing happens client-side via /api/atc/route) import { createError, readBody } from "h3"; import { writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { getOpenAIClient, routeDecision } from "../../utils/openai"; -import type { LLMDecisionResult } from "~~/shared/types/llm"; +import { getOpenAIClient } from "../../utils/normalize"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; @@ -14,27 +13,15 @@ import { getUserFromEvent } from "../../utils/auth"; type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm' interface PTTRequest { - audio: string; // Base64 encoded audio - context: { - state_id: string; - state: any; - candidates: Array<{ id: string; state: any; flow?: string }>; - variables: Record; - flags: Record; - flow_slug?: string; - }; - moduleId: string; - lessonId: string; + audio: string; format?: AudioFormat; - autoDecide?: boolean; + sessionId?: string; + phase?: string; } interface PTTResponse { success: boolean; transcription: string; - decision?: LLMDecisionResult['decision']; - trace?: LLMDecisionResult['trace']; - active_nodes?: LLMDecisionResult['active_nodes']; } async function sh(cmd: string, args: string[]) { @@ -46,14 +33,12 @@ async function sh(cmd: string, args: string[]) { } const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; -const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono +const MAX_AUDIO_BYTES = 2 * 1024 * 1024; const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm']; const AUDIO_FORMAT_SET = new Set(ALLOWED_AUDIO_FORMATS); function resolveAudioFormat(format?: string | null): AudioFormat { - if (!format) { - return 'wav'; - } + if (!format) return 'wav'; const normalized = format.trim().toLowerCase() as AudioFormat; return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav'; } @@ -76,38 +61,21 @@ function decodeAudioPayload(encoded: string): Buffer { return buffer; } -// Convert audio to WAV for better Whisper compatibility async function convertToWav(inputPath: string, outputPath: string) { await sh("ffmpeg", [ "-y", "-i", inputPath, - "-ar", "16000", // 16 kHz for Whisper - "-ac", "1", // Mono + "-ar", "16000", + "-ac", "1", "-f", "wav", outputPath ]); } -function safeClone(value: T): T | undefined { - if (value === undefined) { - return undefined; - } - - try { - return JSON.parse(JSON.stringify(value)); - } catch (err) { - console.warn("Failed to clone value for transmission metadata", err); - return undefined; - } -} - export default defineEventHandler(async (event) => { const body = await readBody(event); - if (!body.audio || !body.context || !body.moduleId || !body.lessonId) { - throw createError({ - statusCode: 400, - statusMessage: "audio, context, moduleId, and lessonId are required" - }); + if (!body.audio) { + throw createError({ statusCode: 400, statusMessage: "audio is required" }); } const id = randomUUID(); @@ -116,11 +84,9 @@ export default defineEventHandler(async (event) => { const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`); try { - // 1. Decode audio from base64 and save const audioBuffer = decodeAudioPayload(body.audio); await writeFile(tmpAudioInput, audioBuffer); - // 2. Convert to WAV if needed (only when FFmpeg is available) let audioFileForWhisper = tmpAudioInput; if (format !== 'wav') { try { @@ -131,7 +97,6 @@ export default defineEventHandler(async (event) => { } } - // 3. OpenAI Whisper for transcription const openai = getOpenAIClient(); const transcription = await openai.audio.transcriptions.create({ file: createReadStream(audioFileForWhisper), @@ -143,152 +108,38 @@ export default defineEventHandler(async (event) => { const transcribedText = transcription.text.trim(); if (!transcribedText) { - throw createError({ - statusCode: 400, - statusMessage: "No speech detected in audio" - }); + throw createError({ statusCode: 400, statusMessage: "No speech detected in audio" }); } - const shouldAutoDecide = body.autoDecide !== false; - - let decisionResult: LLMDecisionResult | null = null; - let decision: PTTResponse['decision']; - - if (shouldAutoDecide) { - // 4. Call the LLM decision directly with the transcribed text - const decisionInput = { - ...body.context, - pilot_utterance: transcribedText - }; - - decisionResult = await routeDecision(decisionInput); - decision = decisionResult.decision; - } - - // 5. Cleanup + // Cleanup await rm(tmpAudioInput).catch(() => {}); if (audioFileForWhisper !== tmpAudioInput) { await rm(tmpAudioWav).catch(() => {}); } + // Log transmission try { - const user = await getUserFromEvent(event) - - const llmCallCount = decisionResult?.trace?.calls?.length || 0; - const fallbackUsed = Boolean(decisionResult?.trace?.fallback?.used); - - let llmStrategy: 'manual' | 'openai' | 'heuristic' | 'fallback' = 'manual'; - if (shouldAutoDecide) { - if (llmCallCount > 0) { - llmStrategy = 'openai'; - } else if (fallbackUsed) { - llmStrategy = 'fallback'; - } else { - llmStrategy = 'heuristic'; - } - } - - const llmUsage = { - autoDecide: shouldAutoDecide, - openaiUsed: llmStrategy === 'openai', - callCount: llmCallCount, - fallbackUsed, - strategy: llmStrategy, - reason: - llmStrategy === 'manual' - ? 'Automatic decision disabled in request.' - : llmStrategy === 'openai' - ? `Decision derived from OpenAI with ${llmCallCount} call(s).` - : llmStrategy === 'fallback' - ? (decisionResult?.trace?.fallback?.reason || 'Fallback triggered after OpenAI failure.') - : 'Decision resolved locally without calling OpenAI.' - }; - - const contextState = safeClone(body.context.state); - if (contextState && typeof contextState === 'object' && contextState !== null) { - const stateRecord = contextState as Record; - if (!('id' in stateRecord)) { - stateRecord.id = body.context.state_id; - } - } - - const contextCandidates = Array.isArray(body.context.candidates) - ? body.context.candidates.map(candidate => { - const candidateState = safeClone(candidate.state); - if (candidateState && typeof candidateState === 'object' && candidateState !== null) { - const candidateRecord = candidateState as Record; - if (!('id' in candidateRecord)) { - candidateRecord.id = candidate.id; - } - } - - return { - id: candidate.id, - flow: candidate.flow || undefined, - state: candidateState - }; - }) - : undefined; - - const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state); - - const sessionId = typeof body.context?.flags?.session_id === 'string' - ? body.context.flags.session_id - : undefined; - + const user = await getUserFromEvent(event); await TransmissionLog.create({ user: user?._id, role: "pilot", channel: "ptt", direction: "incoming", text: transcribedText, - sessionId, - metadata: { - moduleId: body.moduleId, - lessonId: body.lessonId, - decision, - decisionTrace: decisionResult?.trace, - autoDecide: shouldAutoDecide, - llm: llmUsage, - context: { - stateId: body.context.state_id, - state: contextState, - candidates: contextCandidates, - selectedCandidate, - variables: safeClone(body.context.variables), - flags: safeClone(body.context.flags) - } - }, - }) + sessionId: body.sessionId, + metadata: { phase: body.phase }, + }); } catch (logError) { - console.warn("Transmission logging failed", logError) + console.warn("Transmission logging failed", logError); } - const result: PTTResponse = { - success: true, - transcription: transcribedText - }; - - if (decision) { - result.decision = decision; - } - if (decisionResult?.trace) { - result.trace = decisionResult.trace; - } - if (decisionResult?.active_nodes?.length) { - result.active_nodes = decisionResult.active_nodes; - } - - return result; + return { success: true, transcription: transcribedText } satisfies PTTResponse; } catch (error: any) { - // Cleanup on error await rm(tmpAudioInput).catch(() => {}); await rm(tmpAudioWav).catch(() => {}); - if (error.statusCode) { - throw error; - } + if (error.statusCode) throw error; throw createError({ statusCode: 500, diff --git a/server/api/bridge/data.post.ts b/server/api/bridge/data.post.ts index 8e32c49..e6f69b7 100644 --- a/server/api/bridge/data.post.ts +++ b/server/api/bridge/data.post.ts @@ -1,22 +1,62 @@ import { defineEventHandler, readBody, getHeader } from 'h3' import { resolveUserFromToken } from '../../utils/auth' +import { normalizeBridgeToken } from '../../utils/bridge' +import { BridgeToken } from '../../models/BridgeToken' import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry' +import type { UserDocument } from '../../models/User' /** - * Receives MSFS SimConnect telemetry data from an external bridge application. + * Receives MSFS SimConnect telemetry data from the OpenSquawk Bridge app. * - * The bridge should POST telemetry data with an Authorization header so we - * can route the data to the correct FlightLab WebSocket session. + * Supports THREE authentication methods (tried in order): + * 1. Authorization: Bearer — direct user JWT + * 2. X-Bridge-Token: — bridge token (mapped to user in DB) + * 3. body.token — bridge token in payload + * + * The bridge sends data in its own format (snake_case) which we normalize + * to FlightLabTelemetryState format (SIMCONNECT_STYLE) before broadcasting. * * ────────────────────────────────────────── - * EXAMPLE REQUEST (from SimBridge app): + * EXAMPLE REQUEST (current Bridge format): * ────────────────────────────────────────── * * POST /api/bridge/data - * Authorization: Bearer + * X-Bridge-Token: * Content-Type: application/json * * { + * "token": "", + * "status": "active", + * "ts": 1702345678, + * "latitude": 50.033, + * "longitude": 8.570, + * "altitude_ft_true": 364, + * "altitude_ft_indicated": 350, + * "ias_kt": 145.2, + * "tas_kt": 155.0, + * "groundspeed_kt": 142.8, + * "on_ground": true, + * "eng_on": true, + * "n1_pct": 87.3, + * "transponder_code": 4731, + * "vertical_speed_fpm": 0, + * "pitch_deg": 0.5, + * "n1_pct_2": 86.9, + * "gear_handle": true, + * "flaps_index": 2, + * "parking_brake": false, + * "autopilot_master": false + * } + * + * The fields vertical_speed_fpm, pitch_deg, n1_pct_2, gear_handle, + * flaps_index, parking_brake, autopilot_master are NEW and need to be + * added to the Bridge. See BRIDGE-FLIGHTLAB-UPGRADE.md for instructions. + * + * ────────────────────────────────────────── + * ALSO ACCEPTS direct FlightLab format: + * ────────────────────────────────────────── + * + * { * "AIRSPEED_INDICATED": 145.2, * "GROUND_VELOCITY": 142.8, * "VERTICAL_SPEED": 0, @@ -33,24 +73,123 @@ import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry' * * ────────────────────────────────────────── * RESPONSE: 204 No Content (success) - * 401 Unauthorized (missing/invalid token) + * 401 Unauthorized (missing/invalid auth) * ────────────────────────────────────────── */ export default defineEventHandler(async (event) => { - // Resolve user from Bearer token (optional — also works with ?userId query param) + const body = await readBody(event) + + // --- Resolve user ID from multiple auth methods --- + let userId: string | null = null + + // Method 1: JWT Bearer token const user = await resolveUserFromToken(event) - const userId = user?._id?.toString() - ?? new URL(event.node.req.url ?? '', 'http://localhost').searchParams.get('userId') + if (user?._id) { + userId = user._id.toString() + } + + // Method 2: X-Bridge-Token header + if (!userId) { + const headerToken = normalizeBridgeToken(getHeader(event, 'x-bridge-token')) + if (headerToken) { + userId = await resolveUserIdFromBridgeToken(headerToken) + } + } + + // Method 3: token field in body + if (!userId && body?.token) { + const bodyToken = normalizeBridgeToken(body.token) + if (bodyToken) { + userId = await resolveUserIdFromBridgeToken(bodyToken) + } + } + + // Method 4: query param fallback (for testing) + if (!userId) { + const url = new URL(event.node.req.url ?? '', 'http://localhost') + userId = url.searchParams.get('userId') + } if (!userId) { event.node.res.statusCode = 401 - return { error: 'Authorization required — send Bearer token or ?userId query param' } + return { error: 'Auth required — send Bearer token, X-Bridge-Token header, or token in body' } } - const body = await readBody(event) + // --- Normalize telemetry to FlightLab format --- + const telemetry = normalizeTelemetry(body) - // Store telemetry and broadcast to WebSocket subscribers - flightlabTelemetryStore.update(userId, body) + // Store + broadcast to WebSocket subscribers + flightlabTelemetryStore.update(userId, telemetry) + + // Update bridge token status if we have one + const bridgeToken = normalizeBridgeToken(getHeader(event, 'x-bridge-token') ?? body?.token) + if (bridgeToken) { + BridgeToken.updateOne( + { token: bridgeToken }, + { $set: { lastStatusAt: new Date(), flightActive: body?.status === 'active' } }, + ).catch(() => {}) // fire-and-forget + } event.node.res.statusCode = 204 }) + +// --- Helpers --- + +async function resolveUserIdFromBridgeToken(token: string): Promise { + const doc = await BridgeToken.findOne({ token }).populate('user') + if (!doc?.user) return null + const bridgeUser = doc.user as UserDocument + return bridgeUser._id?.toString() ?? null +} + +/** + * Normalize incoming telemetry to FlightLabTelemetryState format. + * Accepts both the Bridge's snake_case format and direct SimConnect-style keys. + */ +function normalizeTelemetry(body: any) { + // If body already has SimConnect-style keys, pass through + if (body?.AIRSPEED_INDICATED !== undefined) { + return { + AIRSPEED_INDICATED: num(body.AIRSPEED_INDICATED), + GROUND_VELOCITY: num(body.GROUND_VELOCITY), + VERTICAL_SPEED: num(body.VERTICAL_SPEED), + PLANE_ALTITUDE: num(body.PLANE_ALTITUDE), + PLANE_PITCH_DEGREES: num(body.PLANE_PITCH_DEGREES), + TURB_ENG_N1_1: num(body.TURB_ENG_N1_1), + TURB_ENG_N1_2: num(body.TURB_ENG_N1_2), + SIM_ON_GROUND: bool(body.SIM_ON_GROUND), + GEAR_HANDLE_POSITION: bool(body.GEAR_HANDLE_POSITION), + FLAPS_HANDLE_INDEX: num(body.FLAPS_HANDLE_INDEX), + BRAKE_PARKING_POSITION: bool(body.BRAKE_PARKING_POSITION), + AUTOPILOT_MASTER: bool(body.AUTOPILOT_MASTER), + } + } + + // Bridge format → FlightLab format + return { + AIRSPEED_INDICATED: num(body.ias_kt), + GROUND_VELOCITY: num(body.groundspeed_kt), + VERTICAL_SPEED: num(body.vertical_speed_fpm ?? 0), + PLANE_ALTITUDE: num(body.altitude_ft_true ?? body.altitude_ft_indicated), + PLANE_PITCH_DEGREES: num(body.pitch_deg ?? 0), + TURB_ENG_N1_1: num(body.n1_pct), + TURB_ENG_N1_2: num(body.n1_pct_2 ?? body.n1_pct), // fallback to engine 1 if engine 2 missing + SIM_ON_GROUND: bool(body.on_ground), + GEAR_HANDLE_POSITION: bool(body.gear_handle ?? true), // default: gear down + FLAPS_HANDLE_INDEX: num(body.flaps_index ?? 0), + BRAKE_PARKING_POSITION: bool(body.parking_brake ?? false), + AUTOPILOT_MASTER: bool(body.autopilot_master ?? false), + } +} + +function num(v: any): number { + const n = Number(v) + return Number.isFinite(n) ? n : 0 +} + +function bool(v: any): boolean { + if (typeof v === 'boolean') return v + if (typeof v === 'number') return v !== 0 + if (typeof v === 'string') return v === 'true' || v === '1' + return false +} diff --git a/server/api/decision-flows/[slug]/runtime.get.ts b/server/api/decision-flows/[slug]/runtime.get.ts deleted file mode 100644 index 5e035de..0000000 --- a/server/api/decision-flows/[slug]/runtime.get.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createError } from 'h3' -import { buildRuntimeDecisionTree } from '../../../services/decisionFlowService' - -export default defineEventHandler(async (event) => { - const slugParam = event.context.params?.slug - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - - const tree = await buildRuntimeDecisionTree(slugParam.trim()) - return tree -}) diff --git a/server/api/decision-flows/runtime.get.ts b/server/api/decision-flows/runtime.get.ts deleted file mode 100644 index 91480e5..0000000 --- a/server/api/decision-flows/runtime.get.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService' - -export default defineEventHandler(async () => { - const system = await buildRuntimeDecisionSystem() - return system -}) diff --git a/server/api/editor/flows.get.ts b/server/api/editor/flows.get.ts deleted file mode 100644 index 8372ea9..0000000 --- a/server/api/editor/flows.get.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { listDecisionFlows } from '../../services/decisionFlowService' -import { requireAdmin } from '../../utils/auth' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const flows = await listDecisionFlows() - return flows -}) diff --git a/server/api/editor/flows.post.ts b/server/api/editor/flows.post.ts deleted file mode 100644 index 7260eca..0000000 --- a/server/api/editor/flows.post.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readBody, createError } from 'h3' -import { requireAdmin } from '../../utils/auth' -import { DecisionFlow } from '../../models/DecisionFlow' -import { getFlowWithNodes } from '../../services/decisionFlowService' - -function sanitizeSlug(input: string) { - return input.toLowerCase().replace(/[^a-z0-9-_]/gi, '-') -} - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const body = await readBody>(event) - - const rawSlug = typeof body.slug === 'string' ? body.slug.trim() : '' - const slug = rawSlug ? sanitizeSlug(rawSlug) : '' - if (!slug) { - throw createError({ statusCode: 400, statusMessage: 'Slug is required' }) - } - - const rawName = typeof body.name === 'string' ? body.name.trim() : '' - if (!rawName) { - throw createError({ statusCode: 400, statusMessage: 'Name is required' }) - } - - const existing = await DecisionFlow.findOne({ slug }) - if (existing) { - throw createError({ statusCode: 409, statusMessage: 'A decision flow with this slug already exists' }) - } - - const startState = (typeof body.startState === 'string' ? body.startState.trim() : '') || 'START' - const description = typeof body.description === 'string' ? body.description.trim() : undefined - const schemaVersion = (typeof body.schemaVersion === 'string' ? body.schemaVersion.trim() : '') || '1.0' - - const roles = Array.isArray(body.roles) - ? body.roles - .map((role: any) => (typeof role === 'string' ? role.trim() : '')) - .filter((role: string) => role.length) - : ['pilot', 'atc', 'system'] - - const phases = Array.isArray(body.phases) - ? body.phases - .map((phase: any) => (typeof phase === 'string' ? phase.trim() : '')) - .filter((phase: string) => phase.length) - : [] - - const entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel' - const isMain = body.isMain === true - - const endStates = Array.isArray(body.endStates) - ? body.endStates - .map((state: any) => (typeof state === 'string' ? state.trim() : '')) - .filter((state: string) => state.length) - : [startState] - - const flow = new DecisionFlow({ - slug, - name: rawName, - description, - schemaVersion, - startState, - endStates, - variables: body.variables && typeof body.variables === 'object' ? body.variables : {}, - flags: body.flags && typeof body.flags === 'object' ? body.flags : {}, - policies: body.policies && typeof body.policies === 'object' ? body.policies : {}, - hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {}, - roles, - phases, - entryMode, - isMain, - }) - - await flow.save() - - if (isMain) { - await DecisionFlow.updateMany( - { _id: { $ne: flow._id } }, - { $set: { isMain: false } } - ) - } - - const { flow: serialized } = await getFlowWithNodes(slug) - return serialized -}) diff --git a/server/api/editor/flows/[slug]/index.get.ts b/server/api/editor/flows/[slug]/index.get.ts deleted file mode 100644 index 475a4b3..0000000 --- a/server/api/editor/flows/[slug]/index.get.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createError } from 'h3' -import { requireAdmin } from '../../../../utils/auth' -import { getFlowWithNodes } from '../../../../services/decisionFlowService' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slug = event.context.params?.slug - if (typeof slug !== 'string' || !slug.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - - const data = await getFlowWithNodes(slug.trim()) - return data -}) diff --git a/server/api/editor/flows/[slug]/index.put.ts b/server/api/editor/flows/[slug]/index.put.ts deleted file mode 100644 index d202e62..0000000 --- a/server/api/editor/flows/[slug]/index.put.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createError, readBody } from 'h3' -import { requireAdmin } from '../../../../utils/auth' -import { DecisionFlow } from '../../../../models/DecisionFlow' -import { getFlowWithNodes } from '../../../../services/decisionFlowService' - -function sanitizeStringArray(values: any): string[] | undefined { - if (!Array.isArray(values)) return undefined - const mapped = values - .map((value: any) => (typeof value === 'string' ? value.trim() : '')) - .filter((value: string) => value.length) - if (!mapped.length) return undefined - return Array.from(new Set(mapped)) -} - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - - const slug = slugParam.trim() - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const body = await readBody>(event) - - if (typeof body.name === 'string' && body.name.trim()) { - flow.name = body.name.trim() - } - - if (typeof body.description === 'string') { - flow.description = body.description.trim() || undefined - } - - if (typeof body.schemaVersion === 'string') { - const version = body.schemaVersion.trim() - flow.schemaVersion = version || flow.schemaVersion - } - - if (typeof body.startState === 'string' && body.startState.trim()) { - flow.startState = body.startState.trim() - } - - const endStates = sanitizeStringArray(body.endStates) - if (endStates) { - flow.endStates = endStates - } - - const roles = sanitizeStringArray(body.roles) - if (roles) { - flow.roles = roles - } - - const phases = sanitizeStringArray(body.phases) - if (phases) { - flow.phases = phases - } - - if (typeof body.entryMode === 'string') { - flow.entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel' - } - - if (typeof body.isMain === 'boolean') { - flow.isMain = body.isMain - } - - if (body.variables && typeof body.variables === 'object') { - flow.variables = body.variables - flow.markModified('variables') - } - - if (body.flags && typeof body.flags === 'object') { - flow.flags = body.flags - flow.markModified('flags') - } - - if (body.policies && typeof body.policies === 'object') { - flow.policies = body.policies - flow.markModified('policies') - } - - if (body.hooks && typeof body.hooks === 'object') { - flow.hooks = body.hooks - flow.markModified('hooks') - } - - if (body.layout && typeof body.layout === 'object') { - const layout = flow.layout || { zoom: 1, pan: { x: 0, y: 0 }, groups: [] } - const zoomValue = body.layout.zoom - const zoom = typeof zoomValue === 'number' ? zoomValue : Number(zoomValue) - if (Number.isFinite(zoom)) { - layout.zoom = Math.min(Math.max(zoom, 0.25), 3) - } - if (body.layout.pan && typeof body.layout.pan === 'object') { - const panX = body.layout.pan.x - const panY = body.layout.pan.y - const parsedX = typeof panX === 'number' ? panX : Number(panX) - const parsedY = typeof panY === 'number' ? panY : Number(panY) - if (Number.isFinite(parsedX)) layout.pan = layout.pan || { x: 0, y: 0 } - if (Number.isFinite(parsedX)) layout.pan.x = parsedX - if (Number.isFinite(parsedY)) layout.pan = layout.pan || { x: 0, y: 0 } - if (Number.isFinite(parsedY)) layout.pan.y = parsedY - } - if (Array.isArray(body.layout.groups)) { - layout.groups = body.layout.groups - .map((group: any) => { - if (!group || typeof group !== 'object') return null - const id = typeof group.id === 'string' ? group.id.trim() : '' - const label = typeof group.label === 'string' ? group.label.trim() : '' - if (!id || !label) return null - if (!group.bounds || typeof group.bounds !== 'object') return null - const bounds = { - x: Number(group.bounds.x) || 0, - y: Number(group.bounds.y) || 0, - width: Number(group.bounds.width) || 0, - height: Number(group.bounds.height) || 0, - } - return { - id, - label, - color: typeof group.color === 'string' ? group.color.trim() || undefined : undefined, - bounds, - } - }) - .filter((group): group is NonNullable => Boolean(group)) - } - flow.layout = layout - flow.markModified('layout') - } - - if (body.metadata && typeof body.metadata === 'object') { - const metadata = flow.metadata || {} - if (typeof body.metadata.notes === 'string') { - metadata.notes = body.metadata.notes.trim() || undefined - } - if (Array.isArray(body.metadata.tags)) { - metadata.tags = body.metadata.tags - .map((tag: any) => (typeof tag === 'string' ? tag.trim() : '')) - .filter((tag: string) => tag.length) - } - if (typeof body.metadata.ownerId === 'string') { - metadata.ownerId = body.metadata.ownerId.trim() || undefined - } - if (typeof body.metadata.lastEditedBy === 'string') { - metadata.lastEditedBy = body.metadata.lastEditedBy.trim() || undefined - } - flow.metadata = metadata - flow.markModified('metadata') - } - - await flow.save() - - if (flow.isMain) { - await DecisionFlow.updateMany( - { _id: { $ne: flow._id } }, - { $set: { isMain: false } } - ) - } - - const data = await getFlowWithNodes(slug) - return data -}) diff --git a/server/api/editor/flows/[slug]/nodes.post.ts b/server/api/editor/flows/[slug]/nodes.post.ts deleted file mode 100644 index 7615f8b..0000000 --- a/server/api/editor/flows/[slug]/nodes.post.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createError, readBody } from 'h3' -import { requireAdmin } from '../../../../utils/auth' -import { DecisionFlow } from '../../../../models/DecisionFlow' -import { DecisionNode } from '../../../../models/DecisionNode' -import { - sanitizeLayout, - sanitizeLLMTemplate, - sanitizeMetadata, - sanitizeNodeCondition, - sanitizeNodeTrigger, - sanitizeTransition, -} from '../../../../utils/decisionSanitizer' -import { serializeNodeDocument } from '../../../../services/decisionFlowService' -import type { - DecisionNodeCondition, - DecisionNodeTrigger, - DecisionNodeTransition, -} from '~~/shared/types/decision' - -const ROLE_SET = new Set(['pilot', 'atc', 'system']) - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - - const slug = slugParam.trim() - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const body = await readBody>(event) - const rawStateId = typeof body.stateId === 'string' ? body.stateId.trim() : '' - if (!rawStateId) { - throw createError({ statusCode: 400, statusMessage: 'stateId is required' }) - } - - const stateId = rawStateId.toUpperCase() - const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId }) - if (existingNode) { - throw createError({ statusCode: 409, statusMessage: 'State already exists in this flow' }) - } - - const role = typeof body.role === 'string' ? body.role.trim().toLowerCase() : '' - if (!ROLE_SET.has(role)) { - throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' }) - } - - const phase = typeof body.phase === 'string' ? body.phase.trim() : '' - if (!phase) { - throw createError({ statusCode: 400, statusMessage: 'phase is required' }) - } - - let transitions: DecisionNodeTransition[] = [] - try { - transitions = Array.isArray(body.transitions) - ? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index)) - : [] - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Transition ungültig', - data: { formError: error?.message || 'Transition ungültig', field: 'transitions' }, - }) - } - - let triggers: DecisionNodeTrigger[] = [] - try { - triggers = Array.isArray(body.triggers) - ? body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index)) - : [] - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Trigger ungültig', - data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' }, - }) - } - - let conditions: DecisionNodeCondition[] = [] - try { - conditions = Array.isArray(body.conditions) - ? body.conditions.map((condition: any, index: number) => sanitizeNodeCondition(condition, index)) - : [] - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Bedingung ungültig', - data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' }, - }) - } - - const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 } - const metadata = sanitizeMetadata(body.metadata) - const llmTemplate = sanitizeLLMTemplate(body.llmTemplate) - - const readbackRequired = Array.isArray(body.readbackRequired) - ? body.readbackRequired - .map((entry: any) => (typeof entry === 'string' ? entry.trim() : '')) - .filter((entry: string) => entry.length) - : [] - - const node = new DecisionNode({ - flow: flow._id, - stateId, - title: typeof body.title === 'string' ? body.title.trim() || undefined : undefined, - summary: typeof body.summary === 'string' ? body.summary.trim() || undefined : undefined, - role, - phase, - sayTemplate: typeof body.sayTemplate === 'string' ? body.sayTemplate.trim() || undefined : undefined, - utteranceTemplate: - typeof body.utteranceTemplate === 'string' ? body.utteranceTemplate.trim() || undefined : undefined, - elseSayTemplate: - typeof body.elseSayTemplate === 'string' ? body.elseSayTemplate.trim() || undefined : undefined, - readbackRequired, - autoBehavior: typeof body.autoBehavior === 'string' ? body.autoBehavior.trim() || undefined : undefined, - actions: Array.isArray(body.actions) ? body.actions : [], - handoff: - body.handoff && typeof body.handoff === 'object' && typeof body.handoff.to === 'string' - ? { - to: body.handoff.to.trim(), - freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined, - note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined, - } - : undefined, - guard: typeof body.guard === 'string' ? body.guard.trim() || undefined : undefined, - trigger: typeof body.trigger === 'string' ? body.trigger.trim() || undefined : undefined, - frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined, - frequencyName: - typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined, - triggers, - conditions, - transitions, - layout, - metadata, - llmTemplate, - }) - - await node.save() - - return serializeNodeDocument(node) -}) diff --git a/server/api/editor/flows/[slug]/nodes/[stateId]/index.delete.ts b/server/api/editor/flows/[slug]/nodes/[stateId]/index.delete.ts deleted file mode 100644 index b1fd66d..0000000 --- a/server/api/editor/flows/[slug]/nodes/[stateId]/index.delete.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createError } from 'h3' -import { requireAdmin } from '../../../../../../utils/auth' -import { DecisionFlow } from '../../../../../../models/DecisionFlow' -import { DecisionNode } from '../../../../../../models/DecisionNode' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - const stateParam = event.context.params?.stateId - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - if (typeof stateParam !== 'string' || !stateParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' }) - } - - const slug = slugParam.trim() - const stateId = stateParam.trim().toUpperCase() - - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const result = await DecisionNode.deleteOne({ flow: flow._id, stateId }) - if (!result.deletedCount) { - throw createError({ statusCode: 404, statusMessage: 'State not found' }) - } - - return { success: true } -}) diff --git a/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts b/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts deleted file mode 100644 index 1f50b49..0000000 --- a/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { createError, readBody } from 'h3' -import { requireAdmin } from '../../../../../../utils/auth' -import { DecisionFlow } from '../../../../../../models/DecisionFlow' -import { DecisionNode } from '../../../../../../models/DecisionNode' -import { - sanitizeLayout, - sanitizeLLMTemplate, - sanitizeMetadata, - sanitizeNodeCondition, - sanitizeNodeTrigger, - sanitizeTransition, -} from '../../../../../../utils/decisionSanitizer' -import { serializeNodeDocument } from '../../../../../../services/decisionFlowService' -import type { - DecisionNodeCondition, - DecisionNodeTrigger, - DecisionNodeTransition, -} from '~~/shared/types/decision' - -const ROLE_SET = new Set(['pilot', 'atc', 'system']) - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - const stateParam = event.context.params?.stateId - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - if (typeof stateParam !== 'string' || !stateParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' }) - } - - const slug = slugParam.trim() - const stateId = stateParam.trim().toUpperCase() - - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const node = await DecisionNode.findOne({ flow: flow._id, stateId }) - if (!node) { - throw createError({ statusCode: 404, statusMessage: 'State not found' }) - } - - const body = await readBody>(event) - - if (typeof body.title === 'string') { - node.title = body.title.trim() || undefined - } - - if (typeof body.summary === 'string') { - node.summary = body.summary.trim() || undefined - } - - if (typeof body.role === 'string') { - const role = body.role.trim().toLowerCase() - if (!ROLE_SET.has(role)) { - throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' }) - } - node.role = role - } - - if (typeof body.phase === 'string' && body.phase.trim()) { - node.phase = body.phase.trim() - } - - if (typeof body.sayTemplate === 'string') { - node.sayTemplate = body.sayTemplate.trim() || undefined - } - - if (typeof body.utteranceTemplate === 'string') { - node.utteranceTemplate = body.utteranceTemplate.trim() || undefined - } - - if (typeof body.elseSayTemplate === 'string') { - node.elseSayTemplate = body.elseSayTemplate.trim() || undefined - } - - if (Array.isArray(body.readbackRequired)) { - node.readbackRequired = body.readbackRequired - .map((entry: any) => (typeof entry === 'string' ? entry.trim() : '')) - .filter((entry: string) => entry.length) - } - - if (typeof body.autoBehavior === 'string') { - node.autoBehavior = body.autoBehavior.trim() || undefined - } - - if (Array.isArray(body.actions)) { - node.actions = body.actions - } - - if (body.handoff && typeof body.handoff === 'object') { - if (typeof body.handoff.to === 'string' && body.handoff.to.trim()) { - node.handoff = { - to: body.handoff.to.trim(), - freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined, - note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined, - } - } else { - node.handoff = undefined - } - } - - if (typeof body.guard === 'string') { - node.guard = body.guard.trim() || undefined - } - - if (typeof body.trigger === 'string') { - node.trigger = body.trigger.trim() || undefined - } - - if (typeof body.frequency === 'string') { - node.frequency = body.frequency.trim() || undefined - } - - if (typeof body.frequencyName === 'string') { - node.frequencyName = body.frequencyName.trim() || undefined - } - - if (Array.isArray(body.transitions)) { - let sanitizedTransitions: DecisionNodeTransition[] - try { - sanitizedTransitions = body.transitions.map((transition: any, index: number) => - sanitizeTransition(transition, index) - ) - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Transition ungültig', - data: { formError: error?.message || 'Transition ungültig', field: 'transitions' }, - }) - } - node.transitions = sanitizedTransitions - } - - if (Array.isArray(body.triggers)) { - let sanitizedTriggers: DecisionNodeTrigger[] - try { - sanitizedTriggers = body.triggers.map((trigger: any, index: number) => sanitizeNodeTrigger(trigger, index)) - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Trigger ungültig', - data: { formError: error?.message || 'Trigger ungültig', field: 'triggers' }, - }) - } - node.triggers = sanitizedTriggers - } - - if (Array.isArray(body.conditions)) { - let sanitizedConditions: DecisionNodeCondition[] - try { - sanitizedConditions = body.conditions.map((condition: any, index: number) => - sanitizeNodeCondition(condition, index) - ) - } catch (error: any) { - throw createError({ - statusCode: 400, - statusMessage: error?.message || 'Bedingung ungültig', - data: { formError: error?.message || 'Bedingung ungültig', field: 'conditions' }, - }) - } - node.conditions = sanitizedConditions - } - - const layout = sanitizeLayout(body.layout) - if (layout) { - node.layout = layout - } - - const metadata = sanitizeMetadata(body.metadata) - if (metadata) { - node.metadata = metadata - } - - const llmTemplate = sanitizeLLMTemplate(body.llmTemplate) - if (llmTemplate) { - node.llmTemplate = llmTemplate - } - - await node.save() - - return serializeNodeDocument(node) -}) diff --git a/server/api/editor/flows/[slug]/nodes/[stateId]/layout.patch.ts b/server/api/editor/flows/[slug]/nodes/[stateId]/layout.patch.ts deleted file mode 100644 index bda5ff9..0000000 --- a/server/api/editor/flows/[slug]/nodes/[stateId]/layout.patch.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createError, readBody } from 'h3' -import { requireAdmin } from '../../../../../../utils/auth' -import { DecisionFlow } from '../../../../../../models/DecisionFlow' -import { DecisionNode } from '../../../../../../models/DecisionNode' -import { sanitizeLayout } from '../../../../../../utils/decisionSanitizer' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - const stateParam = event.context.params?.stateId - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - if (typeof stateParam !== 'string' || !stateParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' }) - } - - const slug = slugParam.trim() - const stateId = stateParam.trim().toUpperCase() - - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const node = await DecisionNode.findOne({ flow: flow._id, stateId }) - if (!node) { - throw createError({ statusCode: 404, statusMessage: 'State not found' }) - } - - const body = await readBody>(event) - const layoutUpdate = sanitizeLayout(body) - if (!layoutUpdate) { - throw createError({ statusCode: 400, statusMessage: 'Invalid layout payload' }) - } - - const existingLayout = node.layout || { x: 0, y: 0 } - node.layout = { ...existingLayout, ...layoutUpdate } - await node.save() - - return { success: true, layout: node.layout } -}) diff --git a/server/api/editor/flows/[slug]/nodes/[stateId]/rename.patch.ts b/server/api/editor/flows/[slug]/nodes/[stateId]/rename.patch.ts deleted file mode 100644 index 7e7eece..0000000 --- a/server/api/editor/flows/[slug]/nodes/[stateId]/rename.patch.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createError, readBody } from 'h3' -import { requireAdmin } from '../../../../../../utils/auth' -import { DecisionFlow } from '../../../../../../models/DecisionFlow' -import { DecisionNode } from '../../../../../../models/DecisionNode' -import { serializeNodeDocument } from '../../../../../../services/decisionFlowService' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const slugParam = event.context.params?.slug - const stateParam = event.context.params?.stateId - if (typeof slugParam !== 'string' || !slugParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' }) - } - if (typeof stateParam !== 'string' || !stateParam.trim()) { - throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' }) - } - - const slug = slugParam.trim() - const currentStateId = stateParam.trim().toUpperCase() - - const body = await readBody<{ stateId?: string }>(event) - const nextStateRaw = typeof body?.stateId === 'string' ? body.stateId.trim() : '' - if (!nextStateRaw) { - throw createError({ statusCode: 400, statusMessage: 'Neuer Identifier ist erforderlich' }) - } - - const nextStateId = nextStateRaw.toUpperCase() - const flow = await DecisionFlow.findOne({ slug }) - if (!flow) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId: nextStateId }) - if (existingNode) { - throw createError({ statusCode: 409, statusMessage: 'State identifier already exists' }) - } - - const node = await DecisionNode.findOne({ flow: flow._id, stateId: currentStateId }) - if (!node) { - throw createError({ statusCode: 404, statusMessage: 'State not found' }) - } - - if (nextStateId === currentStateId) { - const references = await DecisionNode.find({ - flow: flow._id, - 'transitions.target': currentStateId, - }) - return { - node: serializeNodeDocument(node), - references: references.map((ref) => serializeNodeDocument(ref)), - flow: { - startState: flow.startState, - endStates: flow.endStates ?? [], - }, - } - } - - const referencingNodes = await DecisionNode.find({ - flow: flow._id, - 'transitions.target': currentStateId, - }).select('_id') - - const referencingIds = referencingNodes.map((entry) => entry._id) - - node.stateId = nextStateId - await node.save() - - if (referencingIds.length) { - await DecisionNode.updateMany( - { flow: flow._id, _id: { $in: referencingIds } }, - { $set: { 'transitions.$[transition].target': nextStateId } }, - { arrayFilters: [{ 'transition.target': currentStateId }] } - ) - } - - if (flow.startState === currentStateId) { - flow.startState = nextStateId - } - if (Array.isArray(flow.endStates) && flow.endStates.length) { - flow.endStates = flow.endStates.map((entry: string) => (entry === currentStateId ? nextStateId : entry)) - } - await flow.save() - - const updatedReferences = referencingIds.length - ? await DecisionNode.find({ _id: { $in: referencingIds } }) - : [] - - return { - node: serializeNodeDocument(node), - references: updatedReferences.map((ref) => serializeNodeDocument(ref)), - flow: { - startState: flow.startState, - endStates: flow.endStates ?? [], - }, - } -}) diff --git a/server/api/editor/flows/import.post.ts b/server/api/editor/flows/import.post.ts deleted file mode 100644 index eabe9f9..0000000 --- a/server/api/editor/flows/import.post.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readBody } from 'h3' -import { requireAdmin } from '../../../utils/auth' -import { importATCDecisionTree } from '../../../services/decisionImportService' - -export default defineEventHandler(async (event) => { - await requireAdmin(event) - const body = await readBody | undefined>(event) - - const { flow, importedStates } = await importATCDecisionTree({ - slug: typeof body?.slug === 'string' ? body.slug : undefined, - name: typeof body?.name === 'string' ? body.name : undefined, - description: typeof body?.description === 'string' ? body.description : undefined, - }) - - return { - flow, - importedStates, - } -}) diff --git a/server/api/llm/decide.post.ts b/server/api/llm/decide.post.ts deleted file mode 100644 index 93e8dce..0000000 --- a/server/api/llm/decide.post.ts +++ /dev/null @@ -1,35 +0,0 @@ -// server/api/llm/decide.post.ts -import { readBody, createError } from 'h3' -import type { LLMDecisionInput } from '~~/shared/types/llm' -import { routeDecision } from '../../utils/openai' - -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 result = await routeDecision(body) - const { decision, trace } = result - - 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}"`) - } - - if (trace?.calls?.length) { - console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)') - } - - return result - } catch (err: any) { - console.error('Router failed:', err) - throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' }) - } -}) diff --git a/server/api/service/tools/latency.get.ts b/server/api/service/tools/latency.get.ts index 0e7dbaa..1eaa483 100644 --- a/server/api/service/tools/latency.get.ts +++ b/server/api/service/tools/latency.get.ts @@ -1,6 +1,6 @@ // server/api/llm/latency.get.ts import { createError } from 'h3' -import { getOpenAIClient } from '../../../utils/openai' +import { getOpenAIClient } from '../../../utils/normalize' import { getServerRuntimeConfig } from '../../../utils/runtimeConfig' const SYSTEM_PROMPT = diff --git a/server/models/DecisionFlow.ts b/server/models/DecisionFlow.ts deleted file mode 100644 index a97cff6..0000000 --- a/server/models/DecisionFlow.ts +++ /dev/null @@ -1,107 +0,0 @@ -import mongoose from 'mongoose' -import type { - DecisionFlowLayout, - DecisionFlowMetadata, -} from '~~/shared/types/decision' - -export interface DecisionFlowDocument extends mongoose.Document { - slug: string - name: string - description?: string - schemaVersion?: string - startState: string - endStates: string[] - variables: Record - flags: Record - policies: Record - hooks: Record - roles: string[] - phases: string[] - layout?: DecisionFlowLayout - metadata?: DecisionFlowMetadata - entryMode?: 'parallel' | 'linear' - isMain?: boolean - createdAt: Date - updatedAt: Date -} - -const decisionFlowSchema = new mongoose.Schema( - { - slug: { type: String, required: true, unique: true, index: true }, - name: { type: String, required: true }, - description: { type: String }, - schemaVersion: { type: String }, - startState: { type: String, required: true }, - endStates: { type: [String], default: () => [] }, - variables: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, - flags: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, - policies: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, - hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, - roles: { type: [String], default: () => [] }, - phases: { type: [String], default: () => [] }, - entryMode: { type: String, enum: ['parallel', 'linear'], default: 'parallel' }, - isMain: { type: Boolean, default: false }, - layout: { - type: new mongoose.Schema( - { - zoom: { type: Number, default: 1 }, - pan: { - type: new mongoose.Schema({ x: { type: Number, default: 0 }, y: { type: Number, default: 0 } }, { _id: false }), - default: () => ({ x: 0, y: 0 }), - }, - groups: { - type: [ - new mongoose.Schema( - { - id: { type: String, required: true }, - label: { type: String, required: true }, - color: { type: String }, - bounds: { - type: new mongoose.Schema( - { - x: { type: Number, required: true }, - y: { type: Number, required: true }, - width: { type: Number, required: true }, - height: { type: Number, required: true }, - }, - { _id: false } - ), - required: true, - }, - }, - { _id: false } - ), - ], - default: () => [], - }, - }, - { _id: false } - ), - default: () => ({ zoom: 1, pan: { x: 0, y: 0 }, groups: [] }), - }, - metadata: { - type: new mongoose.Schema( - { - notes: { type: String }, - tags: { type: [String], default: () => [] }, - ownerId: { type: String }, - lastEditedBy: { type: String }, - }, - { _id: false } - ), - default: undefined, - }, - }, - { timestamps: true } -) - -decisionFlowSchema.index({ updatedAt: -1 }) - -decisionFlowSchema.set('toJSON', { - virtuals: true, - getters: true, -}) - -export const DecisionFlow = - (mongoose.models.DecisionFlow as mongoose.Model) || - mongoose.model('DecisionFlow', decisionFlowSchema) diff --git a/server/models/DecisionNode.ts b/server/models/DecisionNode.ts deleted file mode 100644 index dc5f974..0000000 --- a/server/models/DecisionNode.ts +++ /dev/null @@ -1,213 +0,0 @@ -import mongoose from 'mongoose' -import type { - DecisionNodeAutoTrigger, - DecisionNodeCondition, - DecisionNodeLayout, - DecisionNodeLLMPlaceholder, - DecisionNodeLLMTemplate, - DecisionNodeMetadata, - DecisionNodeModel, - DecisionNodeTrigger, - DecisionNodeTransition, -} from '~~/shared/types/decision' - -export interface DecisionNodeDocument - extends mongoose.Document, - Omit { - flow: mongoose.Types.ObjectId - stateId: string - transitions: DecisionNodeTransition[] - createdAt: Date - updatedAt: Date -} - -const llmPlaceholderSchema = new mongoose.Schema( - { - key: { type: String, required: true }, - label: { type: String, required: true }, - description: { type: String }, - required: { type: Boolean, default: false }, - example: { type: String }, - defaultValue: { type: String }, - type: { type: String, default: 'text' }, - }, - { _id: false } -) - -const llmTemplateSchema = new mongoose.Schema( - { - summary: { type: String }, - prompt: { type: String }, - responseSchema: { type: String }, - autoProceed: { type: Boolean, default: false }, - temperature: { type: Number }, - topP: { type: Number }, - maxOutputTokens: { type: Number }, - placeholders: { type: [llmPlaceholderSchema], default: () => [] }, - guardrails: { type: [String], default: () => [] }, - notes: { type: String }, - }, - { _id: false } -) - -const metadataSchema = new mongoose.Schema( - { - tags: { type: [String], default: () => [] }, - notes: { type: String }, - pinned: { type: Boolean, default: false }, - complexity: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' }, - }, - { _id: false } -) - -const layoutSchema = new mongoose.Schema( - { - x: { type: Number, required: true }, - y: { type: Number, required: true }, - width: { type: Number }, - height: { type: Number }, - color: { type: String }, - icon: { type: String }, - locked: { type: Boolean, default: false }, - }, - { _id: false } -) - -const autoTriggerSchema = new mongoose.Schema( - { - id: { type: String, required: true }, - type: { type: String, enum: ['telemetry', 'variable', 'expression'], required: true }, - parameter: { type: String }, - variable: { type: String }, - operator: { type: String }, - value: { type: mongoose.Schema.Types.Mixed }, - unit: { type: String }, - expression: { type: String }, - description: { type: String }, - once: { type: Boolean, default: true }, - delayMs: { type: Number }, - }, - { _id: false } -) - -const triggerSchema = new mongoose.Schema( - { - id: { type: String, required: true }, - type: { type: String, enum: ['auto_time', 'auto_variable', 'regex', 'none'], required: true }, - order: { type: Number, default: 0 }, - delaySeconds: { type: Number }, - variable: { type: String }, - operator: { type: String }, - value: { type: mongoose.Schema.Types.Mixed }, - pattern: { type: String }, - patternFlags: { type: String }, - description: { type: String }, - }, - { _id: false } -) - -const conditionSchema = new mongoose.Schema( - { - id: { type: String, required: true }, - type: { type: String, enum: ['variable_value', 'regex', 'regex_not'], required: true }, - order: { type: Number, default: 0 }, - variable: { type: String }, - operator: { type: String }, - value: { type: mongoose.Schema.Types.Mixed }, - pattern: { type: String }, - patternFlags: { type: String }, - description: { type: String }, - }, - { _id: false } -) - -const transitionSchema = new mongoose.Schema( - { - key: { type: String, required: true }, - type: { - type: String, - enum: ['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'], - default: 'next', - }, - target: { type: String, required: true }, - label: { type: String }, - description: { type: String }, - condition: { type: String }, - guard: { type: String }, - order: { type: Number, default: 0 }, - timer: { - type: new mongoose.Schema( - { - afterSeconds: { type: Number, required: true }, - allowManualProceed: { type: Boolean, default: true }, - }, - { _id: false } - ), - default: undefined, - }, - autoTrigger: { type: autoTriggerSchema, default: undefined }, - metadata: { - type: new mongoose.Schema( - { - color: { type: String }, - icon: { type: String }, - notes: { type: String }, - previewTemplate: { type: String }, - }, - { _id: false } - ), - default: undefined, - }, - }, - { _id: false } -) - -const decisionNodeSchema = new mongoose.Schema( - { - flow: { type: mongoose.Schema.Types.ObjectId, ref: 'DecisionFlow', required: true, index: true }, - stateId: { type: String, required: true }, - title: { type: String }, - summary: { type: String }, - role: { type: String, enum: ['pilot', 'atc', 'system'], required: true }, - phase: { type: String, required: true }, - sayTemplate: { type: String }, - utteranceTemplate: { type: String }, - elseSayTemplate: { type: String }, - readbackRequired: { type: [String], default: () => [] }, - autoBehavior: { type: String }, - actions: { type: [mongoose.Schema.Types.Mixed], default: () => [] }, - handoff: { - type: new mongoose.Schema( - { - to: { type: String, required: true }, - freq: { type: String }, - note: { type: String }, - }, - { _id: false } - ), - default: undefined, - }, - guard: { type: String }, - trigger: { type: String }, - frequency: { type: String }, - frequencyName: { type: String }, - triggers: { type: [triggerSchema], default: undefined }, - conditions: { type: [conditionSchema], default: undefined }, - transitions: { type: [transitionSchema], default: () => [] }, - layout: { type: layoutSchema, default: undefined }, - metadata: { type: metadataSchema, default: undefined }, - llmTemplate: { type: llmTemplateSchema, default: undefined }, - }, - { timestamps: true } -) - -decisionNodeSchema.index({ flow: 1, stateId: 1 }, { unique: true }) - -decisionNodeSchema.set('toJSON', { - virtuals: true, - getters: true, -}) - -export const DecisionNode = - (mongoose.models.DecisionNode as mongoose.Model) || - mongoose.model('DecisionNode', decisionNodeSchema) diff --git a/server/services/decisionFlowService.ts b/server/services/decisionFlowService.ts deleted file mode 100644 index 530afb1..0000000 --- a/server/services/decisionFlowService.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { createError } from 'h3' -import { DecisionFlow, type DecisionFlowDocument } from '../models/DecisionFlow' -import { DecisionNode, type DecisionNodeDocument } from '../models/DecisionNode' -import type { - DecisionFlowModel, - DecisionFlowSummary, - DecisionNodeModel, - DecisionNodeTransition, - RuntimeDecisionAutoTransition, - RuntimeDecisionState, - RuntimeDecisionTree, - RuntimeDecisionSystem, -} from '~~/shared/types/decision' - -export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel { - return { - id: String(doc._id), - slug: doc.slug, - name: doc.name, - description: doc.description || undefined, - schemaVersion: doc.schemaVersion || undefined, - startState: doc.startState, - endStates: Array.isArray(doc.endStates) ? doc.endStates : [], - variables: doc.variables || {}, - flags: doc.flags || {}, - policies: doc.policies || {}, - hooks: doc.hooks || {}, - roles: Array.isArray(doc.roles) ? doc.roles : [], - phases: Array.isArray(doc.phases) ? doc.phases : [], - layout: doc.layout || undefined, - metadata: doc.metadata || undefined, - createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(), - updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(), - nodeCount, - entryMode: doc.entryMode || 'parallel', - isMain: doc.isMain || false, - } -} - -export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeModel { - const obj = doc.toObject({ virtuals: false }) - return { - stateId: obj.stateId, - title: obj.title || undefined, - summary: obj.summary || undefined, - role: obj.role as any, - phase: obj.phase, - sayTemplate: obj.sayTemplate || undefined, - utteranceTemplate: obj.utteranceTemplate || undefined, - elseSayTemplate: obj.elseSayTemplate || undefined, - readbackRequired: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : [], - autoBehavior: obj.autoBehavior || undefined, - actions: Array.isArray(obj.actions) ? obj.actions : [], - handoff: obj.handoff || undefined, - guard: obj.guard || undefined, - trigger: obj.trigger || undefined, - frequency: obj.frequency || undefined, - frequencyName: obj.frequencyName || undefined, - triggers: Array.isArray(obj.triggers) ? obj.triggers : [], - conditions: Array.isArray(obj.conditions) ? obj.conditions : [], - transitions: Array.isArray(obj.transitions) ? obj.transitions : [], - layout: obj.layout || undefined, - metadata: obj.metadata || undefined, - llmTemplate: obj.llmTemplate || undefined, - createdAt: obj.createdAt?.toISOString?.(), - updatedAt: obj.updatedAt?.toISOString?.(), - } -} - -export async function listDecisionFlows(): Promise { - const flows = await DecisionFlow.find().sort({ updatedAt: -1 }).lean() - if (!flows.length) { - return [] - } - - const ids = flows.map((flow) => flow._id) - const counts = await DecisionNode.aggregate([ - { $match: { flow: { $in: ids } } }, - { $group: { _id: '$flow', count: { $sum: 1 } } }, - ]) - - const countMap = counts.reduce>((acc, entry) => { - acc[String(entry._id)] = entry.count - return acc - }, {}) - - return flows.map((flow) => ({ - id: String(flow._id), - slug: flow.slug, - name: flow.name, - description: flow.description || undefined, - startState: flow.startState, - nodeCount: countMap[String(flow._id)] || 0, - updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(), - createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(), - entryMode: flow.entryMode || 'parallel', - isMain: Boolean(flow.isMain), - })) -} - -export async function getFlowWithNodes(slug: string): Promise<{ flow: DecisionFlowModel; nodes: DecisionNodeModel[] }> { - const flowDoc = await DecisionFlow.findOne({ slug }) - if (!flowDoc) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - const nodes = await DecisionNode.find({ flow: flowDoc._id }).sort({ stateId: 1 }) - const flow = serializeFlowDocument(flowDoc, nodes.length) - const serializedNodes = nodes.map((node) => serializeNodeDocument(node)) - - return { flow, nodes: serializedNodes } -} - -function toRuntimeTransitions( - transitions: DecisionNodeTransition[], - types: Array, - includeAuto = false -) { - return transitions - .filter((transition) => types.includes(transition.type) || (includeAuto && transition.type === 'auto')) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((transition) => ({ - to: transition.target, - label: transition.label || undefined, - when: transition.condition || undefined, - guard: transition.guard || undefined, - })) -} - -function toRuntimeTimers(transitions: DecisionNodeTransition[]): RuntimeDecisionState['timer_next'] { - return transitions - .filter((transition) => transition.type === 'timer' && transition.timer) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((transition) => ({ - to: transition.target, - after_s: transition.timer?.afterSeconds ?? 0, - label: transition.label || undefined, - })) -} - -function toRuntimeAutoTransitions(transitions: DecisionNodeTransition[]): RuntimeDecisionAutoTransition[] { - return transitions - .filter((transition) => transition.autoTrigger) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((transition) => ({ - id: transition.key, - to: transition.target, - label: transition.label || undefined, - description: transition.description || undefined, - condition: transition.condition || undefined, - guard: transition.guard || undefined, - trigger: transition.autoTrigger || null, - metadata: transition.metadata || undefined, - })) -} - -function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState { - const obj = node.toObject({ virtuals: false }) - const transitions = Array.isArray(obj.transitions) ? obj.transitions : [] - - return { - role: obj.role as any, - phase: obj.phase, - name: obj.title || undefined, - summary: obj.summary || undefined, - say_tpl: obj.sayTemplate || undefined, - utterance_tpl: obj.utteranceTemplate || undefined, - else_say_tpl: obj.elseSayTemplate || undefined, - next: toRuntimeTransitions(transitions, ['next'], true), - ok_next: toRuntimeTransitions(transitions, ['ok']), - bad_next: toRuntimeTransitions(transitions, ['bad']), - timer_next: toRuntimeTimers(transitions), - auto: obj.autoBehavior || null, - readback_required: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : undefined, - actions: Array.isArray(obj.actions) ? obj.actions : undefined, - handoff: obj.handoff || undefined, - guard: obj.guard || undefined, - trigger: obj.trigger || undefined, - frequency: obj.frequency || undefined, - frequencyName: obj.frequencyName || undefined, - auto_transitions: toRuntimeAutoTransitions(transitions), - triggers: Array.isArray(obj.triggers) ? obj.triggers : undefined, - conditions: Array.isArray(obj.conditions) ? obj.conditions : undefined, - metadata: obj.metadata || undefined, - } -} - -async function buildRuntimeTreeForDoc( - flowDoc: DecisionFlowDocument, - nodeDocs?: DecisionNodeDocument[] -): Promise { - const nodes = nodeDocs ?? (await DecisionNode.find({ flow: flowDoc._id })) - const states = nodes.reduce>((acc, node) => { - acc[node.stateId] = serializeRuntimeState(node) - return acc - }, {}) - - return { - slug: flowDoc.slug, - schema_version: flowDoc.schemaVersion || '1.0', - name: flowDoc.name || flowDoc.slug, - description: flowDoc.description || undefined, - start_state: flowDoc.startState, - end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [], - variables: flowDoc.variables || {}, - flags: flowDoc.flags || {}, - policies: flowDoc.policies || {}, - hooks: flowDoc.hooks || {}, - roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [], - phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [], - states, - entry_mode: flowDoc.isMain ? 'main' : flowDoc.entryMode || 'parallel', - } -} - -export async function buildRuntimeDecisionTree(slug: string): Promise { - const flowDoc = await DecisionFlow.findOne({ slug }) - if (!flowDoc) { - throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' }) - } - - return buildRuntimeTreeForDoc(flowDoc) -} - -export async function buildRuntimeDecisionSystem(): Promise { - const flowDocs = await DecisionFlow.find().sort({ updatedAt: -1 }) - if (!flowDocs.length) { - throw createError({ statusCode: 404, statusMessage: 'No decision flows available' }) - } - - const flowIds = flowDocs.map((doc) => doc._id) - - const nodeDocs = await DecisionNode.find({ flow: { $in: flowIds } }) - const groupedNodes = nodeDocs.reduce>((acc, node) => { - const key = String(node.flow) - if (!acc[key]) { - acc[key] = [] - } - acc[key].push(node) - return acc - }, {}) - - const runtimeTrees: RuntimeDecisionTree[] = [] - for (const doc of flowDocs) { - const nodes = groupedNodes[String(doc._id)] || [] - runtimeTrees.push(await buildRuntimeTreeForDoc(doc, nodes)) - } - - const flows = runtimeTrees.reduce>((acc, tree) => { - acc[tree.slug] = tree - return acc - }, {}) - - const order = runtimeTrees.map((tree) => tree.slug) - const preferredMain = flowDocs.find((doc) => doc.isMain)?.slug - const fallbackMain = flowDocs.find((doc) => doc.slug === 'icao_atc_decision_tree')?.slug - const main = preferredMain || fallbackMain || order[0] - - return { - main, - order, - flows, - } -} diff --git a/server/services/decisionImportService.ts b/server/services/decisionImportService.ts deleted file mode 100644 index 55fe6fb..0000000 --- a/server/services/decisionImportService.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getFlowWithNodes } from './decisionFlowService' - -export interface ImportDecisionTreeOptions { - slug?: string - name?: string - description?: string -} - -export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) { - const slug = typeof options.slug === 'string' && options.slug.trim().length - ? options.slug.trim() - : 'icao_atc_decision_tree' - - const { flow, nodes } = await getFlowWithNodes(slug) - - return { - flow, - nodes, - importedStates: nodes.length, - } -} diff --git a/server/utils/decisionSanitizer.ts b/server/utils/decisionSanitizer.ts deleted file mode 100644 index 8949f90..0000000 --- a/server/utils/decisionSanitizer.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { - DecisionComparisonOperator, - DecisionNodeAutoTrigger, - DecisionNodeCondition, - DecisionNodeLayout, - DecisionNodeLLMPlaceholder, - DecisionNodeLLMTemplate, - DecisionNodeMetadata, - DecisionNodeTrigger, - DecisionNodeTransition, -} from '~~/shared/types/decision' - -const TRANSITION_TYPES = new Set(['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return']) -const AUTO_TRIGGER_TYPES = new Set(['telemetry', 'variable', 'expression']) -const NODE_TRIGGER_TYPES = new Set(['auto_time', 'auto_variable', 'regex', 'none']) -const NODE_CONDITION_TYPES = new Set(['variable_value', 'regex', 'regex_not']) -const COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!=']) -const TELEMETRY_PARAMETERS = new Set([ - 'altitude_ft', - 'speed_kts', - 'groundspeed_kts', - 'vertical_speed_fpm', - 'heading_deg', - 'distance_nm', -]) - -function asTrimmedString(input: any): string | undefined { - if (typeof input === 'string') { - const trimmed = input.trim() - return trimmed.length ? trimmed : undefined - } - return undefined -} - -function asNumber(input: any): number | undefined { - if (typeof input === 'number' && Number.isFinite(input)) { - return input - } - if (typeof input === 'string' && input.trim()) { - const parsed = Number(input) - if (Number.isFinite(parsed)) { - return parsed - } - } - return undefined -} - -function asBoolean(input: any, fallback: boolean): boolean { - if (typeof input === 'boolean') return input - if (typeof input === 'string') { - const normalized = input.trim().toLowerCase() - if (normalized === 'true') return true - if (normalized === 'false') return false - } - return fallback -} - -function asComparisonOperatorValue(input: any, fallback: DecisionComparisonOperator = '=='): DecisionComparisonOperator { - const operator = asTrimmedString(input) - if (operator && COMPARISON_OPERATORS.has(operator)) { - return operator as DecisionComparisonOperator - } - return fallback -} - -function asTelemetryParameter( - input: any, - fallback: NonNullable = 'altitude_ft' -): NonNullable { - const parameter = asTrimmedString(input) - if (parameter && TELEMETRY_PARAMETERS.has(parameter)) { - return parameter as NonNullable - } - return fallback -} - -function asTelemetryValue(input: any, fallback: number | string = 0): number | string { - const numeric = asNumber(input) - if (typeof numeric === 'number') return numeric - const stringValue = asTrimmedString(input) - if (stringValue !== undefined) return stringValue - return fallback -} - -function asVariableValue(input: any, fallback: number | string | boolean = ''): number | string | boolean { - const numeric = asNumber(input) - if (typeof numeric === 'number') return numeric - if (typeof input === 'boolean') return input - const stringValue = asTrimmedString(input) - if (stringValue !== undefined) return stringValue - return fallback -} - -export function sanitizeLayout(raw: any): DecisionNodeLayout | undefined { - if (!raw || typeof raw !== 'object') return undefined - const x = asNumber(raw.x) ?? 0 - const y = asNumber(raw.y) ?? 0 - const layout: DecisionNodeLayout = { x, y } - const width = asNumber(raw.width) - const height = asNumber(raw.height) - if (typeof width === 'number') layout.width = width - if (typeof height === 'number') layout.height = height - const color = asTrimmedString(raw.color) - if (color) layout.color = color - const icon = asTrimmedString(raw.icon) - if (icon) layout.icon = icon - if (typeof raw.locked === 'boolean') { - layout.locked = raw.locked - } - return layout -} - -export function sanitizeMetadata(raw: any): DecisionNodeMetadata | undefined { - if (!raw || typeof raw !== 'object') return undefined - const metadata: DecisionNodeMetadata = {} - if (Array.isArray(raw.tags)) { - metadata.tags = raw.tags - .map((tag: any) => asTrimmedString(tag)) - .filter((tag): tag is string => Boolean(tag)) - } - const notes = asTrimmedString(raw.notes) - if (notes) metadata.notes = notes - if (typeof raw.pinned === 'boolean') metadata.pinned = raw.pinned - const complexity = asTrimmedString(raw.complexity) - if (complexity && ['low', 'medium', 'high'].includes(complexity)) { - metadata.complexity = complexity as DecisionNodeMetadata['complexity'] - } - return Object.keys(metadata).length ? metadata : undefined -} - -function sanitizeLLMPlaceholder(raw: any): DecisionNodeLLMPlaceholder | null { - if (!raw || typeof raw !== 'object') return null - const key = asTrimmedString(raw.key) - const label = asTrimmedString(raw.label) - if (!key || !label) return null - const placeholder: DecisionNodeLLMPlaceholder = { key, label } - const description = asTrimmedString(raw.description) - if (description) placeholder.description = description - if (typeof raw.required === 'boolean') placeholder.required = raw.required - const example = asTrimmedString(raw.example) - if (example) placeholder.example = example - const defaultValue = asTrimmedString(raw.defaultValue) - if (defaultValue !== undefined) placeholder.defaultValue = defaultValue - const type = asTrimmedString(raw.type) - if (type && ['text', 'number', 'choice'].includes(type)) { - placeholder.type = type as DecisionNodeLLMPlaceholder['type'] - } - return placeholder -} - -export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefined { - if (!raw || typeof raw !== 'object') return undefined - const template: DecisionNodeLLMTemplate = {} - const summary = asTrimmedString(raw.summary) - if (summary) template.summary = summary - const prompt = asTrimmedString(raw.prompt) - if (prompt) template.prompt = prompt - const schema = asTrimmedString(raw.responseSchema) - if (schema) template.responseSchema = schema - if (raw.autoProceed !== undefined) template.autoProceed = asBoolean(raw.autoProceed, false) - const temperature = asNumber(raw.temperature) - if (typeof temperature === 'number') template.temperature = temperature - const topP = asNumber(raw.topP) - if (typeof topP === 'number') template.topP = topP - const maxOutputTokens = asNumber(raw.maxOutputTokens) - if (typeof maxOutputTokens === 'number') template.maxOutputTokens = maxOutputTokens - if (Array.isArray(raw.guardrails)) { - template.guardrails = raw.guardrails - .map((item: any) => asTrimmedString(item)) - .filter((item): item is string => Boolean(item)) - } - const notes = asTrimmedString(raw.notes) - if (notes) template.notes = notes - if (Array.isArray(raw.placeholders)) { - const placeholders = raw.placeholders - .map((item: any) => sanitizeLLMPlaceholder(item)) - .filter((item): item is DecisionNodeLLMPlaceholder => Boolean(item)) - template.placeholders = placeholders - } - return Object.keys(template).length ? template : undefined -} - -export function sanitizeAutoTrigger(raw: any): DecisionNodeAutoTrigger | undefined { - const payload = raw && typeof raw === 'object' ? raw : {} - const type = asTrimmedString(payload.type) - const normalizedType = - type && AUTO_TRIGGER_TYPES.has(type) ? (type as DecisionNodeAutoTrigger['type']) : 'expression' - - const trigger: DecisionNodeAutoTrigger = { - id: asTrimmedString(payload.id) || `auto_${randomUUID()}`, - type: normalizedType, - } - - if (normalizedType === 'expression') { - trigger.expression = asTrimmedString(payload.expression) ?? '' - } else if (normalizedType === 'telemetry') { - trigger.parameter = asTelemetryParameter(payload.parameter) - trigger.operator = asComparisonOperatorValue(payload.operator) - trigger.value = asTelemetryValue(payload.value, 0) - const unit = asTrimmedString(payload.unit) - if (unit) trigger.unit = unit - } else if (normalizedType === 'variable') { - trigger.variable = asTrimmedString(payload.variable) ?? '' - trigger.operator = asComparisonOperatorValue(payload.operator) - trigger.value = asVariableValue(payload.value, '') - } - - trigger.once = asBoolean(payload.once, true) - const delayMs = asNumber(payload.delayMs) - if (typeof delayMs === 'number') trigger.delayMs = delayMs - const description = asTrimmedString(payload.description) - if (description) trigger.description = description - - return trigger -} - -export function sanitizeNodeTrigger(raw: any, index = 0): DecisionNodeTrigger { - const payload = raw && typeof raw === 'object' ? raw : {} - const type = asTrimmedString(payload.type) - const normalizedType = - type && NODE_TRIGGER_TYPES.has(type) ? (type as DecisionNodeTrigger['type']) : 'none' - - const trigger: DecisionNodeTrigger = { - id: asTrimmedString(payload.id) || `trigger_${randomUUID()}`, - type: normalizedType, - order: typeof payload.order === 'number' ? payload.order : index, - } - - if (trigger.type === 'auto_time') { - trigger.delaySeconds = asNumber(payload.delaySeconds) ?? 0 - } else if (trigger.type === 'auto_variable') { - trigger.variable = asTrimmedString(payload.variable) ?? '' - trigger.operator = asComparisonOperatorValue(payload.operator) - trigger.value = asVariableValue(payload.value, '') - } else if (trigger.type === 'regex') { - trigger.pattern = asTrimmedString(payload.pattern) ?? '' - trigger.patternFlags = asTrimmedString(payload.patternFlags) ?? '' - } - - const description = asTrimmedString(payload.description) - if (description) trigger.description = description - - return trigger -} - -export function sanitizeNodeCondition(raw: any, index = 0): DecisionNodeCondition { - const payload = raw && typeof raw === 'object' ? raw : {} - const type = asTrimmedString(payload.type) - const normalizedType = - type && NODE_CONDITION_TYPES.has(type) - ? (type as DecisionNodeCondition['type']) - : 'variable_value' - - const condition: DecisionNodeCondition = { - id: asTrimmedString(payload.id) || `condition_${randomUUID()}`, - type: normalizedType, - order: typeof payload.order === 'number' ? payload.order : index, - } - - const description = asTrimmedString(payload.description) - if (description) condition.description = description - - if (condition.type === 'variable_value') { - condition.variable = asTrimmedString(payload.variable) ?? '' - condition.operator = asComparisonOperatorValue(payload.operator) - condition.value = asVariableValue(payload.value, '') - } else { - condition.pattern = asTrimmedString(payload.pattern) ?? '' - condition.patternFlags = asTrimmedString(payload.patternFlags) ?? '' - } - - return condition -} - -export function sanitizeTransition(raw: any, index = 0): DecisionNodeTransition { - if (!raw || typeof raw !== 'object') { - throw new Error('Invalid transition payload') - } - const key = asTrimmedString(raw.key) || `tr_${randomUUID()}` - const type = asTrimmedString(raw.type) - const normalizedType = type && TRANSITION_TYPES.has(type) ? type : 'next' - const target = asTrimmedString(raw.target) - if (!target) { - throw new Error('Transition target is required') - } - - const transition: DecisionNodeTransition = { - key, - type: normalizedType as DecisionNodeTransition['type'], - target, - order: typeof raw.order === 'number' ? raw.order : index, - } - - const label = asTrimmedString(raw.label) - if (label) transition.label = label - const description = asTrimmedString(raw.description) - if (description) transition.description = description - const condition = asTrimmedString(raw.condition) - if (condition) transition.condition = condition - const guard = asTrimmedString(raw.guard) - if (guard) transition.guard = guard - - if (raw.timer && typeof raw.timer === 'object') { - const timerValue = asNumber(raw.timer.afterSeconds) - if (typeof timerValue === 'number') { - transition.timer = { - afterSeconds: timerValue, - allowManualProceed: asBoolean(raw.timer.allowManualProceed, true), - } - } - } - - if (raw.autoTrigger) { - transition.autoTrigger = sanitizeAutoTrigger(raw.autoTrigger) - } - - if (raw.metadata && typeof raw.metadata === 'object') { - const color = asTrimmedString(raw.metadata.color) - const icon = asTrimmedString(raw.metadata.icon) - const notes = asTrimmedString(raw.metadata.notes) - const previewTemplate = asTrimmedString(raw.metadata.previewTemplate) - const metadata: DecisionNodeTransition['metadata'] = {} - if (color) metadata.color = color - if (icon) metadata.icon = icon - if (notes) metadata.notes = notes - if (previewTemplate) metadata.previewTemplate = previewTemplate - if (Object.keys(metadata).length) { - transition.metadata = metadata - } - } - - return transition -} diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts index b3c3f30..2c994fa 100644 --- a/server/utils/normalize.ts +++ b/server/utils/normalize.ts @@ -15,6 +15,10 @@ if (openaiBaseUrl) { export const normalize = new OpenAI(normalizeClientOptions); +export function getOpenAIClient(): OpenAI { + return normalize; +} + export const LLM_MODEL = llmModel; export const TTS_MODEL = ttsModel; diff --git a/server/utils/openai.test.ts b/server/utils/openai.test.ts deleted file mode 100644 index c400a37..0000000 --- a/server/utils/openai.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert/strict' - -import type { RuntimeDecisionState, RuntimeDecisionSystem } from '~~/shared/types/decision' -import type { LLMDecisionInput } from '~~/shared/types/llm' -import { __setRuntimeDecisionSystemForTests, routeDecision } from './openai' - -const createState = (overrides: Partial): RuntimeDecisionState => ({ - role: 'pilot', - phase: 'ground', - name: 'State', - summary: 'Generic state', - say_tpl: undefined, - utterance_tpl: undefined, - else_say_tpl: undefined, - next: [], - ok_next: [], - bad_next: [], - timer_next: [], - auto: null, - readback_required: undefined, - actions: undefined, - handoff: undefined, - guard: undefined, - trigger: undefined, - frequency: undefined, - frequencyName: undefined, - auto_transitions: [], - triggers: [], - conditions: [], - metadata: undefined, - ...overrides, -}) - -const START = createState({ - name: 'Start', - summary: 'Start of flow', - role: 'atc', -}) - -const ACK = createState({ - name: 'Acknowledge', - summary: 'Acknowledge pilot readback', - triggers: [ - { type: 'regex', pattern: 'roger', patternFlags: 'i' }, - ], -}) - -const TAXI = createState({ - name: 'Taxi clearance', - summary: 'Pilot requesting taxi clearance', - triggers: [ - { type: 'regex', pattern: 'request', patternFlags: 'i' }, - ], -}) - -const HOLD = createState({ - name: 'Hold position', - summary: 'Pilot requesting hold position', - triggers: [ - { type: 'regex', pattern: 'request', patternFlags: 'i' }, - ], -}) - -START.next = [ - { to: 'ACK' }, - { to: 'TAXI' }, - { to: 'HOLD' }, -] - -const runtimeSystem: RuntimeDecisionSystem = { - main: 'main', - order: ['main'], - flows: { - main: { - slug: 'main', - start_state: 'START', - entry_mode: 'main', - states: { - START, - ACK, - TAXI, - HOLD, - }, - }, - }, -} - -const baseInput: Omit = { - state_id: 'START', - state: START, - variables: { callsign: 'TEST123' }, - flags: { current_unit: 'TWR', in_air: false }, - pilot_utterance: '', -} - -describe('routeDecision', () => { - beforeEach(() => { - __setRuntimeDecisionSystemForTests(runtimeSystem) - }) - - it('returns heuristic decision when exactly one candidate matches', async () => { - const input: LLMDecisionInput = { - ...baseInput, - pilot_utterance: 'Roger that', - candidates: [ - { id: 'ACK', state: ACK }, - ], - } - - const result = await routeDecision(input) - - assert.equal(result.decision.next_state, 'ACK') - assert.equal(result.trace?.calls.length ?? 0, 0) - assert.equal(result.trace?.autoSelection?.id, 'ACK') - assert.equal(result.pilot_intent ?? null, null) - }) - - it('falls back to heuristic selection when OpenAI call fails', async () => { - const input: LLMDecisionInput = { - ...baseInput, - pilot_utterance: 'Request taxi instructions', - candidates: [ - { id: 'TAXI', state: TAXI }, - { id: 'HOLD', state: HOLD }, - ], - } - - const result = await routeDecision(input) - - assert.equal(result.trace?.calls.length, 1) - assert.ok(result.trace?.calls[0]?.error) - assert.equal(result.trace?.fallback?.used, true) - assert.equal(result.decision.next_state, 'TAXI') - assert.equal(result.pilot_intent ?? null, null) - }) -}) - diff --git a/server/utils/openai.ts b/server/utils/openai.ts deleted file mode 100644 index a0e0005..0000000 --- a/server/utils/openai.ts +++ /dev/null @@ -1,1136 +0,0 @@ -// server/utils/openai.ts -import OpenAI from 'openai' -import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech' -import type { - CandidateTraceEntry, - CandidateTraceStep, - DecisionCandidateTimeline, - FlowActivationMode, - LLMDecisionInput, - LLMDecisionResult, - LLMDecisionTrace, -} from '../../shared/types/llm' -import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision' -import { buildRuntimeDecisionSystem } from '../services/decisionFlowService' -import {getServerRuntimeConfig} from './runtimeConfig' - -let openaiClient: OpenAI | null = null -let cachedModel: string | null = null - -import https from 'node:https' - -const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 50, // bei Bedarf anpassen - maxFreeSockets: 10, - timeout: 0 // keine Socket-Idle-Timeouts durch Node -}) - -function ensureOpenAI(): OpenAI { - if (!openaiClient) { - const {openaiKey, openaiProject, openaiBaseUrl, llmModel} = getServerRuntimeConfig() - if (!openaiKey) { - throw new Error('OPENAI_API_KEY is missing. Please set the key before using AI features.') - } - const clientOptions: ConstructorParameters[0] = {apiKey: openaiKey, - defaultHeaders: { 'Connection': 'keep-alive' }, - defaultHttpAgent: httpsAgent - } - if (openaiProject) { - clientOptions.project = openaiProject - } - if (openaiBaseUrl) { - clientOptions.baseURL = openaiBaseUrl - } - console.log("using connection opened client") - openaiClient = new OpenAI(clientOptions) - cachedModel = llmModel - } - console.log("returning existing openai client") - return openaiClient -} - -function getModel(): string { - if (!cachedModel) { - const {llmModel} = getServerRuntimeConfig() - cachedModel = llmModel - } - return cachedModel -} - -export function getOpenAIClient(): OpenAI { - return ensureOpenAI() -} - -export async function decide(system: string, user: string): Promise { - const client = ensureOpenAI() - const model = getModel() - const r = await client.chat.completions.create({ - model, - messages: [ - {role: 'system', content: system}, - {role: 'user', content: user} - ] - }) - return r.choices?.[0]?.message?.content?.trim() || '' -} - - -type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain' - -const READBACK_REQUIREMENTS: Record = { - CD_READBACK_CHECK: ['dest', 'sid', 'runway', 'initial_altitude_ft', 'squawk'], - GRD_TAXI_READBACK_CHECK: ['runway', 'taxi_route', 'hold_short'], - TWR_TAKEOFF_READBACK_CHECK: ['runway', 'cleared_takeoff'], - GRD_TAXI_IN_READBACK_CHECK: ['gate', 'taxi_route'] -} - -const READBACK_JSON_SCHEMA = { - name: 'readback_check', - schema: { - type: 'object', - additionalProperties: false, - properties: { - status: { - type: 'string', - enum: ['ok', 'missing', 'incorrect', 'uncertain'] - }, - missing: { - type: 'array', - items: {type: 'string'}, - default: [] - }, - incorrect: { - type: 'array', - items: {type: 'string'}, - default: [] - }, - confidence: { - type: 'number' - }, - notes: { - type: 'string' - } - }, - required: ['status'] - } -} as const - -function sanitizeForQuickMatch(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() -} - -function buildSpokenVariants(key: string, value: string): string[] { - const normalized = String(value ?? '').trim() - if (!normalized) return [] - - const variants = new Set() - variants.add(normalized) - variants.add(normalized.toUpperCase()) - - if (key === 'hold_short') { - const base = normalized.replace(/^holding\s+short/i, 'hold short') - variants.add(base) - if (!/\brunway\b/i.test(base)) { - variants.add(base.replace(/^(hold short)/i, '$1 runway')) - } - } - - if (key === 'cleared_takeoff') { - variants.add(normalized.replace(/take-off/gi, 'takeoff')) - variants.add(normalized.replace(/take-off/gi, 'take off')) - } - - if (/^[A-Z]{3,4}$/.test(normalized.toUpperCase())) { - variants.add(toIcaoPhonetic(normalized)) - } - - if (/^\d{4}$/.test(normalized)) { - variants.add(normalized.split('').join(' ')) - variants.add(spellIcaoDigits(normalized)) - } - - if (/^\d{1,2}[LCR]?$/i.test(normalized)) { - const digits = normalized.match(/\d+/)?.[0] ?? '' - const spelledDigits = spellIcaoDigits(digits) - const suffix = normalized.replace(/\d+/g, '').toUpperCase() - const suffixWord = suffix === 'L' ? 'left' : suffix === 'R' ? 'right' : suffix === 'C' ? 'center' : '' - - variants.add(`runway ${normalized}`) - if (spelledDigits) { - variants.add(`runway ${spelledDigits}${suffixWord ? ` ${suffixWord}` : ''}`) - } - } - - if (key.includes('altitude') || key.includes('level')) { - const digits = normalized.replace(/[^0-9]/g, '') - if (digits) { - const spaced = digits.split('').join(' ') - variants.add(spaced) - variants.add(digits) - variants.add(spellIcaoDigits(digits)) - } - } - - return Array.from(variants) -} - -/** - * Quick heuristic readback check: verifies that the pilot's utterance - * contains the required fields (dest, runway, squawk, etc.) by matching - * against spoken variants of the expected values. - * Returns 'ok' if all required fields are present, 'missing' with the - * list of missing keys otherwise. - */ -function quickReadbackCheck( - utterance: string, - readbackKeys: string[], - variables: Record -): { status: 'ok' | 'missing'; missing: string[] } { - if (!readbackKeys.length) return { status: 'ok', missing: [] } - - const sanitized = sanitizeForQuickMatch(utterance) - const missing: string[] = [] - - for (const key of readbackKeys) { - const expected = resolveReadbackValue(key, { variables } as any) - if (!expected) continue // Can't verify if no expected value - - const variants = buildSpokenVariants(key, expected) - const found = variants.some(variant => - sanitized.includes(sanitizeForQuickMatch(variant)) - ) - if (!found) { - missing.push(key) - } - } - - return { - status: missing.length === 0 ? 'ok' : 'missing', - missing, - } -} - -function pickTransition( - transitions: Array<{ to: string }> | undefined, - candidates: Array<{ id: string; state: any }> -): string | null { - if (!transitions?.length) return null - for (const option of transitions) { - if (candidates.some(c => c.id === option.to)) { - return option.to - } - } - return null -} - -function fallbackNextState(input: LLMDecisionInput): string { - return input.candidates[0]?.id || input.state_id || 'GEN_NO_REPLY' -} - -interface IndexedStateEntry { - flow: string - state: RuntimeDecisionState -} - -interface DecisionCandidate { - id: string - flow: string - state: RuntimeDecisionState - triggers: DecisionNodeTrigger[] - regexTriggers: DecisionNodeTrigger[] - noneTriggers: DecisionNodeTrigger[] -} - -interface PreparedCandidateResult { - finalCandidates: DecisionCandidate[] - candidateFlowMap: Map - candidateIndex: Map - finalCandidateIndex: Map - activeFlowSlug: string - flowEntryModes: Map - timeline: DecisionCandidateTimeline - autoSelected?: DecisionCandidate | null -} - -const RUNTIME_CACHE_TTL_MS = 5_000 -let runtimeSystemCache: { system: RuntimeDecisionSystem; index: Map; timestamp: number } | null = null - -function buildRuntimeIndex(system: RuntimeDecisionSystem): Map { - const index = new Map() - for (const [flowSlug, tree] of Object.entries(system.flows || {})) { - const states = tree?.states || {} - for (const [stateId, state] of Object.entries(states)) { - index.set(stateId, { flow: flowSlug, state }) - } - } - return index -} - -async function getRuntimeSystemIndex(): Promise<{ system: RuntimeDecisionSystem; index: Map }> { - const now = Date.now() - if (!runtimeSystemCache || now - runtimeSystemCache.timestamp > RUNTIME_CACHE_TTL_MS) { - const system = await buildRuntimeDecisionSystem() - runtimeSystemCache = { - system, - index: buildRuntimeIndex(system), - timestamp: now, - } - } - return { system: runtimeSystemCache.system, index: runtimeSystemCache.index } -} - -function evaluateRegexPattern(pattern: string | undefined, flags: string | undefined, value: string): boolean { - const source = pattern?.trim() - if (!source) { - return false - } - const normalizedFlags = flags && flags.trim().length ? flags : 'i' - try { - const regex = new RegExp(source, normalizedFlags) - return regex.test(value) - } catch { - return false - } -} - -function analyzeTriggers(triggers: DecisionNodeTrigger[] | undefined, utterance: string) { - if (!Array.isArray(triggers) || triggers.length === 0) { - return { matchesRegex: false, matchesNone: true } - } - - let matchesRegex = false - let hasNone = false - for (const trigger of triggers) { - if (!trigger) continue - if (trigger.type === 'regex') { - if (evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) { - matchesRegex = true - } - } else if (trigger.type === 'none') { - hasNone = true - } - } - - if (!matchesRegex && !hasNone) { - hasNone = true - } - - return { matchesRegex, matchesNone: hasNone } -} - -function normalizeComparable(value: any): any { - if (typeof value === 'number') return value - if (typeof value === 'boolean') return value - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed.length) return '' - const numeric = Number(trimmed) - if (!Number.isNaN(numeric)) return numeric - if (trimmed.toLowerCase() === 'true') return true - if (trimmed.toLowerCase() === 'false') return false - return trimmed - } - return value -} - -function parseComparable(raw: any): any { - if (typeof raw === 'number' || typeof raw === 'boolean') { - return raw - } - if (typeof raw === 'string') { - const trimmed = raw.trim() - if (!trimmed.length) return '' - const numeric = Number(trimmed) - if (!Number.isNaN(numeric)) return numeric - if (trimmed.toLowerCase() === 'true') return true - if (trimmed.toLowerCase() === 'false') return false - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith('\'') && trimmed.endsWith('\'')) - ) { - return trimmed.slice(1, -1) - } - return trimmed - } - return raw -} - -function compareValuesSafe(left: any, operator: string | undefined, right: any): { - result: boolean - left: any - right: any - operator: string -} { - const normalizedLeft = normalizeComparable(left) - const normalizedRight = normalizeComparable(parseComparable(right)) - const op = operator || '==' - let result = false - switch (op) { - case '>': - result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' - ? normalizedLeft > normalizedRight - : false - break - case '>=': - result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' - ? normalizedLeft >= normalizedRight - : false - break - case '<': - result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' - ? normalizedLeft < normalizedRight - : false - break - case '<=': - result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number' - ? normalizedLeft <= normalizedRight - : false - break - case '!==': - case '!=': - result = normalizedLeft !== normalizedRight - break - case '===': - case '==': - default: - result = normalizedLeft === normalizedRight - break - } - return { result, left: normalizedLeft, right: normalizedRight, operator: op } -} - -function resolveContextPath( - path: string | undefined, - context: { variables: Record; flags: Record } -) { - if (!path || typeof path !== 'string') return undefined - const segments = path.split('.').map(segment => segment.trim()).filter(Boolean) - if (!segments.length) return undefined - - let current: any - const [first, ...rest] = segments - if (first === 'variables' || first === 'flags') { - current = (context as any)[first] - } else { - current = context.variables - rest.unshift(first) - } - - for (const segment of rest) { - if (current == null) return undefined - current = current[segment] - } - return current -} - -function evaluateConditionEntry( - condition: DecisionNodeCondition | undefined, - context: { variables: Record; flags: Record }, - utterance: string -): { passed: boolean; detail?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { - if (!condition) return { passed: true } - switch (condition.type) { - case 'regex': { - const passed = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) - return { - passed, - detail: passed ? undefined : { condition }, - } - } - case 'regex_not': { - const matched = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance) - const passed = !matched - return { - passed, - detail: passed ? undefined : { condition }, - } - } - case 'variable_value': - default: { - const left = resolveContextPath(condition.variable, context) - const comparison = compareValuesSafe(left, condition.operator, condition.value) - return { - passed: comparison.result, - detail: comparison.result - ? undefined - : { - condition, - actualValue: comparison.left, - expectedValue: comparison.right, - operator: comparison.operator, - }, - } - } - } -} - -function evaluateConditionList( - conditions: DecisionNodeCondition[] | undefined, - context: { variables: Record; flags: Record }, - utterance: string -): { passed: boolean; failure?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } { - if (!Array.isArray(conditions) || conditions.length === 0) { - return { passed: true } - } - const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0)) - for (const condition of ordered) { - const result = evaluateConditionEntry(condition, context, utterance) - if (!result.passed) { - return { - passed: false, - failure: { - condition, - actualValue: result.detail?.actualValue, - expectedValue: result.detail?.expectedValue, - operator: result.detail?.operator, - }, - } - } - } - return { passed: true } -} - -async function prepareDecisionCandidates( - input: LLMDecisionInput, - utterance: string -): Promise { - const { system, index } = await getRuntimeSystemIndex() - - let activeFlowSlug = input.flow_slug && system.flows[input.flow_slug] - ? input.flow_slug - : undefined - - if (!activeFlowSlug) { - const entry = index.get(input.state_id) - if (entry) { - activeFlowSlug = entry.flow - } - } - - if (!activeFlowSlug) { - activeFlowSlug = system.main || Object.keys(system.flows)[0] || '' - } - - const flowEntryModes = new Map() - for (const [slug, tree] of Object.entries(system.flows || {})) { - const mode = tree.entry_mode === 'main' - ? 'main' - : tree.entry_mode === 'linear' - ? 'linear' - : slug === system.main - ? 'main' - : 'parallel' - flowEntryModes.set(slug, mode) - } - - const candidateMap = new Map() - - const createCandidate = (id: string, flow: string | undefined, state: RuntimeDecisionState | undefined): DecisionCandidate | null => { - if (!id || !state) return null - const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : [] - const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex') - const noneTriggers = triggers.filter(trigger => trigger?.type === 'none') - return { - id, - flow: flow || activeFlowSlug, - state, - triggers, - regexTriggers, - noneTriggers, - } - } - - const addCandidate = (id: string | undefined, flow: string | undefined, state: RuntimeDecisionState | undefined) => { - if (!id) return - if (candidateMap.has(id)) return - const candidate = createCandidate(id, flow, state) - if (candidate) { - candidateMap.set(id, candidate) - } - } - - for (const raw of input.candidates || []) { - if (!raw?.id) continue - const indexed = index.get(raw.id) - const flow = raw.flow || indexed?.flow || activeFlowSlug - const state = indexed?.state ? { ...indexed.state } : raw.state - addCandidate(raw.id, flow, state) - } - - for (const raw of input.candidates || []) { - if (!raw?.id || !raw.state) continue - if (!candidateMap.has(raw.id)) { - addCandidate(raw.id, raw.flow || activeFlowSlug, raw.state) - } - } - - // Note: Previously, all flow start states were added as candidates here. - // This was removed because it polluted the candidate pool and caused the - // LLM to pick unrelated flow starts. Flow switches should be defined via - // explicit transitions in the decision tree instead. - - const candidates = Array.from(candidateMap.values()) - const context = { variables: input.variables || {}, flags: input.flags || {} } - const timelineSteps: CandidateTraceStep[] = [] - let fallbackUsed = false - - const toTraceEntry = (candidate: DecisionCandidate): CandidateTraceEntry => ({ - id: candidate.id, - flow: candidate.flow, - name: candidate.state?.name, - summary: candidate.state?.summary, - role: candidate.state?.role, - triggers: candidate.triggers, - conditions: candidate.state?.conditions || [], - }) - - const recordStep = ( - stage: CandidateTraceStage, - label: string, - stepCandidates: DecisionCandidate[], - eliminated: CandidateTraceElimination[] = [], - note?: string - ) => { - timelineSteps.push({ - stage, - label, - candidates: stepCandidates.map(toTraceEntry), - eliminated: eliminated.length ? eliminated : undefined, - note, - }) - } - - const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0) - let workingSet: DecisionCandidate[] = [] - - if (regexCandidates.length > 0) { - recordStep('regex_candidates', 'Regex candidates', regexCandidates) - - const survivors: DecisionCandidate[] = [] - const eliminated: CandidateTraceElimination[] = [] - - for (const candidate of regexCandidates) { - const matched = candidate.regexTriggers.some(trigger => - evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance) - ) - if (matched) { - survivors.push(candidate) - } else { - eliminated.push({ - candidate: toTraceEntry(candidate), - kind: 'regex', - reason: 'No regex trigger matched the pilot utterance.', - context: { - patterns: candidate.regexTriggers.map(trigger => ({ - id: trigger.id, - pattern: trigger.pattern, - flags: trigger.patternFlags, - })), - transcript: utterance, - }, - }) - } - } - - recordStep( - 'regex_filtered', - 'Regex evaluation', - survivors, - eliminated, - survivors.length ? undefined : 'No regex triggers matched the pilot transmission.' - ) - - workingSet = survivors - } else { - recordStep('regex_candidates', 'Regex candidates', [], [], 'No regex-triggered transitions available.') - workingSet = [] - } - - let finalCandidates: DecisionCandidate[] = [] - - if (workingSet.length > 0) { - const survivors: DecisionCandidate[] = [] - const eliminated: CandidateTraceElimination[] = [] - - for (const candidate of workingSet) { - const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance) - if (evaluation.passed) { - survivors.push(candidate) - } else if (evaluation.failure) { - eliminated.push({ - candidate: toTraceEntry(candidate), - kind: 'condition', - reason: 'Node conditions were not satisfied.', - context: { - condition: evaluation.failure.condition, - actualValue: evaluation.failure.actualValue, - expectedValue: evaluation.failure.expectedValue, - operator: evaluation.failure.operator, - }, - }) - } else { - eliminated.push({ - candidate: toTraceEntry(candidate), - kind: 'condition', - reason: 'Node conditions were not satisfied.', - }) - } - } - - recordStep( - 'condition_filtered', - 'Condition evaluation', - survivors, - eliminated, - survivors.length ? undefined : 'All regex candidates failed their conditions.' - ) - - finalCandidates = survivors - } - - if (finalCandidates.length === 0) { - fallbackUsed = true - const fallbackCandidates = candidates.filter(candidate => - candidate.noneTriggers.length > 0 || (candidate.triggers.length === 0 && candidate.regexTriggers.length === 0) - ) - - if (fallbackCandidates.length > 0) { - recordStep('fallback_candidates', 'Fallback candidates', fallbackCandidates) - - const survivors: DecisionCandidate[] = [] - const eliminated: CandidateTraceElimination[] = [] - - for (const candidate of fallbackCandidates) { - const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance) - if (evaluation.passed) { - survivors.push(candidate) - } else if (evaluation.failure) { - eliminated.push({ - candidate: toTraceEntry(candidate), - kind: 'condition', - reason: 'Node conditions were not satisfied.', - context: { - condition: evaluation.failure.condition, - actualValue: evaluation.failure.actualValue, - expectedValue: evaluation.failure.expectedValue, - operator: evaluation.failure.operator, - }, - }) - } else { - eliminated.push({ - candidate: toTraceEntry(candidate), - kind: 'condition', - reason: 'Node conditions were not satisfied.', - }) - } - } - - recordStep( - 'fallback_filtered', - 'Fallback evaluation', - survivors, - eliminated, - survivors.length ? undefined : 'No fallback candidates satisfied their conditions.' - ) - - finalCandidates = survivors - } else { - recordStep('fallback_candidates', 'Fallback candidates', [], [], 'No fallback triggers defined.') - recordStep('fallback_filtered', 'Fallback evaluation', [], [], 'No fallback candidates available.') - } - } - - recordStep( - 'final', - 'Final candidates', - finalCandidates, - [], - finalCandidates.length ? undefined : 'No transitions remain after evaluation.' - ) - - const autoSelected = finalCandidates.length === 1 ? finalCandidates[0] : null - - const candidateFlowMap = new Map() - for (const candidate of finalCandidates) { - if (candidate.flow) { - candidateFlowMap.set(candidate.id, candidate.flow) - } - } - - const finalCandidateIndex = new Map() - for (const candidate of finalCandidates) { - finalCandidateIndex.set(candidate.id, candidate) - } - - const timeline: DecisionCandidateTimeline = { - steps: timelineSteps, - fallbackUsed, - autoSelected: autoSelected ? toTraceEntry(autoSelected) : null, - } - - return { - finalCandidates, - candidateFlowMap, - candidateIndex: candidateMap, - finalCandidateIndex, - activeFlowSlug, - flowEntryModes, - timeline, - autoSelected, - } -} - -function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null { - const rawValue = input.variables?.[key] - if (rawValue !== undefined && rawValue !== null) { - const trimmed = `${rawValue}`.trim() - if (trimmed.length > 0) { - return trimmed - } - } - - switch (key) { - case 'hold_short': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `holding short ${runway}`.trim() - } - return 'holding short' - } - case 'cleared_takeoff': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `cleared for take-off ${runway}`.trim() - } - return 'cleared for take-off' - } - case 'cleared_to_land': { - const runway = input.variables?.runway - if (typeof runway === 'string' && runway.trim().length > 0) { - return `cleared to land runway ${runway}`.trim() - } - return 'cleared to land' - } - default: - return null - } -} - -// Extrahiere verwendete Variablen aus Templates -function extractTemplateVariables(text?: string): string[] { - if (!text) return [] - const matches = text.match(/\{([^}]+)\}/g) || [] - return matches.map(match => match.slice(1, -1)) // Remove { } -} - -// Optimized yet sufficient input for reliable decisions -function optimizeInputForLLM(input: LLMDecisionInput) { - // Collect all available variables from the decision tree - const availableVariables = [ - 'callsign', 'dest', 'dep', 'runway', 'squawk', 'sid', 'transition', - 'initial_altitude_ft', 'climb_altitude_ft', 'cruise_flight_level', - 'taxi_route', 'stand', 'gate', 'atis_code', 'qnh_hpa', - 'ground_freq', 'tower_freq', 'departure_freq', 'approach_freq', 'handoff_freq', - 'star', 'approach_type', 'remarks', 'acf_type' - ] - - const readbackKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || [] - - const stateSummary = { - id: input.state_id, - role: input.state.role, - phase: input.state.phase, - auto: input.state.auto ?? null, - say_tpl: input.state.say_tpl ?? null, - utterance_tpl: input.state.utterance_tpl ?? null, - readback_keys: readbackKeys, - next: (input.state.next ?? []).map((n: any) => n.to), - ok_next: (input.state.ok_next ?? []).map((n: any) => n.to), - bad_next: (input.state.bad_next ?? []).map((n: any) => n.to) - } - - // Relevante Candidate-Daten mit Template-Variablen - const candidates = input.candidates.map(c => { - const templateVars = extractTemplateVariables(c.state.say_tpl) - const candidateReadback = READBACK_REQUIREMENTS[c.id] || c.state.readback_required || [] - const requiresResponse = - c.state.role === 'atc' || - Boolean(c.state.say_tpl) || - Boolean(candidateReadback.length) || - c.id.startsWith('INT_') - return { - id: c.id, - role: c.state.role, - phase: c.state.phase, - template_vars: templateVars, // Welche Variablen dieser State verwendet - auto: c.state.auto ?? null, - requires_atc_reply: requiresResponse, - readback_keys: candidateReadback, - has_say_tpl: Boolean(c.state.say_tpl), - has_utterance_tpl: Boolean(c.state.utterance_tpl), - handoff: c.state.handoff ? { - to: c.state.handoff.to, - freq: c.state.handoff.freq ?? null - } : null - } - }) - - // Sammle alle Template-Variablen aus den Candidates - const candidateVars = new Set() - candidates.forEach(c => c.template_vars?.forEach(v => candidateVars.add(v))) - - return { - state_id: input.state_id, - current_phase: input.state.phase, - current_role: input.state.role, - state_summary: stateSummary, - candidates: candidates, - available_variables: availableVariables, // All available variables - candidate_variables: Array.from(candidateVars), // Variablen die Candidates verwenden - pilot_utterance: input.pilot_utterance, - decision_hints: { - expecting_pilot_call: input.state.role === 'pilot', - state_auto: input.state.auto ?? null, - current_unit: input.flags.current_unit, - has_interrupt_candidate: input.candidates.some(c => c.id.startsWith('INT_')), - readback_check_state: Boolean(readbackKeys.length) - }, - // Current context only without values (to save tokens) - context: { - callsign: input.variables.callsign, - current_unit: input.flags.current_unit, - in_air: input.flags.in_air, - phase: input.state.phase - } - } -} - - -function summarizeCandidateForPrompt(candidate: DecisionCandidate) { - const { state } = candidate - return { - id: candidate.id, - flow: candidate.flow, - role: state?.role, - phase: state?.phase, - summary: state?.summary, - say_tpl: state?.say_tpl, - utterance_tpl: state?.utterance_tpl, - handoff: state?.handoff, - } -} - -function extractJsonObject(text: string): any | null { - if (!text) return null - const trimmed = text.trim() - try { - return JSON.parse(trimmed) - } catch {} - - const match = trimmed.match(/\{[\s\S]*\}/) - if (!match) { - return null - } - try { - return JSON.parse(match[0]) - } catch { - return null - } -} - -function buildDecisionObject(stateId: string, candidate: DecisionCandidate | undefined, index: Map): LLMDecisionResult['decision'] { - const decision: LLMDecisionResult['decision'] = { next_state: stateId } - // Attach the say_tpl from the chosen state so the frontend can speak it - // without an extra lookup. Checks the candidate first, then the runtime index. - const sayTpl = candidate?.state?.say_tpl ?? index.get(stateId)?.state?.say_tpl - if (sayTpl) { - decision.controller_say_tpl = sayTpl - } - return decision -} - -export async function routeDecision(input: LLMDecisionInput): Promise { - const utterance = (input.pilot_utterance || '').trim() - const prepared = await prepareDecisionCandidates(input, utterance) - const { index } = await getRuntimeSystemIndex() - - const trace: LLMDecisionTrace = { - calls: [], - candidateTimeline: prepared.timeline, - } - let pilotIntent: string | null = null - - // Heuristic readback check: if the current state requires a readback - // (e.g. pilot just read back a clearance), verify the required fields - // are present in the utterance BEFORE routing. This catches obvious - // readback errors without needing an LLM call. - const readbackKeys = READBACK_REQUIREMENTS[input.state_id] || input.state?.readback_required || [] - if (readbackKeys.length > 0 && utterance) { - const check = quickReadbackCheck(utterance, readbackKeys, input.variables || {}) - if (check.status === 'missing' && check.missing.length > 0) { - // Readback incomplete — try to route to bad_next (repeat instruction) - const badTargets = (input.state?.bad_next ?? []).map((t: any) => t?.to).filter(Boolean) - const badCandidate = badTargets.length > 0 - ? prepared.candidateIndex.get(badTargets[0]) ?? null - : null - if (badCandidate) { - trace.autoSelection = { - id: badCandidate.id, - flow: badCandidate.flow, - reason: `Readback missing fields: ${check.missing.join(', ')}`, - } - return { - decision: buildDecisionObject(badCandidate.id, badCandidate, index), - trace, - pilot_intent: 'incomplete_readback', - } - } - } - } - - if (prepared.autoSelected) { - trace.autoSelection = { - id: prepared.autoSelected.id, - flow: prepared.autoSelected.flow, - reason: 'Heuristic routing resolved a single remaining candidate.', - } - return { - decision: buildDecisionObject(prepared.autoSelected.id, prepared.autoSelected, index), - trace, - pilot_intent: pilotIntent, - } - } - - const candidatePool = prepared.finalCandidates.length > 0 - ? prepared.finalCandidates - : Array.from(prepared.candidateIndex.values()) - - if (candidatePool.length === 0) { - const fallbackState = fallbackNextState(input) - trace.fallback = { - used: true, - reason: 'No viable candidates after heuristic evaluation; falling back to default transition.', - selected: fallbackState, - } - return { decision: buildDecisionObject(fallbackState, undefined, index), trace, pilot_intent: pilotIntent } - } - - const optimizedInput = optimizeInputForLLM({ - ...input, - candidates: candidatePool.map(candidate => ({ - id: candidate.id, - flow: candidate.flow, - state: candidate.state, - })), - }) - - const candidateSummaries = candidatePool - .map(candidate => { - const summary = [ - `${candidate.id}`, - candidate.state?.summary || candidate.state?.say_tpl || candidate.state?.utterance_tpl || '', - ] - .filter(Boolean) - .join(' — ') - return `- ${summary}` - }) - .join('\n') - - const systemPrompt = [ - 'You are an assistant that selects the correct next state in an aviation decision tree.', - 'Evaluate the pilot transmission and choose the most appropriate candidate state id from the provided list.', - [ - 'Respond strictly with a JSON object whose first property is "pilot_intent"', - 'followed by "next_state" and "reason": {"pilot_intent": "intent", "next_state": "STATE_ID", "reason": "short rationale"}.', - ].join(' '), - 'Only use state ids that were provided. If you cannot decide, choose the best heuristic option.', - ].join(' ') - - const userPrompt = [ - `Pilot transmission: "${utterance || '(silence)'}"`, - 'Candidate options:', - candidateSummaries, - 'Context (JSON):', - JSON.stringify(optimizedInput, null, 2), - ].join('\n') - - const callEntry = { - stage: 'decision' as const, - request: { - systemPrompt, - userPrompt, - candidates: candidatePool.map(summarizeCandidateForPrompt), - }, - } - trace.calls.push(callEntry) - - try { - const rawResponse = await decide(systemPrompt, userPrompt) - callEntry.rawResponseText = rawResponse - const parsed = extractJsonObject(rawResponse) - - if (parsed && typeof parsed === 'object') { - callEntry.response = parsed - - if (typeof (parsed as any).pilot_intent === 'string') { - const intentValue = ((parsed as any).pilot_intent as string).trim() - if (intentValue) { - pilotIntent = intentValue - } - } - } - - const nextState = - parsed && typeof parsed === 'object' && typeof (parsed as any).next_state === 'string' - ? ((parsed as any).next_state as string).trim() - : '' - - if (nextState.length > 0) { - const resolved = - prepared.finalCandidateIndex.get(nextState) - || prepared.candidateIndex.get(nextState) - - if (resolved) { - return { - decision: buildDecisionObject(resolved.id, resolved, index), - trace, - pilot_intent: pilotIntent, - } - } - - return { - decision: buildDecisionObject(nextState, undefined, index), - trace, - pilot_intent: pilotIntent, - } - } - - throw new Error('LLM response missing next_state field') - } catch (err: any) { - callEntry.error = err?.message || String(err) - - const fallbackCandidate = prepared.finalCandidates[0] || candidatePool[0] - const fallbackState = fallbackCandidate?.id || fallbackNextState(input) - - trace.fallback = { - used: true, - reason: 'OpenAI decision failed or was inconclusive; falling back to heuristic selection.', - selected: fallbackState, - } - - return { - decision: buildDecisionObject(fallbackState, fallbackCandidate ?? undefined, index), - trace, - pilot_intent: pilotIntent, - } - } -} - -export function __setRuntimeDecisionSystemForTests(system: RuntimeDecisionSystem) { - runtimeSystemCache = { - system, - index: buildRuntimeIndex(system), - timestamp: Date.now(), - } -} diff --git a/shared/types/decision.ts b/shared/types/decision.ts deleted file mode 100644 index 70ed290..0000000 --- a/shared/types/decision.ts +++ /dev/null @@ -1,277 +0,0 @@ -export type DecisionNodeRole = 'pilot' | 'atc' | 'system' - -export type DecisionTransitionType = - | 'next' - | 'ok' - | 'bad' - | 'timer' - | 'auto' - | 'interrupt' - | 'return' - -export type DecisionAutoTriggerType = 'telemetry' | 'variable' | 'expression' - -export type DecisionComparisonOperator = '>' | '>=' | '<' | '<=' | '==' | '!=' - -export interface DecisionNodeAutoTrigger { - id: string - type: DecisionAutoTriggerType - parameter?: - | 'altitude_ft' - | 'speed_kts' - | 'groundspeed_kts' - | 'vertical_speed_fpm' - | 'heading_deg' - | 'distance_nm' - variable?: string - operator?: DecisionComparisonOperator - value?: number | string - unit?: string - expression?: string - description?: string - once?: boolean - delayMs?: number -} - -export type DecisionNodeTriggerType = 'auto_time' | 'auto_variable' | 'regex' | 'none' - -export interface DecisionNodeTrigger { - id: string - type: DecisionNodeTriggerType - order?: number - delaySeconds?: number - variable?: string - operator?: DecisionComparisonOperator - value?: number | string | boolean - pattern?: string - patternFlags?: string - description?: string -} - -export type DecisionNodeConditionType = 'variable_value' | 'regex' | 'regex_not' - -export interface DecisionNodeCondition { - id: string - type: DecisionNodeConditionType - order?: number - variable?: string - operator?: DecisionComparisonOperator - value?: number | string | boolean - pattern?: string - patternFlags?: string - description?: string -} - -export interface DecisionTransitionMetadata { - color?: string - icon?: string - notes?: string - previewTemplate?: string -} - -export interface DecisionNodeTransition { - key: string - type: DecisionTransitionType - target: string - label?: string - description?: string - condition?: string - guard?: string - order?: number - timer?: { - afterSeconds: number - allowManualProceed?: boolean - } - autoTrigger?: DecisionNodeAutoTrigger | null - metadata?: DecisionTransitionMetadata -} - -export interface DecisionNodeLayout { - x: number - y: number - width?: number - height?: number - color?: string - icon?: string - locked?: boolean -} - -export interface DecisionNodeLLMPlaceholder { - key: string - label: string - description?: string - required?: boolean - example?: string - defaultValue?: string - type?: 'text' | 'number' | 'choice' -} - -export interface DecisionNodeLLMTemplate { - summary?: string - prompt?: string - responseSchema?: string - autoProceed?: boolean - temperature?: number - topP?: number - maxOutputTokens?: number - placeholders?: DecisionNodeLLMPlaceholder[] - guardrails?: string[] - notes?: string -} - -export interface DecisionNodeMetadata { - tags?: string[] - notes?: string - pinned?: boolean - complexity?: 'low' | 'medium' | 'high' -} - -export type DecisionFlowEntryMode = 'parallel' | 'linear' - -export interface DecisionNodeModel { - stateId: string - title?: string - summary?: string - role: DecisionNodeRole - phase: string - sayTemplate?: string - utteranceTemplate?: string - elseSayTemplate?: string - readbackRequired?: string[] - autoBehavior?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent' - actions?: Array - handoff?: { to: string; freq?: string; note?: string } - guard?: string - trigger?: string - frequency?: string - frequencyName?: string - triggers?: DecisionNodeTrigger[] - conditions?: DecisionNodeCondition[] - transitions: DecisionNodeTransition[] - layout?: DecisionNodeLayout - metadata?: DecisionNodeMetadata - llmTemplate?: DecisionNodeLLMTemplate - createdAt?: string - updatedAt?: string -} - -export interface DecisionFlowLayoutGroup { - id: string - label: string - color?: string - bounds: { - x: number - y: number - width: number - height: number - } -} - -export interface DecisionFlowLayout { - zoom?: number - pan?: { x: number; y: number } - groups?: DecisionFlowLayoutGroup[] -} - -export interface DecisionFlowMetadata { - notes?: string - tags?: string[] - ownerId?: string - lastEditedBy?: string -} - -export interface DecisionFlowModel { - id: string - slug: string - name: string - description?: string - schemaVersion?: string - startState: string - endStates: string[] - variables: Record - flags: Record - policies: Record - hooks: Record - roles: DecisionNodeRole[] - phases: string[] - layout?: DecisionFlowLayout - metadata?: DecisionFlowMetadata - createdAt: string - updatedAt: string - nodeCount?: number - entryMode?: DecisionFlowEntryMode - isMain?: boolean -} - -export interface RuntimeDecisionAutoTransition { - id: string - to: string - label?: string - description?: string - condition?: string - guard?: string - trigger?: DecisionNodeAutoTrigger | null - metadata?: DecisionTransitionMetadata -} - -export interface RuntimeDecisionState { - role: DecisionNodeRole - phase: string - name?: string - summary?: string - say_tpl?: string - utterance_tpl?: string - else_say_tpl?: string - next?: Array<{ to: string; label?: string; when?: string; guard?: string }> - ok_next?: Array<{ to: string; label?: string; when?: string; guard?: string }> - bad_next?: Array<{ to: string; label?: string; when?: string; guard?: string }> - timer_next?: Array<{ to: string; after_s: number; label?: string }> - auto?: string | null - readback_required?: string[] - actions?: any[] - handoff?: { to: string; freq?: string } - guard?: string - trigger?: string - frequency?: string - frequencyName?: string - auto_transitions?: RuntimeDecisionAutoTransition[] - triggers?: DecisionNodeTrigger[] - conditions?: DecisionNodeCondition[] - metadata?: DecisionNodeMetadata -} - -export interface RuntimeDecisionTree { - slug: string - schema_version: string - name: string - description?: string - start_state: string - end_states: string[] - variables: Record - flags: Record - policies: Record - hooks: Record - roles: DecisionNodeRole[] - phases: string[] - states: Record - entry_mode?: 'main' | DecisionFlowEntryMode -} - -export interface RuntimeDecisionSystem { - main: string - order: string[] - flows: Record -} - -export interface DecisionFlowSummary { - id: string - slug: string - name: string - description?: string - startState: string - nodeCount: number - updatedAt: string - createdAt: string - entryMode?: DecisionFlowEntryMode - isMain?: boolean -} diff --git a/shared/types/llm.ts b/shared/types/llm.ts deleted file mode 100644 index 3656d03..0000000 --- a/shared/types/llm.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision' - -export interface LLMDecisionInput { - state_id: string - state: any - candidates: Array<{ id: string; state: any; flow?: string }> - variables: Record - flags: Record - pilot_utterance: string - flow_slug?: string -} - -export type FlowActivationMode = 'main' | 'parallel' | 'linear' - -export interface FlowActivationInstruction { - slug: string - mode?: FlowActivationMode -} - -export interface ActiveNodeSummary { - flow: string - state: string - role?: string - say_tpl?: string - controller_say_tpl?: string -} - -export interface CandidateTraceEntry { - id: string - flow: string - name?: string - summary?: string - role?: string - triggers?: DecisionNodeTrigger[] - conditions?: DecisionNodeCondition[] -} - -export type CandidateTraceStage = - | 'regex_candidates' - | 'regex_filtered' - | 'condition_filtered' - | 'fallback_candidates' - | 'fallback_filtered' - | 'final' - -export interface CandidateTraceEliminationContext { - patterns?: Array<{ id?: string; pattern?: string; flags?: string }> - transcript?: string - condition?: { - id?: string - type: DecisionNodeCondition['type'] - variable?: string - operator?: string - value?: number | string | boolean - pattern?: string - patternFlags?: string - } - actualValue?: any - expectedValue?: any -} - -export interface CandidateTraceElimination { - candidate: CandidateTraceEntry - kind: 'regex' | 'condition' - reason: string - context?: CandidateTraceEliminationContext -} - -export interface CandidateTraceStep { - stage: CandidateTraceStage - label: string - candidates: CandidateTraceEntry[] - eliminated?: CandidateTraceElimination[] - note?: string -} - -export interface DecisionCandidateTimeline { - steps: CandidateTraceStep[] - fallbackUsed?: boolean - autoSelected?: CandidateTraceEntry | null -} - -export interface LLMDecision { - next_state: string - updates?: Record - flags?: Record - controller_say_tpl?: string - off_schema?: boolean - radio_check?: boolean - activate_flow?: string | FlowActivationInstruction - resume_previous?: boolean -} - -export interface LLMDecisionTraceCall { - stage: 'readback-check' | 'decision' - request: Record - response?: any - rawResponseText?: string - error?: string -} - -export interface LLMDecisionTraceFallback { - used: boolean - reason?: string - selected?: string -} - -export interface LLMDecisionTrace { - calls: LLMDecisionTraceCall[] - fallback?: LLMDecisionTraceFallback - candidateTimeline?: DecisionCandidateTimeline - autoSelection?: { - id: string - flow: string - reason?: string - } -} - -export interface LLMDecisionResult { - decision: LLMDecision - trace?: LLMDecisionTrace - active_nodes?: ActiveNodeSummary[] - pilot_intent?: string | null -} diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts deleted file mode 100644 index df64f42..0000000 --- a/shared/utils/communicationsEngine.ts +++ /dev/null @@ -1,1428 +0,0 @@ -// communicationsEngine composable -import { ref, computed, readonly, reactive } from 'vue' -import type { - RuntimeDecisionTree, - RuntimeDecisionSystem, - RuntimeDecisionState, - RuntimeDecisionAutoTransition, - DecisionNodeAutoTrigger, -} from '../types/decision' -import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm' -import { normalizeRadioPhrase } from './radioSpeech' - -// --- DecisionTree runtime types --- -type Role = 'pilot' | 'atc' | 'system' -type Phase = string - -interface EngineFlags { - in_air: boolean - emergency_active: boolean - current_unit: string - stack: string[] - off_schema_count: number - radio_checks_done: number - session_id: string - [key: string]: any -} - -// Flight phases and communication steps for better 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' } -] - -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}.' - } - // Additional steps can be defined here -] - -type FrequencyVariableKey = 'atis_freq' - | 'delivery_freq' - | 'ground_freq' - | 'tower_freq' - | 'departure_freq' - | 'approach_freq' - | 'handoff_freq' - -// --- Load ATC decision tree --- -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 - ground_freq: string - tower_freq: string - departure_freq: string - approach_freq: string - handoff_freq: string - atis_freq?: string - qnh_hpa: number | string - taxi_route: string - remarks?: string - time_now?: string - phase: string - lastTransmission?: string - awaitingResponse?: boolean -} - -export interface EngineLog { - timestamp: Date - frequency?: string - speaker: Role - message: string - normalized: string - state: string - radioCheck?: boolean - offSchema?: boolean - flow?: string -} - -interface FlowSnapshot { - tree: RuntimeDecisionTree - variables: Record - flags: EngineFlags - telemetry: TelemetryState - currentStateId: string - communicationLog: EngineLog[] - autoHistory: Map> - flightContext: FlightContext - ready: boolean -} - -type TelemetryState = { - altitude_ft: number - speed_kts: number - groundspeed_kts: number - vertical_speed_fpm: number - latitude_deg: number - longitude_deg: number - heading_deg: number - [key: string]: number -} - -function createSessionId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` -} - -export function normalizeATCText(text: string, context: Record): string { - const rendered = renderTpl(text, context) - return normalizeRadioPhrase(rendered) -} - -function createDefaultFlightContext(): FlightContext { - return { - callsign: '', - aircraft: 'A320', - dep: 'EDDF', - dest: 'EDDM', - stand: 'A12', - runway: '25R', - squawk: '1234', - atis_code: 'K', - sid: 'ANEKI7S', - transition: 'ANEKI', - flight_level: 'FL360', - atis_freq: '118.025', - 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', - remarks: 'standard', - time_now: undefined, - phase: 'clearance', - } -} - -function renderTpl(tpl: string, ctx: Record): string { - return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => { - const parts = key.split('.') - let cur: any = ctx - for (const p of parts) cur = cur?.[p] - return (cur ?? '').toString() - }) -} - -export default function useCommunicationsEngine() { - const runtimeSystem = ref(null) - const flowOrder = ref([]) - const activeFlowSlug = ref('') - const sessionId = ref('') - const flowStack = ref([]) - - const tree = ref(null) - const ready = ref(false) - const lastDecisionTrace = ref(null) - - const flowSnapshots = reactive>({}) - - const states = computed>(() => tree.value?.states ?? {}) - - const variables = ref>({}) - const flags = ref({ - in_air: false, - emergency_active: false, - current_unit: 'DEL', - stack: [], - off_schema_count: 0, - radio_checks_done: 0, - }) - const currentStateId = ref('') - const communicationLog = ref([]) - - const telemetry = ref({ - altitude_ft: 0, - speed_kts: 0, - groundspeed_kts: 0, - vertical_speed_fpm: 0, - latitude_deg: 0, - longitude_deg: 0, - heading_deg: 0, - }) - - // Flight context used for pm_alt.vue integration - const flightContext = ref(createDefaultFlightContext()) - - const currentState = computed(() => { - const stateMap = states.value - const fallbackId = tree.value?.start_state - const id = currentStateId.value || fallbackId - if (!id) return null - const base = stateMap[id] - return base ? { ...base, id } : null - }) - - function ensureSnapshot(slug: string): FlowSnapshot { - const snapshot = flowSnapshots[slug] - if (!snapshot) { - throw new Error(`Flow snapshot not loaded: ${slug}`) - } - return snapshot - } - - function getActiveSnapshot(): FlowSnapshot | null { - if (!activeFlowSlug.value) return null - return flowSnapshots[activeFlowSlug.value] || null - } - - function assignActiveVariables(next: Record) { - variables.value = next - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].variables = next - } - } - - function ensureSessionValue(raw?: string): string { - if (raw && typeof raw === 'string' && raw.trim().length) { - sessionId.value = raw.trim() - } else if (!sessionId.value) { - sessionId.value = createSessionId() - } - return sessionId.value - } - - function assignActiveFlags(next: EngineFlags) { - const normalizedSession = ensureSessionValue(next?.session_id) - next.session_id = normalizedSession - flags.value = next - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].flags = next - } - } - - function assignActiveTelemetry(next: TelemetryState) { - telemetry.value = next - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].telemetry = next - } - } - - function assignCommunicationLog(next: EngineLog[]) { - communicationLog.value = next - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].communicationLog = next - } - } - - function assignFlightContext(next: FlightContext) { - flightContext.value = next - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].flightContext = next - } - } - - function setActiveStateId(stateId: string) { - currentStateId.value = stateId - if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) { - flowSnapshots[activeFlowSlug.value].currentStateId = stateId - } - } - - function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot { - const variables = { ...treeData.variables } - const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {} - const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : [] - const flags: EngineFlags = { - in_air: Boolean((baseFlags as any).in_air), - emergency_active: Boolean((baseFlags as any).emergency_active), - current_unit: typeof (baseFlags as any).current_unit === 'string' - ? (baseFlags as any).current_unit - : 'DEL', - stack, - off_schema_count: Number((baseFlags as any).off_schema_count) || 0, - radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0, - session_id: '', - ...(baseFlags as EngineFlags), - } - if (!Array.isArray(flags.stack)) { - flags.stack = [] - } - if (typeof flags.session_id !== 'string') { - flags.session_id = '' - } - - const telemetry: TelemetryState = { - altitude_ft: Number((baseFlags as any).altitude_ft) || 0, - speed_kts: Number((baseFlags as any).speed_kts) || 0, - groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0, - vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0, - latitude_deg: Number((baseFlags as any).latitude_deg) || 0, - longitude_deg: Number((baseFlags as any).longitude_deg) || 0, - heading_deg: Number((baseFlags as any).heading_deg) || 0, - } - - const log: EngineLog[] = [] - const snapshotContext = createDefaultFlightContext() - snapshotContext.phase = 'clearance' - - const autoHistory = new Map>() - if (treeData.start_state) { - autoHistory.set(treeData.start_state, new Set()) - } - - return { - tree: treeData, - variables, - flags, - telemetry, - currentStateId: treeData.start_state, - communicationLog: log, - autoHistory, - flightContext: snapshotContext, - ready: true, - } - } - - function persistActiveSnapshot() { - if (!activeFlowSlug.value) return - const snapshot = flowSnapshots[activeFlowSlug.value] - if (!snapshot) return - snapshot.variables = variables.value - snapshot.flags = flags.value - snapshot.telemetry = telemetry.value - snapshot.currentStateId = currentStateId.value - snapshot.communicationLog = communicationLog.value - snapshot.flightContext = flightContext.value - snapshot.ready = ready.value - } - - function activateFlow(slug: string) { - const snapshot = ensureSnapshot(slug) - if (activeFlowSlug.value && activeFlowSlug.value !== slug) { - persistActiveSnapshot() - } - activeFlowSlug.value = slug - tree.value = snapshot.tree - assignActiveVariables(snapshot.variables) - assignActiveFlags(snapshot.flags) - assignActiveTelemetry(snapshot.telemetry) - assignCommunicationLog(snapshot.communicationLog) - assignFlightContext(snapshot.flightContext) - setActiveStateId(snapshot.currentStateId) - ready.value = snapshot.ready - } - - function resolveFlowMode(slug: string | undefined): FlowActivationMode { - if (!slug) return 'parallel' - const system = runtimeSystem.value - if (!system) return 'parallel' - if (slug === system.main) return 'main' - const treeData = system.flows[slug] - if (!treeData) return 'parallel' - if (treeData.entry_mode === 'main') return 'main' - if (treeData.entry_mode === 'linear') return 'linear' - return 'parallel' - } - - function normalizeFlowInstruction(target: string | FlowActivationInstruction | null | undefined): FlowActivationInstruction | null { - if (!target) return null - if (typeof target === 'string') { - return { slug: target, mode: resolveFlowMode(target) } - } - if (!target.slug) return null - return { slug: target.slug, mode: target.mode ?? resolveFlowMode(target.slug) } - } - - function setActiveFlow(target: string | FlowActivationInstruction, options: { skipStack?: boolean } = {}) { - const instruction = normalizeFlowInstruction(target) - if (!instruction) { - throw new Error(`Flow snapshot not loaded: ${typeof target === 'string' ? target : target?.slug}`) - } - const slug = instruction.slug - if (!slug || !flowSnapshots[slug]) { - throw new Error(`Flow snapshot not loaded: ${slug}`) - } - const previous = activeFlowSlug.value - const shouldPush = !options.skipStack - && instruction.mode === 'linear' - && previous - && previous !== slug - if (shouldPush) { - flowStack.value.push(previous) - } - activateFlow(slug) - ready.value = true - queueMicrotask(() => evaluateAutoTransitions()) - } - const nextCandidates = computed(() => { - const s = currentState.value - if (!s) return [] - const entries = [ - ...(s.next ?? []), - ...(s.ok_next ?? []), - ...(s.bad_next ?? []), - ...(s.timer_next?.map(t => ({ to: t.to })) ?? []), - ] - return Array.from( - new Set( - entries - .map(entry => entry?.to) - .filter((id): id is string => typeof id === 'string' && id.length) - ) - ) - }) - - const isReady = computed(() => ready.value) - - function ensureTree(): RuntimeDecisionTree { - if (!tree.value) { - throw new Error('Decision tree not loaded') - } - return tree.value - } - - function resetAutoHistory(stateId: string, slug = activeFlowSlug.value) { - if (!slug) return - const snapshot = ensureSnapshot(slug) - snapshot.autoHistory.set(stateId, new Set()) - } - - function markAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value) { - if (!slug) return - const snapshot = ensureSnapshot(slug) - if (!snapshot.autoHistory.has(stateId)) { - snapshot.autoHistory.set(stateId, new Set()) - } - snapshot.autoHistory.get(stateId)!.add(transitionId) - } - - function hasAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value): boolean { - if (!slug) return false - const snapshot = ensureSnapshot(slug) - const set = snapshot.autoHistory.get(stateId) - return set ? set.has(transitionId) : false - } - - function resetEngineFromTree(treeData: RuntimeDecisionTree) { - const system: RuntimeDecisionSystem = { - main: treeData.slug, - order: [treeData.slug], - flows: { [treeData.slug]: treeData }, - } - resetEngineFromSystem(system, { activeSlug: treeData.slug }) - } - - function resetEngineFromSystem(system: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) { - runtimeSystem.value = system - const order = Array.isArray(system.order) && system.order.length - ? [...system.order] - : Object.keys(system.flows) - flowOrder.value = order - - for (const key of Object.keys(flowSnapshots)) { - delete flowSnapshots[key] - } - - for (const slug of order) { - const treeData = system.flows[slug] - if (!treeData) continue - flowSnapshots[slug] = createSnapshotFromTree(treeData) - } - - flowStack.value = [] - sessionId.value = createSessionId() - for (const slug of order) { - const snapshot = flowSnapshots[slug] - if (snapshot) { - snapshot.flags.session_id = sessionId.value - } - } - - const preferred = options.activeSlug && system.flows[options.activeSlug] - ? options.activeSlug - : system.main && system.flows[system.main] - ? system.main - : order[0] - - if (preferred) { - activateFlow(preferred) - ready.value = true - const snapshot = ensureSnapshot(preferred) - resetAutoHistory(snapshot.currentStateId, preferred) - evaluateAutoTransitions() - } else { - activeFlowSlug.value = '' - tree.value = null - ready.value = false - assignActiveVariables({}) - assignActiveFlags({ - in_air: false, - emergency_active: false, - current_unit: 'DEL', - stack: [], - off_schema_count: 0, - radio_checks_done: 0, - session_id: ensureSessionValue(), - }) - assignActiveTelemetry({ - altitude_ft: 0, - speed_kts: 0, - groundspeed_kts: 0, - vertical_speed_fpm: 0, - latitude_deg: 0, - longitude_deg: 0, - heading_deg: 0, - }) - assignCommunicationLog([]) - assignFlightContext(createDefaultFlightContext()) - setActiveStateId('') - } - } - - function loadRuntimeTree(data: RuntimeDecisionTree) { - resetEngineFromTree(data) - } - - function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) { - resetEngineFromSystem(data, options) - } - - const activeFlow = computed(() => activeFlowSlug.value) - - const mainFlowSlug = computed(() => runtimeSystem.value?.main || '') - - const availableFlows = computed(() => { - if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }> - return flowOrder.value - .filter((slug) => Boolean(runtimeSystem.value!.flows[slug])) - .map((slug) => { - const treeData = runtimeSystem.value!.flows[slug] - return { - slug, - name: treeData.name || slug, - description: treeData.description, - start: treeData.start_state, - mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'), - } - }) - }) - - async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') { - ready.value = false - const fetcher: any = (globalThis as any).$fetch - if (typeof fetcher !== 'function') { - throw new Error('Universal fetch is not available in this context') - } - const data = await fetcher('/api/decision-flows/runtime') - const activeSlug = slug && data.flows[slug] ? slug : data.main - resetEngineFromSystem(data, { activeSlug }) - } - - function normalizeComparableValue(value: any): any { - if (typeof value === 'number') return value - if (typeof value === 'boolean') return value - if (typeof value === 'string') { - const trimmed = value.trim() - const numeric = Number(trimmed) - if (!Number.isNaN(numeric)) return numeric - if (trimmed.toLowerCase() === 'true') return true - if (trimmed.toLowerCase() === 'false') return false - return trimmed.toLowerCase() - } - return value - } - - function parseComparisonValue(raw: any): any { - if (typeof raw === 'number' || typeof raw === 'boolean') return raw - if (typeof raw !== 'string') return raw - const trimmed = raw.trim() - if (!trimmed.length) return trimmed - if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) { - return trimmed.slice(1, -1) - } - const numeric = Number(trimmed) - if (!Number.isNaN(numeric)) return numeric - if (trimmed.toLowerCase() === 'true') return true - if (trimmed.toLowerCase() === 'false') return false - return trimmed - } - - function compareValues(left: any, operator: string, right: any): boolean { - const leftValue = normalizeComparableValue(left) - const rightValue = normalizeComparableValue(parseComparisonValue(right)) - switch (operator) { - case '>': - return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue > rightValue : false - case '>=': - return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue >= rightValue : false - case '<': - return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue < rightValue : false - case '<=': - return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue <= rightValue : false - case '===': - case '==': - return leftValue === rightValue - case '!==': - case '!=': - return leftValue !== rightValue - default: - return false - } - } - - function getValueByPath(path: string): any { - if (!path || typeof path !== 'string') return undefined - const segments = path.split('.').map(part => part.trim()).filter(Boolean) - if (!segments.length) return undefined - let sourceKey = segments[0] - let current: any - if (sourceKey === 'variables') { - current = variables.value - segments.shift() - } else if (sourceKey === 'flags') { - current = flags.value - segments.shift() - } else if (sourceKey === 'telemetry') { - current = telemetry.value - segments.shift() - } else { - current = variables.value - } - for (const segment of segments) { - if (current == null) return undefined - current = current[segment] - } - return current - } - - function evaluateSimpleCondition(condition: string): boolean { - const expr = condition.trim() - if (!expr) return true - const pattern = /^(variables|flags|telemetry)\.([A-Za-z0-9_.]+)\s*(===|==|!==|!=|>=|<=|>|<)\s*(.+)$/ - const match = expr.match(pattern) - if (match) { - const [, source, path, operator, rawValue] = match - const fullPath = `${source}.${path}` - const left = getValueByPath(fullPath) - return compareValues(left, operator, rawValue) - } - // Allow shorthand without namespace (defaults to variables) - const fallbackPattern = /^([A-Za-z0-9_.]+)\s*(===|==|!==|!=|>=|<=|>|<)\s*(.+)$/ - const fallback = expr.match(fallbackPattern) - if (fallback) { - const [, path, operator, rawValue] = fallback - const left = getValueByPath(path) - return compareValues(left, operator, rawValue) - } - return false - } - - function evaluateConditionExpression(expression?: string): boolean { - if (!expression || !expression.trim()) return true - const expr = expression.trim() - if (expr.includes('||')) { - return expr.split('||').some(part => evaluateConditionExpression(part.trim())) - } - if (expr.includes('&&')) { - return expr.split('&&').every(part => evaluateConditionExpression(part.trim())) - } - return evaluateSimpleCondition(expr) - } - - const activeFrequency = computed(() => { - const unit = typeof flags.value.current_unit === 'string' ? flags.value.current_unit.toUpperCase() : 'DEL' - switch (unit) { - 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 - } - }) - - // Current step for 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) { - const runtime = ensureTree() - // Set variables - const nextVariables = { - ...variables.value, - callsign: fpl.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, - taxi_route: 'A, V', - atis_freq: '118.025', - 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, - push_delay_min: 0, - surface_wind: '220/05', - speed_restriction: '210 knots', - emergency_heading: '180', - remarks: 'standard', - time_now: new Date().toISOString() - } - assignActiveVariables(nextVariables) - - // Update flight context - Object.assign(flightContext.value, { - ...variables.value, - phase: 'clearance' - }) - - const nextFlags: EngineFlags = { - ...flags.value, - in_air: false, - emergency_active: false, - current_unit: 'DEL', - stack: [], - off_schema_count: 0, - radio_checks_done: 0, - session_id: ensureSessionValue(flags.value.session_id) - } - assignActiveFlags(nextFlags) - - setActiveStateId(runtime.start_state) - assignCommunicationLog([]) - resetAutoHistory(runtime.start_state) - } - - function updateFrequencyVariables(update: Partial>) { - if (!update) return - - const sanitizedEntries = Object.entries(update) - .filter(([, value]) => typeof value === 'string' && value.trim().length) - .map(([key, value]) => [key, value!.trim()]) as [FrequencyVariableKey, string][] - - if (!sanitizedEntries.length) { - return - } - - for (const [key, value] of sanitizedEntries) { - variables.value[key] = value - } - - Object.assign(flightContext.value, Object.fromEntries(sanitizedEntries)) - } - - function buildLLMContext(pilotTranscript: string) { - const runtime = ensureTree() - const s = currentState.value - if (!s) { - throw new Error('Decision state unavailable') - } - let candidates = nextCandidates.value - .map(id => ({ id, state: states.value[id], flow: runtime.slug })) - .filter(candidate => candidate.state) - - // "Look through" auto-behavior check states (e.g. CD_READBACK_CHECK). - // If ALL candidates are check_readback/monitor states, expand them to - // their ok_next + bad_next targets so the LLM can evaluate the pilot's - // readback directly and route to the correct outcome. - const allAutoCheck = candidates.length > 0 && candidates.every(c => - c.state.auto === 'check_readback' || c.state.auto === 'monitor' - ) - if (allAutoCheck) { - const expanded: typeof candidates = [] - const seen = new Set() - for (const c of candidates) { - const targets = [ - ...(c.state.ok_next ?? []), - ...(c.state.bad_next ?? []), - ...(c.state.next ?? []), - ] - for (const t of targets) { - if (!t?.to || seen.has(t.to)) continue - seen.add(t.to) - const targetState = states.value[t.to] - if (targetState) { - expanded.push({ id: t.to, state: targetState, flow: runtime.slug }) - } - } - } - if (expanded.length > 0) { - candidates = expanded - } - } - - return { - state_id: s.id, - state: { ...s }, - candidates, - variables: { ...variables.value }, - flags: { ...flags.value }, - pilot_utterance: pilotTranscript, - tree: runtime.name, - flow_slug: runtime.slug, - } - } - - function applyLLMDecision(decision: any, trace?: LLMDecisionTrace | null) { - if (!decision || typeof decision !== 'object') { - lastDecisionTrace.value = null - return - } - - lastDecisionTrace.value = trace ?? null - - if (decision.updates && typeof decision.updates === 'object') { - Object.assign(variables.value, decision.updates) - } - - if (decision.flags && typeof decision.flags === 'object') { - Object.assign(flags.value, decision.flags) - } - - if (decision.telemetry && typeof decision.telemetry === 'object') { - updateTelemetry(decision.telemetry) - } - - if (Array.isArray(decision.stack)) { - flags.value.stack = decision.stack.slice() - } - - if (decision.activate_flow) { - const activation = normalizeFlowInstruction(decision.activate_flow as any) - if (activation && (activation.slug !== activeFlowSlug.value || activation.mode === 'main')) { - try { - setActiveFlow(activation) - } catch (err) { - console.warn('[Engine] Failed to activate flow from decision', err) - } - } - } - - 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}`) - } - - if (decision.controller_say_tpl) { - speak('atc', decision.controller_say_tpl, currentStateId.value, { - radioCheck: decision.radio_check, - offSchema: decision.off_schema - }) - } - - const resumeFlow = decision.resume_previous === true - const nextState = typeof decision.next_state === 'string' - ? decision.next_state - : typeof decision.nextState === 'string' - ? decision.nextState - : null - - if (resumeFlow) { - const resumed = resumeLinearFlow() - if (!resumed) { - resumeStackedState() - } - } else if (!decision.radio_check && nextState) { - moveTo(nextState) - } - - if (!decision.radio_check) { - queueMicrotask(() => evaluateAutoTransitions()) - } - } - - function processPilotTransmission(transcript: string): string | null { - if (!ready.value) { - return null - } - speak('pilot', transcript, currentStateId.value) - return null - } - - function processUserTransmission(transcript: string): string | null { - return processPilotTransmission(transcript) - } - - /** - * After a decision lands on a state, walk forward through all non-pilot - * states (ATC replies, system checks, handoffs) collecting ATC messages - * that need TTS playback, until we reach the next pilot state. - * - * This is the core auto-advance mechanism for the live ATC flow. - * It handles: - * - ATC states with say_tpl (collect for TTS, advance) - * - System states (check_readback, monitor, etc) — advance via ok_next - * - Handoff states (advance automatically) - * - States with single unambiguous transitions - */ - function collectAtcStatesUntilPilotTurn(maxHops = 30): Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> { - const messages: Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> = [] - const visited = new Set() - let hops = 0 - - while (hops++ < maxHops) { - const s = currentState.value - if (!s) break - - // Prevent infinite loops - if (visited.has(s.id)) break - visited.add(s.id) - - // If we're on a pilot state, stop — it's the pilot's turn to speak - if (s.role === 'pilot') break - - // If this is an end state, stop - const endStates = tree.value?.end_states ?? [] - if (endStates.includes(s.id)) break - - // Collect ATC/system messages for TTS - if (s.say_tpl) { - messages.push({ - stateId: s.id, - say_tpl: s.say_tpl, - rendered: renderTpl(s.say_tpl, exposeCtx()), - normalized: normalizeATCText(s.say_tpl, exposeCtxFlat()), - }) - } - - // Determine the next state to advance to. - // Strategy: - // 1. For auto-behavior states (check_readback, monitor, pop_stack), - // prefer ok_next (assume success for auto-advance) - // 2. For states with a single eligible transition, take it - // 3. For ambiguous states (multiple eligible), stop - let nextId: string | null = null - - const auto = s.auto - if (auto === 'check_readback' || auto === 'monitor' || auto === 'pop_stack_or_route_by_intent') { - // Auto-behavior states: prefer ok_next, then next - const okTransitions = (s.ok_next ?? []).filter(t => { - if (!t?.to) return false - if (t.when && !evaluateConditionExpression(t.when)) return false - if (t.guard && !evaluateConditionExpression(t.guard)) return false - return true - }) - if (okTransitions.length > 0) { - nextId = okTransitions[0].to - } - } - - if (!nextId) { - // Collect all eligible transitions - const allTransitions = [ - ...(s.next ?? []), - ...(s.ok_next ?? []), - ] as Array<{ to?: string; when?: string; guard?: string }> - - const eligible = allTransitions.filter(t => { - if (!t?.to) return false - if (t.when && !evaluateConditionExpression(t.when)) return false - if (t.guard && !evaluateConditionExpression(t.guard)) return false - return true - }) - - // Only advance if there's exactly one unambiguous path - if (eligible.length === 1) { - nextId = eligible[0].to! - } - } - - if (!nextId || !states.value[nextId]) break - - // Advance to the next state (moveTo handles logging, actions, handoffs) - moveTo(nextId) - } - - return messages - } - - /** - * Computed: expected pilot phrases for the current state. - * If we're on a pilot state, returns that state's utterance_tpl rendered. - * If we're on an ATC state, looks at the next candidates that are pilot states. - */ - const expectedPilotPhrases = computed>(() => { - const s = currentState.value - if (!s) return [] - - // If current state IS a pilot state with utterance_tpl, show it - if (s.role === 'pilot' && s.utterance_tpl) { - return [{ - stateId: s.id, - text: renderTpl(s.utterance_tpl, exposeCtx()), - normalized: normalizeATCText(s.utterance_tpl, exposeCtxFlat()), - }] - } - - // Otherwise look at next candidates that are pilot states - const results: Array<{ stateId: string; text: string; normalized: string }> = [] - for (const id of nextCandidates.value) { - const state = states.value[id] - if (!state) continue - if (state.role === 'pilot' && state.utterance_tpl) { - results.push({ - stateId: id, - text: renderTpl(state.utterance_tpl, exposeCtx()), - normalized: normalizeATCText(state.utterance_tpl, exposeCtxFlat()), - }) - } - } - return results - }) - - function resolveTelemetryValue(parameter: string) { - const value = (telemetry.value as any)[parameter] - if (value !== undefined) return value - return (variables.value as any)[parameter] - } - - function updateTelemetry(update: Partial | null | undefined) { - if (!update || typeof update !== 'object') { - return - } - const next: TelemetryState = { ...telemetry.value } - for (const [key, raw] of Object.entries(update)) { - if (raw === undefined || raw === null) continue - const current = telemetry.value[key] - if (typeof current === 'number') { - const numeric = typeof raw === 'number' ? raw : Number(raw) - if (!Number.isNaN(numeric)) { - next[key] = numeric - continue - } - } - const fallback = typeof raw === 'number' ? raw : Number(raw) - next[key] = Number.isNaN(fallback) ? current : fallback - } - assignActiveTelemetry(next) - queueMicrotask(() => evaluateAutoTransitions()) - } - - function evaluateAutoTrigger(trigger: DecisionNodeAutoTrigger | null | undefined): boolean { - if (!trigger) return false - if (trigger.type === 'expression') { - return evaluateConditionExpression(trigger.expression) - } - if (trigger.type === 'telemetry') { - if (!trigger.parameter || !trigger.operator) return false - const left = resolveTelemetryValue(trigger.parameter) - return compareValues(left, trigger.operator, trigger.value) - } - if (trigger.type === 'variable') { - const target = trigger.variable ? getValueByPath(trigger.variable) : undefined - if (trigger.operator) { - return compareValues(target, trigger.operator, trigger.value) - } - return Boolean(target) - } - return false - } - - let pendingSimpleAutoTransition: { from: string; to: string } | null = null - - function evaluateSimpleAutoFlow(loopGuard = 0) { - if (!ready.value || loopGuard > 20) return - if (pendingSimpleAutoTransition?.from === currentStateId.value) { - return - } - - const state = currentState.value - if (!state) return - if (state.role === 'atc' && state.say_tpl) return - - const transitions = [ - ...(state.next ?? []), - ...(state.ok_next ?? []), - ...(state.bad_next ?? []), - ] as Array<{ to?: string, guard?: string, when?: string, type?: string | null }> - - const eligible = transitions.filter(candidate => { - if (!candidate || typeof candidate.to !== 'string' || !candidate.to.length) { - return false - } - if (candidate.type === 'auto') { - return false - } - if (candidate.when && !evaluateConditionExpression(candidate.when)) { - return false - } - if (candidate.guard && !evaluateConditionExpression(candidate.guard)) { - return false - } - return true - }) - - if (eligible.length !== 1) return - - const targetId = eligible[0].to! - const targetState = states.value[targetId] - if (!targetState) return - - const silentSystemHop = targetState.role === 'system' && !targetState.say_tpl - const delay = silentSystemHop - ? 50 - : Math.floor(Math.random() * 1000) + 1000 - - const fromStateId = currentStateId.value - pendingSimpleAutoTransition = { from: fromStateId, to: targetId } - setTimeout(() => { - pendingSimpleAutoTransition = null - if (currentStateId.value !== fromStateId) { - return - } - moveTo(targetId) - evaluateSimpleAutoFlow(loopGuard + 1) - }, delay) - } - - function evaluateAutoTransitions(loopGuard = 0) { - if (!ready.value || loopGuard > 8) return - const state = currentState.value - if (!state) return - - const autoTransitions = state.auto_transitions ?? [] - - for (const transition of autoTransitions) { - if (!transition || !transition.trigger) continue - if (transition.trigger.once !== false && hasAutoExecuted(state.id, transition.id)) { - continue - } - if (transition.condition && !evaluateConditionExpression(transition.condition)) { - continue - } - if (transition.guard && !evaluateConditionExpression(transition.guard)) { - continue - } - if (!evaluateAutoTrigger(transition.trigger)) { - continue - } - markAutoExecuted(state.id, transition.id) - const delay = transition.trigger.delayMs ?? 0 - if (delay > 0) { - setTimeout(() => { - moveTo(transition.to) - evaluateAutoTransitions(loopGuard + 1) - }, delay) - } else { - moveTo(transition.to) - evaluateAutoTransitions(loopGuard + 1) - } - return - } - - evaluateSimpleAutoFlow(loopGuard + 1) - } - - function moveTo(stateId: string) { - ensureTree() - if (!states.value[stateId]) { - console.warn(`[Engine] Unknown state: ${stateId}`) - return - } - - if (stateId.startsWith('INT_')) { - flags.value.stack.push(currentStateId.value) - } - - setActiveStateId(stateId) - resetAutoHistory(stateId) - const s = currentState.value - if (!s) return - - // Execute actions - 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, telemetry: telemetry.value }, act.set, act.to) - } - } - - // Handoff - if (s.handoff?.to) { - flags.value.current_unit = unitFromHandoff(s.handoff.to) - if (s.handoff.freq) { - variables.value.handoff_freq = renderTpl(s.handoff.freq, exposeCtx()) - } - } - - // Auto-Say - if (s.say_tpl) { - speak(s.role, s.say_tpl, s.id!) - } - - // Update flight context phase - updateFlightPhase(s.phase) - queueMicrotask(() => evaluateAutoTransitions()) - } - - function updateFlightPhase(phase: Phase) { - const phaseMap: Record = { - '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] - } - } - - function resumeStackedState() { - const prev = flags.value.stack.pop() - if (prev) moveTo(prev) - } - - function resumeLinearFlow(): boolean { - const previousFlow = flowStack.value.pop() - if (previousFlow) { - try { - setActiveFlow({ slug: previousFlow, mode: resolveFlowMode(previousFlow) }, { skipStack: true }) - return true - } catch (err) { - console.warn('[Engine] Failed to resume linear flow', err) - } - } else if (mainFlowSlug.value && activeFlowSlug.value !== mainFlowSlug.value) { - try { - setActiveFlow({ slug: mainFlowSlug.value, mode: 'main' }, { skipStack: true }) - return true - } catch (err) { - console.warn('[Engine] Failed to restore main flow', err) - } - } - return false - } - - function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) { - const msg = renderTpl(tpl, exposeCtx()) - const entry: EngineLog = { - timestamp: new Date(), - frequency: activeFrequency.value, - speaker, - message: msg, - normalized: normalizeATCText(msg, exposeCtxFlat()), - state: stateId, - radioCheck: options.radioCheck, - offSchema: options.offSchema, - flow: activeFlowSlug.value || undefined, - } - communicationLog.value.push(entry) - } - - function renderATCMessage(tpl: string) { - return renderTpl(tpl, exposeCtx()) - } - - function exposeCtx() { - return { - ...variables.value, - ...flags.value, - variables: variables.value, - flags: flags.value, - telemetry: telemetry.value, - } - } - - function exposeCtxFlat() { - return { ...variables.value, ...flags.value, ...telemetry.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 getStateDetails(stateId: string) { - const s = states.value[stateId] - if (!s) return null - return { ...s, id: stateId } - } - - 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)] - } - - function safeEvalBoolean(expr?: string): boolean { - return evaluateConditionExpression(expr) - } - - function setByPath(root: Record, path: string, val: any) { - const parts = path.split('.').map(part => part.trim()).filter(Boolean) - if (!parts.length) return - let cur = root - for (let i = 0; i < parts.length - 1; i++) { - const p = parts[i] - if (!(p in cur) || typeof cur[p] !== 'object') { - cur[p] = {} - } - cur = cur[p] - } - cur[parts[parts.length - 1]] = val - } - - return { - // State - currentState: computed(() => currentState.value), - currentStateId: readonly(currentStateId), - variables: readonly(variables), - flags: readonly(flags), - telemetry: readonly(telemetry), - nextCandidates, - activeFrequency, - communicationLog: readonly(communicationLog), - clearCommunicationLog: () => { assignCommunicationLog([]) }, - activeFlow, - availableFlows, - sessionId: readonly(sessionId), - lastDecisionTrace: readonly(lastDecisionTrace), - - // pm_alt.vue integration - flightContext: readonly(flightContext), - currentStep, - - // Lifecycle - initializeFlight, - updateFrequencyVariables, - loadRuntimeTree, - loadRuntimeSystem, - fetchRuntimeTree, - setActiveFlow, - isReady, - - // Communication - processPilotTransmission, - processUserTransmission, - buildLLMContext, - applyLLMDecision, - collectAtcStatesUntilPilotTurn, - expectedPilotPhrases, - - // Flow Control - moveTo, - - // Utilities - normalizeATCText, - renderATCMessage, - getStateDetails, - updateTelemetry, - } -} diff --git a/shared/utils/openaiDecision.ts b/shared/utils/openaiDecision.ts deleted file mode 100644 index e36f857..0000000 --- a/shared/utils/openaiDecision.ts +++ /dev/null @@ -1,10 +0,0 @@ -// utils/openaiDecision.ts -import type { LLMDecisionInput, LLMDecisionResult } from '../types/llm' - -/** Client-seitig: ruft den Backend-Endpunkt auf */ -export async function decideNextStateLLM(input: LLMDecisionInput): Promise { - return await $fetch('/api/llm/decide', { - method: 'POST', - body: input - }) -}