From cf6748b9bc046d0068fdfd039e334341f446fe41 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sat, 20 Sep 2025 18:33:32 +0200 Subject: [PATCH] Build decision flow editor and runtime integration --- app/components/editor/DecisionNodeCanvas.vue | 459 ++++++ app/pages/editor/index.vue | 1317 +++++++++++++++++ app/pages/pm.vue | 21 + package.json | 6 +- scripts/import-decision-tree.ts | 42 + .../api/decision-flows/[slug]/runtime.get.ts | 12 + server/api/editor/flows.get.ts | 8 + server/api/editor/flows.post.ts | 71 + server/api/editor/flows/[slug]/index.get.ts | 14 + server/api/editor/flows/[slug]/index.put.ts | 150 ++ server/api/editor/flows/[slug]/nodes.post.ts | 101 ++ .../[slug]/nodes/[stateId]/index.delete.ts | 31 + .../flows/[slug]/nodes/[stateId]/index.put.ts | 137 ++ .../[slug]/nodes/[stateId]/layout.patch.ts | 42 + server/api/editor/flows/import.post.ts | 19 + server/models/DecisionFlow.ts | 103 ++ server/models/DecisionNode.ts | 178 +++ server/services/decisionFlowService.ts | 203 +++ server/services/decisionImportService.ts | 197 +++ server/utils/decisionSanitizer.ts | 258 ++++ shared/types/decision.ts | 228 +++ shared/utils/communicationsEngine.ts | 528 +++++-- tsconfig.scripts.json | 20 + 23 files changed, 4030 insertions(+), 115 deletions(-) create mode 100644 app/components/editor/DecisionNodeCanvas.vue create mode 100644 app/pages/editor/index.vue create mode 100644 scripts/import-decision-tree.ts create mode 100644 server/api/decision-flows/[slug]/runtime.get.ts create mode 100644 server/api/editor/flows.get.ts create mode 100644 server/api/editor/flows.post.ts create mode 100644 server/api/editor/flows/[slug]/index.get.ts create mode 100644 server/api/editor/flows/[slug]/index.put.ts create mode 100644 server/api/editor/flows/[slug]/nodes.post.ts create mode 100644 server/api/editor/flows/[slug]/nodes/[stateId]/index.delete.ts create mode 100644 server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts create mode 100644 server/api/editor/flows/[slug]/nodes/[stateId]/layout.patch.ts create mode 100644 server/api/editor/flows/import.post.ts create mode 100644 server/models/DecisionFlow.ts create mode 100644 server/models/DecisionNode.ts create mode 100644 server/services/decisionFlowService.ts create mode 100644 server/services/decisionImportService.ts create mode 100644 server/utils/decisionSanitizer.ts create mode 100644 shared/types/decision.ts create mode 100644 tsconfig.scripts.json diff --git a/app/components/editor/DecisionNodeCanvas.vue b/app/components/editor/DecisionNodeCanvas.vue new file mode 100644 index 0000000..1926b61 --- /dev/null +++ b/app/components/editor/DecisionNodeCanvas.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue new file mode 100644 index 0000000..f213b03 --- /dev/null +++ b/app/pages/editor/index.vue @@ -0,0 +1,1317 @@ + + + + + diff --git a/app/pages/pm.vue b/app/pages/pm.vue index e037662..3a9d9a2 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -955,6 +955,8 @@ const { currentStep, initializeFlight, updateFrequencyVariables, + fetchRuntimeTree, + isReady: engineReady, processPilotTransmission, buildLLMContext, applyLLMDecision, @@ -1069,6 +1071,14 @@ onMounted(async () => { }) } + try { + await fetchRuntimeTree() + } catch (err) { + console.error('Failed to load decision tree runtime', err) + error.value = 'Decision engine konnte nicht initialisiert werden.' + return + } + if (typeof window !== 'undefined') { const storedVatsimId = window.localStorage.getItem(STORAGE_KEYS.vatsimId) if (storedVatsimId) { @@ -1606,6 +1616,17 @@ const loadFlightPlans = async () => { } const startMonitoring = async (flightPlan: any) => { + try { + if (!engineReady.value) { + await fetchRuntimeTree() + } + } catch (err) { + console.error('Failed to prepare decision engine', err) + error.value = 'Entscheidungsbaum konnte nicht geladen werden.' + return + } + + error.value = '' selectedPlan.value = flightPlan initializeFlight(flightPlan) currentScreen.value = 'monitor' diff --git a/package.json b/package.json index a3875c3..fc499bc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", - "postinstall": "nuxt prepare" + "postinstall": "nuxt prepare", + "import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts" }, "dependencies": { "@nuxt/image": "1.11.0", @@ -34,6 +35,7 @@ }, "packageManager": "yarn@4.9.4", "devDependencies": { - "@types/fluent-ffmpeg": "^2" + "@types/fluent-ffmpeg": "^2", + "tsx": "^4.15.1" } } diff --git a/scripts/import-decision-tree.ts b/scripts/import-decision-tree.ts new file mode 100644 index 0000000..56af60d --- /dev/null +++ b/scripts/import-decision-tree.ts @@ -0,0 +1,42 @@ +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/decision-flows/[slug]/runtime.get.ts b/server/api/decision-flows/[slug]/runtime.get.ts new file mode 100644 index 0000000..5e035de --- /dev/null +++ b/server/api/decision-flows/[slug]/runtime.get.ts @@ -0,0 +1,12 @@ +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/editor/flows.get.ts b/server/api/editor/flows.get.ts new file mode 100644 index 0000000..8372ea9 --- /dev/null +++ b/server/api/editor/flows.get.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..315431d --- /dev/null +++ b/server/api/editor/flows.post.ts @@ -0,0 +1,71 @@ +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 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, + }) + + await flow.save() + + 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 new file mode 100644 index 0000000..475a4b3 --- /dev/null +++ b/server/api/editor/flows/[slug]/index.get.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..a9326c8 --- /dev/null +++ b/server/api/editor/flows/[slug]/index.put.ts @@ -0,0 +1,150 @@ +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 (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() + + 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 new file mode 100644 index 0000000..7b40ce9 --- /dev/null +++ b/server/api/editor/flows/[slug]/nodes.post.ts @@ -0,0 +1,101 @@ +import { createError, readBody } from 'h3' +import { requireAdmin } from '../../../../utils/auth' +import { DecisionFlow } from '../../../../models/DecisionFlow' +import { DecisionNode } from '../../../../models/DecisionNode' +import { + sanitizeLayout, + sanitizeLLMTemplate, + sanitizeMetadata, + sanitizeTransition, +} from '../../../../utils/decisionSanitizer' +import { serializeNodeDocument } from '../../../../services/decisionFlowService' + +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' }) + } + + const transitions = Array.isArray(body.transitions) + ? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index)) + : [] + + 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, + 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 new file mode 100644 index 0000000..b1fd66d --- /dev/null +++ b/server/api/editor/flows/[slug]/nodes/[stateId]/index.delete.ts @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..21008ef --- /dev/null +++ b/server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts @@ -0,0 +1,137 @@ +import { createError, readBody } from 'h3' +import { requireAdmin } from '../../../../../../utils/auth' +import { DecisionFlow } from '../../../../../../models/DecisionFlow' +import { DecisionNode } from '../../../../../../models/DecisionNode' +import { + sanitizeLayout, + sanitizeLLMTemplate, + sanitizeMetadata, + sanitizeTransition, +} from '../../../../../../utils/decisionSanitizer' +import { serializeNodeDocument } from '../../../../../../services/decisionFlowService' + +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)) { + node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index)) + } + + 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 new file mode 100644 index 0000000..bda5ff9 --- /dev/null +++ b/server/api/editor/flows/[slug]/nodes/[stateId]/layout.patch.ts @@ -0,0 +1,42 @@ +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/import.post.ts b/server/api/editor/flows/import.post.ts new file mode 100644 index 0000000..eabe9f9 --- /dev/null +++ b/server/api/editor/flows/import.post.ts @@ -0,0 +1,19 @@ +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/models/DecisionFlow.ts b/server/models/DecisionFlow.ts new file mode 100644 index 0000000..7e06b9c --- /dev/null +++ b/server/models/DecisionFlow.ts @@ -0,0 +1,103 @@ +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 + 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: () => [] }, + 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 new file mode 100644 index 0000000..9fb3dcf --- /dev/null +++ b/server/models/DecisionNode.ts @@ -0,0 +1,178 @@ +import mongoose from 'mongoose' +import type { + DecisionNodeAutoTrigger, + DecisionNodeLayout, + DecisionNodeLLMPlaceholder, + DecisionNodeLLMTemplate, + DecisionNodeMetadata, + DecisionNodeModel, + 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 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 }, + 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 new file mode 100644 index 0000000..068028e --- /dev/null +++ b/server/services/decisionFlowService.ts @@ -0,0 +1,203 @@ +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, +} 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, + } +} + +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, + 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(), + })) +} + +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, + 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), + metadata: obj.metadata || undefined, + } +} + +export async function buildRuntimeDecisionTree(slug: string): Promise { + 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 }) + const states = nodes.reduce>((acc, node) => { + acc[node.stateId] = serializeRuntimeState(node) + return acc + }, {}) + + return { + schema_version: flowDoc.schemaVersion || '1.0', + 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, + } +} diff --git a/server/services/decisionImportService.ts b/server/services/decisionImportService.ts new file mode 100644 index 0000000..b42a225 --- /dev/null +++ b/server/services/decisionImportService.ts @@ -0,0 +1,197 @@ +import { randomUUID } from 'node:crypto' +import atcDecisionTree from '~~/shared/data/atcDecisionTree' +import { DecisionFlow } from '../models/DecisionFlow' +import { DecisionNode } from '../models/DecisionNode' +import type { DecisionNodeTransition } from '~~/shared/types/decision' +import { getFlowWithNodes } from './decisionFlowService' + +interface LegacyTransition { + to?: string + when?: string + label?: string + condition?: string + guard?: string + after_s?: number + allowManualProceed?: boolean + description?: string +} + +export interface ImportDecisionTreeOptions { + slug?: string + name?: string + description?: string +} + +const ROLE_COLORS: Record = { + pilot: '#0ea5e9', + atc: '#22d3ee', + system: '#f59e0b', +} + +function createTransition( + type: DecisionNodeTransition['type'], + data: LegacyTransition, + order: number +): DecisionNodeTransition | null { + if (!data || typeof data !== 'object') return null + const target = typeof data.to === 'string' ? data.to.trim() : '' + if (!target) return null + + const transition: DecisionNodeTransition = { + key: `${type}_${randomUUID().slice(0, 8)}`, + type, + target, + order, + } + + const label = typeof data.label === 'string' ? data.label.trim() : undefined + if (label) transition.label = label + + const condition = + typeof data.when === 'string' + ? data.when.trim() + : typeof data.condition === 'string' + ? data.condition.trim() + : undefined + if (condition) transition.condition = condition + + const guard = typeof data.guard === 'string' ? data.guard.trim() : undefined + if (guard) transition.guard = guard + + const description = typeof data.description === 'string' ? data.description.trim() : undefined + if (description) transition.description = description + + if (type === 'timer') { + const after = typeof data.after_s === 'number' ? data.after_s : Number(data.after_s) + if (Number.isFinite(after)) { + transition.timer = { + afterSeconds: Number(after), + allowManualProceed: data.allowManualProceed !== false, + } + } + } + + return transition +} + +export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) { + const slug = typeof options.slug === 'string' && options.slug.trim().length + ? options.slug.trim() + : atcDecisionTree.name || 'icao_atc_decision_tree' + + const name = typeof options.name === 'string' && options.name.trim().length + ? options.name.trim() + : 'ATC Decision Tree' + + const existingFlow = await DecisionFlow.findOne({ slug }) + const flow = existingFlow || new DecisionFlow({ slug, name }) + + flow.name = name + flow.description = options.description ?? atcDecisionTree.description ?? flow.description + flow.schemaVersion = atcDecisionTree.schema_version || '1.0' + flow.startState = atcDecisionTree.start_state + flow.endStates = Array.isArray(atcDecisionTree.end_states) ? atcDecisionTree.end_states : [] + flow.variables = atcDecisionTree.variables || {} + flow.flags = atcDecisionTree.flags || {} + flow.policies = atcDecisionTree.policies || {} + flow.hooks = atcDecisionTree.hooks || {} + flow.roles = Array.isArray(atcDecisionTree.roles) ? atcDecisionTree.roles : flow.roles + flow.phases = Array.isArray(atcDecisionTree.phases) ? atcDecisionTree.phases : flow.phases + flow.layout = flow.layout || { zoom: 0.9, pan: { x: 0, y: 0 }, groups: [] } + + await flow.save() + + await DecisionNode.deleteMany({ flow: flow._id }) + + const phases = Array.isArray(flow.phases) && flow.phases.length ? flow.phases : ['General'] + const phaseColumns = new Map() + phases.forEach((phase, index) => phaseColumns.set(phase, index)) + const phaseRowCounters = new Map() + + const stateEntries = Object.entries(atcDecisionTree.states || {}) + const nodesToInsert = stateEntries.map(([stateId, state]) => { + const role = typeof state.role === 'string' ? state.role : 'system' + const phase = typeof state.phase === 'string' ? state.phase : 'General' + + const columnIndex = phaseColumns.has(phase) ? phaseColumns.get(phase)! : phaseColumns.size + if (!phaseColumns.has(phase)) { + phaseColumns.set(phase, columnIndex) + } + const rowIndex = phaseRowCounters.get(phase) || 0 + phaseRowCounters.set(phase, rowIndex + 1) + + const transitions: DecisionNodeTransition[] = [] + let order = 0 + + if (Array.isArray(state.next)) { + for (const entry of state.next as LegacyTransition[]) { + const transition = createTransition('next', entry, order++) + if (transition) transitions.push(transition) + } + } + + if (Array.isArray(state.ok_next)) { + for (const entry of state.ok_next as LegacyTransition[]) { + const transition = createTransition('ok', entry, order++) + if (transition) transitions.push(transition) + } + } + + if (Array.isArray(state.bad_next)) { + for (const entry of state.bad_next as LegacyTransition[]) { + const transition = createTransition('bad', entry, order++) + if (transition) transitions.push(transition) + } + } + + if (Array.isArray(state.timer_next)) { + for (const entry of state.timer_next as LegacyTransition[]) { + const transition = createTransition('timer', entry, order++) + if (transition) transitions.push(transition) + } + } + + const readbackRequired = Array.isArray(state.readback_required) + ? state.readback_required.filter((item: any) => typeof item === 'string' && item.trim().length) + : [] + + const layout = { + x: columnIndex * 340, + y: rowIndex * 220, + color: ROLE_COLORS[role] || '#38bdf8', + } + + return { + flow: flow._id, + stateId, + title: typeof state.title === 'string' ? state.title.trim() || undefined : undefined, + summary: typeof state.summary === 'string' ? state.summary.trim() || undefined : undefined, + role, + phase, + sayTemplate: typeof state.say_tpl === 'string' ? state.say_tpl : undefined, + utteranceTemplate: typeof state.utterance_tpl === 'string' ? state.utterance_tpl : undefined, + elseSayTemplate: typeof state.else_say_tpl === 'string' ? state.else_say_tpl : undefined, + readbackRequired, + autoBehavior: typeof state.auto === 'string' ? state.auto : undefined, + actions: Array.isArray(state.actions) ? state.actions : [], + handoff: state.handoff && typeof state.handoff === 'object' ? state.handoff : undefined, + guard: typeof state.guard === 'string' ? state.guard : undefined, + trigger: typeof state.trigger === 'string' ? state.trigger : undefined, + frequency: typeof state.frequency === 'string' ? state.frequency : undefined, + frequencyName: typeof state.frequencyName === 'string' ? state.frequencyName : undefined, + transitions, + layout, + } + }) + + await DecisionNode.insertMany(nodesToInsert) + + const { flow: serializedFlow, nodes } = await getFlowWithNodes(slug) + + return { + flow: serializedFlow, + nodes, + importedStates: nodes.length, + } +} + diff --git a/server/utils/decisionSanitizer.ts b/server/utils/decisionSanitizer.ts new file mode 100644 index 0000000..18f148e --- /dev/null +++ b/server/utils/decisionSanitizer.ts @@ -0,0 +1,258 @@ +import { randomUUID } from 'node:crypto' +import type { + DecisionNodeAutoTrigger, + DecisionNodeLayout, + DecisionNodeLLMPlaceholder, + DecisionNodeLLMTemplate, + DecisionNodeMetadata, + 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 COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!=']) + +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 +} + +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 { + if (!raw || typeof raw !== 'object') return undefined + const type = asTrimmedString(raw.type) + if (!type || !AUTO_TRIGGER_TYPES.has(type)) { + throw new Error('Invalid auto trigger type') + } + const trigger: DecisionNodeAutoTrigger = { + id: asTrimmedString(raw.id) || `auto_${randomUUID()}`, + type: type as DecisionNodeAutoTrigger['type'], + } + + if (type === 'expression') { + const expression = asTrimmedString(raw.expression) + if (!expression) { + throw new Error('Expression trigger requires an expression') + } + trigger.expression = expression + } else if (type === 'telemetry') { + const parameter = asTrimmedString(raw.parameter) + if (!parameter) { + throw new Error('Telemetry trigger requires a parameter') + } + trigger.parameter = parameter as DecisionNodeAutoTrigger['parameter'] + const operator = asTrimmedString(raw.operator) + if (!operator || !COMPARISON_OPERATORS.has(operator)) { + throw new Error('Telemetry trigger requires a valid operator') + } + trigger.operator = operator as DecisionNodeAutoTrigger['operator'] + const value = raw.value !== undefined ? raw.value : undefined + if (value === undefined) { + throw new Error('Telemetry trigger requires a value') + } + const numericValue = asNumber(value) + trigger.value = numericValue !== undefined ? numericValue : value + const unit = asTrimmedString(raw.unit) + if (unit) trigger.unit = unit + } else if (type === 'variable') { + const variable = asTrimmedString(raw.variable) + if (!variable) { + throw new Error('Variable trigger requires a variable path') + } + trigger.variable = variable + const operator = asTrimmedString(raw.operator) + if (!operator || !COMPARISON_OPERATORS.has(operator)) { + throw new Error('Variable trigger requires a valid operator') + } + trigger.operator = operator as DecisionNodeAutoTrigger['operator'] + const value = raw.value !== undefined ? raw.value : undefined + if (value === undefined) { + throw new Error('Variable trigger requires a value') + } + const numericValue = asNumber(value) + trigger.value = numericValue !== undefined ? numericValue : value + } + + if (raw.once !== undefined) { + trigger.once = asBoolean(raw.once, true) + } + const delayMs = asNumber(raw.delayMs) + if (typeof delayMs === 'number') trigger.delayMs = delayMs + const description = asTrimmedString(raw.description) + if (description) trigger.description = description + return trigger +} + +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/shared/types/decision.ts b/shared/types/decision.ts new file mode 100644 index 0000000..a5d5f31 --- /dev/null +++ b/shared/types/decision.ts @@ -0,0 +1,228 @@ +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 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 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 + 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 +} + +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 + 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[] + metadata?: DecisionNodeMetadata +} + +export interface RuntimeDecisionTree { + 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 +} + +export interface DecisionFlowSummary { + id: string + slug: string + name: string + description?: string + startState: string + nodeCount: number + updatedAt: string + createdAt: string +} diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 9a800cb..bac5a37 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,71 +1,25 @@ // communicationsEngine composable import { ref, computed, readonly } from 'vue' -import atcDecisionTree from "../data/atcDecisionTree"; +import type { + RuntimeDecisionTree, + RuntimeDecisionState, + RuntimeDecisionAutoTransition, + DecisionNodeAutoTrigger, +} from '../types/decision' import { normalizeRadioPhrase } from './radioSpeech' -// --- DecisionTree types (derived from ~/data/atcDecisionTree.json) --- +// --- DecisionTree runtime types --- type Role = 'pilot' | 'atc' | 'system' -type Phase = - | 'Preflight' | 'Clearance' | 'PushStart' | 'TaxiOut' | 'Departure' - | 'Climb' | 'Enroute' | 'Descent' | 'Approach' | 'Landing' - | 'TaxiIn' | 'Postflight' | 'Interrupt' | 'LostComms' | 'Missed' +type Phase = string -interface DTNext { to: string; when?: string } -interface DTHandoff { to: string; freq: string } -interface DTActionSet { set?: string; to?: any; if?: string } - -interface DTState { - id?: string - role: Role - phase: Phase - say_tpl?: string - utterance_tpl?: string - else_say_tpl?: string - next?: DTNext[] - ok_next?: DTNext[] - bad_next?: DTNext[] - timer_next?: { after_s: number; to: string }[] - handoff?: DTHandoff - condition?: string - guard?: string - trigger?: string - auto?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent' - readback_required?: string[] - actions?: (DTActionSet | string)[] - frequency?: string // Shortcut for accessing frequencies - frequencyName?: string // Frequency label (DEL, GND, TWR, etc.) -} - -interface DecisionTree { - schema_version: string - name: string - description: string - start_state: string - end_states: string[] - variables: Record - flags: { - in_air: boolean - emergency_active: boolean - current_unit: string - stack: string[] - off_schema_count: number // Counter for off-schema responses - radio_checks_done: number // Counter for radio checks - } - policies: { - timeouts: { - pilot_readback_timeout_s: number - controller_ack_timeout_s: number - no_reply_retry_after_s: number - no_reply_max_retries: number - lost_comms_detect_after_s: number - } - no_reply_sequence: { after_s: number; controller_say_tpl: string }[] - interrupts_allowed_when: Record - } - hooks: Record - roles: Role[] - phases: Phase[] - states: Record +interface EngineFlags { + in_air: boolean + emergency_active: boolean + current_unit: string + stack: string[] + off_schema_count: number + radio_checks_done: number + [key: string]: any } // Flight phases and communication steps for better integration @@ -141,6 +95,17 @@ export interface EngineLog { offSchema?: 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 +} + export function normalizeATCText(text: string, context: Record): string { const rendered = renderTpl(text, context) return normalizeRadioPhrase(rendered) @@ -156,21 +121,34 @@ function renderTpl(tpl: string, ctx: Record): string { } export default function useCommunicationsEngine() { - const tree = ref({ - ...atcDecisionTree as DecisionTree, - flags: { - ...atcDecisionTree.flags, - off_schema_count: 0, - radio_checks_done: 0 - } + const tree = ref(null) + const ready = ref(false) + + 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, }) - const states = computed>(() => tree.value.states) - - const variables = ref>({ ...tree.value.variables }) - const flags = ref({ ...tree.value.flags }) - const currentStateId = ref(tree.value.start_state) - const communicationLog = ref([]) + const autoExecutionHistory = new Map>() // Flight context used for pm_alt.vue integration const flightContext = ref({ @@ -196,24 +174,221 @@ export default function useCommunicationsEngine() { phase: 'clearance' }) - const currentState = computed(() => { - const s = states.value[currentStateId.value] || states.value[tree.value.start_state] - return { ...s, id: currentStateId.value } + 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 }) const nextCandidates = computed(() => { const s = currentState.value - const nxt = [ + if (!s) return [] + const entries = [ ...(s.next ?? []), ...(s.ok_next ?? []), ...(s.bad_next ?? []), - ...(s.timer_next?.map(t => ({ to: t.to })) ?? []) - ].map(x => x.to) - return Array.from(new Set(nxt)) + ...(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) { + autoExecutionHistory.set(stateId, new Set()) + } + + function markAutoExecuted(stateId: string, transitionId: string) { + if (!autoExecutionHistory.has(stateId)) { + autoExecutionHistory.set(stateId, new Set()) + } + autoExecutionHistory.get(stateId)!.add(transitionId) + } + + function hasAutoExecuted(stateId: string, transitionId: string): boolean { + const set = autoExecutionHistory.get(stateId) + return set ? set.has(transitionId) : false + } + + function resetEngineFromTree(treeData: RuntimeDecisionTree) { + tree.value = treeData + variables.value = { ...treeData.variables } + const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {} + const stack = Array.isArray(baseFlags.stack) ? [...baseFlags.stack] : [] + flags.value = { + in_air: Boolean(baseFlags.in_air), + emergency_active: Boolean(baseFlags.emergency_active), + current_unit: typeof baseFlags.current_unit === 'string' ? baseFlags.current_unit : 'DEL', + stack, + off_schema_count: 0, + radio_checks_done: 0, + ...baseFlags, + } + if (!Array.isArray(flags.value.stack)) { + flags.value.stack = [] + } + currentStateId.value = treeData.start_state + communicationLog.value = [] + telemetry.value = { + altitude_ft: Number(baseFlags.altitude_ft) || 0, + speed_kts: Number(baseFlags.speed_kts) || 0, + groundspeed_kts: Number(baseFlags.groundspeed_kts) || 0, + vertical_speed_fpm: Number(baseFlags.vertical_speed_fpm) || 0, + latitude_deg: Number(baseFlags.latitude_deg) || 0, + longitude_deg: Number(baseFlags.longitude_deg) || 0, + heading_deg: Number(baseFlags.heading_deg) || 0, + } + autoExecutionHistory.clear() + resetAutoHistory(currentStateId.value) + flightContext.value.phase = 'clearance' + ready.value = true + evaluateAutoTransitions() + } + + function loadRuntimeTree(data: RuntimeDecisionTree) { + resetEngineFromTree(data) + } + + 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/${slug}/runtime`) + resetEngineFromTree(data) + } + + 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(() => { - switch (flags.value.current_unit) { + 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 @@ -240,6 +415,7 @@ export default function useCommunicationsEngine() { }) function initializeFlight(fpl: any) { + const runtime = ensureTree() // Set variables variables.value = { ...variables.value, @@ -289,8 +465,9 @@ export default function useCommunicationsEngine() { radio_checks_done: 0 } - currentStateId.value = tree.value.start_state + currentStateId.value = runtime.start_state communicationLog.value = [] + resetAutoHistory(currentStateId.value) } function updateFrequencyVariables(update: Partial>) { @@ -312,33 +489,57 @@ export default function useCommunicationsEngine() { } function buildLLMContext(pilotTranscript: string) { + const runtime = ensureTree() const s = currentState.value + if (!s) { + throw new Error('Decision state unavailable') + } + const candidates = nextCandidates.value + .map(id => ({ id, state: states.value[id] })) + .filter(candidate => candidate.state) + return { state_id: s.id, - state: s, - candidates: nextCandidates.value.map(id => ({ id, state: states.value[id] })), - variables: variables.value, - flags: flags.value, - pilot_utterance: pilotTranscript + state: { ...s }, + candidates, + variables: { ...variables.value }, + flags: { ...flags.value }, + pilot_utterance: pilotTranscript, + tree: runtime.name, } } function applyLLMDecision(decision: any) { - // Apply updates - if (decision.updates) Object.assign(variables.value, decision.updates) - if (decision.flags) Object.assign(flags.value, decision.flags) + if (!decision || typeof decision !== 'object') { + return + } + + 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() + } - // Track off-schema responses and radio checks if (decision.off_schema) { flags.value.off_schema_count++ console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`) } + if (decision.radio_check) { flags.value.radio_checks_done++ console.log(`[Engine] Radio check #${flags.value.radio_checks_done}`) } - // Controller response if (decision.controller_say_tpl) { speak('atc', decision.controller_say_tpl, currentStateId.value, { radioCheck: decision.radio_check, @@ -346,13 +547,28 @@ export default function useCommunicationsEngine() { }) } - // State transition (only when not a radio check) + 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) { + resumePriorFlow() + } else if (!decision.radio_check && nextState) { + moveTo(nextState) + } + if (!decision.radio_check) { - moveTo(decision.next_state) + queueMicrotask(() => evaluateAutoTransitions()) } } function processPilotTransmission(transcript: string): string | null { + if (!ready.value) { + return null + } // Log pilot input speak('pilot', transcript, currentStateId.value) @@ -384,7 +600,90 @@ export default function useCommunicationsEngine() { return processPilotTransmission(transcript) } + 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 + } + telemetry.value = 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 + } + + function evaluateAutoTransitions(loopGuard = 0) { + if (!ready.value || loopGuard > 8) return + const state = currentState.value + if (!state || !state.auto_transitions?.length) return + + for (const transition of state.auto_transitions) { + 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 + } + } + function moveTo(stateId: string) { + ensureTree() if (!states.value[stateId]) { console.warn(`[Engine] Unknown state: ${stateId}`) return @@ -395,13 +694,17 @@ export default function useCommunicationsEngine() { } currentStateId.value = 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 }, act.set, act.to) + if (act.set) { + setByPath({ variables: variables.value, flags: flags.value, telemetry: telemetry.value }, act.set, act.to) + } } // Handoff @@ -419,6 +722,7 @@ export default function useCommunicationsEngine() { // Update flight context phase updateFlightPhase(s.phase) + queueMicrotask(() => evaluateAutoTransitions()) } function updateFlightPhase(phase: Phase) { @@ -475,11 +779,12 @@ export default function useCommunicationsEngine() { ...flags.value, variables: variables.value, flags: flags.value, + telemetry: telemetry.value, } } function exposeCtxFlat() { - return { ...variables.value, ...flags.value } + return { ...variables.value, ...flags.value, ...telemetry.value } } function unitFromHandoff(to: string) { @@ -523,26 +828,18 @@ export default function useCommunicationsEngine() { } function safeEvalBoolean(expr?: string): boolean { - if (!expr) return true - const m = expr.match(/^(variables|flags)\.([A-Za-z0-9_]+)\s*===\s*(true|false|'[^']*'|"[^"]*"|\d+)$/) - if (!m) return false - const bag = m[1] === 'variables' ? variables.value : flags.value as any - const key = m[2] - const rhsRaw = m[3] - let rhs: any = rhsRaw - if (rhsRaw === 'true') rhs = true - else if (rhsRaw === 'false') rhs = false - else if (/^\d+$/.test(rhsRaw)) rhs = Number(rhsRaw) - else rhs = rhsRaw.replace(/^['"]|['"]$/g, '') - return bag?.[key] === rhs + return evaluateConditionExpression(expr) } function setByPath(root: Record, path: string, val: any) { - const parts = path.split('.') + 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)) cur[p] = {} + if (!(p in cur) || typeof cur[p] !== 'object') { + cur[p] = {} + } cur = cur[p] } cur[parts[parts.length - 1]] = val @@ -554,6 +851,7 @@ export default function useCommunicationsEngine() { currentStateId: readonly(currentStateId), variables: readonly(variables), flags: readonly(flags), + telemetry: readonly(telemetry), nextCandidates, activeFrequency, communicationLog: readonly(communicationLog), @@ -566,6 +864,9 @@ export default function useCommunicationsEngine() { // Lifecycle initializeFlight, updateFrequencyVariables, + loadRuntimeTree, + fetchRuntimeTree, + isReady, // Communication processPilotTransmission, @@ -580,6 +881,7 @@ export default function useCommunicationsEngine() { // Utilities normalizeATCText, renderATCMessage, - getStateDetails + getStateDetails, + updateTelemetry, } } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..ab2b5e8 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~~/*": ["./*"], + "@@/*": ["./*"], + "~/\*": ["./app/*"], + "@/*": ["./app/*"] + }, + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "server/**/*.ts", "shared/**/*.ts"] +}