Enable flow-aware decision routing

This commit is contained in:
Remi
2025-09-21 21:16:33 +02:00
parent d3058314ec
commit 10cae457f9
16 changed files with 1622 additions and 998 deletions

View File

@@ -325,7 +325,7 @@
<div class="flex flex-1 overflow-hidden">
<section class="relative flex-1 overflow-hidden bg-[#070d1a]">
<DecisionNodeCanvas
v-if="flowDetail"
v-if="flowDetail && flowDetail.nodes.length"
ref="canvasComponent"
:nodes="canvasNodes"
:zoom="canvasState.zoom"
@@ -341,6 +341,17 @@
@update:pan="onUpdatePan"
@update:zoom="onUpdateZoom"
/>
<div
v-else-if="flowDetail"
class="flex h-full items-center justify-center px-6 text-center text-white/60"
>
<div class="space-y-4">
<p class="text-sm">Dieser Flow enthält noch keine Nodes.</p>
<v-btn color="cyan" variant="tonal" prepend-icon="mdi-plus-circle" @click="createInitialNode">
Ersten Node anlegen
</v-btn>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-white/60">
<div class="space-y-3 text-center">
<v-progress-circular v-if="flowLoading" indeterminate color="cyan" class="mx-auto" />
@@ -489,99 +500,354 @@
</div>
</v-window-item>
<v-window-item value="transitions">
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-widest text-white/70">Transitions</h3>
<div class="flex gap-2">
<v-btn size="small" color="cyan" variant="tonal" prepend-icon="mdi-plus" @click="addTransition()">
Transition
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn v-bind="menuProps" size="small" color="purple" variant="tonal" prepend-icon="mdi-flash">
Auto Presets
</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="preset in autoTriggerPresets" :key="preset.id" @click="applyAutoPreset(preset)">
<v-list-item-title>{{ preset.label }}</v-list-item-title>
<v-list-item-subtitle>{{ preset.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="space-y-6">
<div
v-if="nodeFormError"
class="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200"
>
{{ nodeFormError }}
</div>
<v-expansion-panels variant="accordion" multiple>
<v-expansion-panel v-for="(transition, index) in nodeForm.transitions" :key="transition.key || index">
<v-expansion-panel-title>
<div class="flex w-full items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-white/60">{{ transition.type.toUpperCase() }}</span>
<span class="font-semibold">→ {{ transition.target || 'Ziel wählen' }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-white/40">
<span v-if="transition.autoTrigger">Auto</span>
<span v-if="transition.timer">Timer {{ transition.timer.afterSeconds }}s</span>
<v-btn size="x-small" icon variant="text" @click.stop="removeTransition(index)">
<v-icon icon="mdi-delete" size="16" />
</v-btn>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<v-select v-model="transition.type" :items="transitionTypes" label="Typ" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.target" label="Zielstate" hide-details density="comfortable" color="cyan" />
</div>
<v-text-field v-model="transition.label" label="Label" hide-details density="comfortable" color="cyan" />
<v-textarea v-model="transition.description" label="Beschreibung" rows="2" hide-details color="cyan" />
<v-text-field v-model="transition.condition" label="Bedingung" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.guard" label="Guard" hide-details density="comfortable" color="cyan" />
<v-text-field v-model.number="transition.order" label="Reihenfolge" type="number" hide-details density="comfortable" color="cyan" />
<div v-if="transition.type === 'timer'" class="grid grid-cols-2 gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3">
<v-text-field v-model.number="transition.timer.afterSeconds" label="Timer Sekunden" type="number" hide-details density="comfortable" color="amber" />
<v-switch v-model="transition.timer.allowManualProceed" label="Manueller Proceed" hide-details inset density="compact" color="amber" />
</div>
<div class="rounded-xl border border-cyan-500/30 bg-cyan-500/10 p-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-white/80">Auto Trigger</h4>
<v-btn size="x-small" variant="text" color="cyan" @click="toggleAutoTrigger(index)">
{{ transition.autoTrigger ? 'Entfernen' : 'Hinzufügen' }}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-widest text-white/70">Node Trigger</h3>
<v-btn size="small" color="cyan" variant="tonal" prepend-icon="mdi-plus" @click="addNodeTrigger">
Trigger
</v-btn>
</div>
<p v-if="!nodeForm.triggers?.length" class="text-xs text-white/50">
Keine Trigger definiert.
</p>
<v-expansion-panels
v-else
variant="accordion"
multiple
class="rounded-xl border border-white/10"
>
<v-expansion-panel
v-for="(trigger, index) in nodeForm.triggers"
:key="trigger.id || index"
>
<v-expansion-panel-title>
<div class="flex w-full items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-white/60">
{{ nodeTriggerTypeLabel(trigger.type) }}
</span>
<span class="font-semibold">{{ nodeTriggerSummary(trigger) }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-white/40">
<v-btn size="x-small" icon variant="text" @click.stop="removeNodeTrigger(index)">
<v-icon icon="mdi-delete" size="16" />
</v-btn>
</div>
<div v-if="transition.autoTrigger" class="space-y-3 pt-2">
<v-select v-model="transition.autoTrigger.type" :items="autoTriggerTypes" label="Trigger Typ" hide-details density="comfortable" color="cyan" />
<div v-if="transition.autoTrigger.type === 'telemetry'" class="grid grid-cols-2 gap-3">
<v-select v-model="transition.autoTrigger.parameter" :items="telemetryParameters" label="Parameter" hide-details density="comfortable" color="cyan" />
<v-select v-model="transition.autoTrigger.operator" :items="comparisonOperators" label="Operator" hide-details density="comfortable" color="cyan" />
<v-text-field v-model.number="transition.autoTrigger.value" label="Wert" type="number" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.autoTrigger.unit" label="Einheit" hide-details density="comfortable" color="cyan" />
</div>
<div v-else-if="transition.autoTrigger.type === 'variable'" class="grid grid-cols-2 gap-3">
<v-text-field v-model="transition.autoTrigger.variable" label="Variablenpfad" hide-details density="comfortable" color="cyan" />
<v-select v-model="transition.autoTrigger.operator" :items="comparisonOperators" label="Operator" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.autoTrigger.value" label="Vergleichswert" hide-details density="comfortable" color="cyan" />
</div>
<v-textarea
v-else
v-model="transition.autoTrigger.expression"
label="Bedingungsausdruck"
rows="2"
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<v-select
v-model="trigger.type"
:items="nodeTriggerTypeOptions"
item-title="title"
item-value="value"
label="Typ"
hide-details
density="comfortable"
color="cyan"
/>
<v-textarea v-model="transition.autoTrigger.description" label="Beschreibung" rows="2" hide-details color="cyan" />
<div class="grid grid-cols-2 gap-3">
<v-text-field v-model.number="transition.autoTrigger.delayMs" label="Verzögerung (ms)" type="number" hide-details density="comfortable" color="cyan" />
<v-switch v-model="transition.autoTrigger.once" label="Nur einmal" hide-details inset density="compact" color="cyan" />
<v-text-field
v-model.number="trigger.order"
label="Reihenfolge"
type="number"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<div v-if="trigger.type === 'auto_time'" class="grid grid-cols-2 gap-3">
<v-text-field
v-model.number="trigger.delaySeconds"
label="Verzögerung (Sekunden)"
type="number"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<div v-else-if="trigger.type === 'auto_variable'" class="grid grid-cols-3 gap-3">
<v-text-field
v-model="trigger.variable"
label="Variable"
hide-details
density="comfortable"
color="cyan"
/>
<v-select
v-model="trigger.operator"
:items="comparisonOperators"
label="Operator"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model="trigger.value"
label="Vergleichswert"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<div v-else-if="trigger.type === 'regex'" class="grid grid-cols-2 gap-3">
<v-text-field
v-model="trigger.pattern"
label="Regex"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model="trigger.patternFlags"
label="Flags"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<p v-else class="text-xs text-white/50">
Dieser Trigger wird genutzt, wenn kein anderer vorher greift.
</p>
<v-textarea
v-model="trigger.description"
label="Beschreibung"
rows="2"
hide-details
color="cyan"
/>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-widest text-white/70">Node Bedingungen</h3>
<v-btn size="small" color="cyan" variant="tonal" prepend-icon="mdi-plus" @click="addNodeCondition">
Bedingung
</v-btn>
</div>
<p v-if="!nodeForm.conditions?.length" class="text-xs text-white/50">
Keine Bedingungen definiert.
</p>
<v-expansion-panels
v-else
variant="accordion"
multiple
class="rounded-xl border border-white/10"
>
<v-expansion-panel
v-for="(condition, index) in nodeForm.conditions"
:key="condition.id || index"
>
<v-expansion-panel-title>
<div class="flex w-full items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-white/60">
{{ nodeConditionTypeLabel(condition.type) }}
</span>
<span class="font-semibold">{{ nodeConditionSummary(condition) }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-white/40">
<v-btn size="x-small" icon variant="text" @click.stop="removeNodeCondition(index)">
<v-icon icon="mdi-delete" size="16" />
</v-btn>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<v-select
v-model="condition.type"
:items="nodeConditionTypeOptions"
item-title="title"
item-value="value"
label="Typ"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model.number="condition.order"
label="Reihenfolge"
type="number"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<div v-if="condition.type === 'variable_value'" class="grid grid-cols-3 gap-3">
<v-text-field
v-model="condition.variable"
label="Variable"
hide-details
density="comfortable"
color="cyan"
/>
<v-select
v-model="condition.operator"
:items="comparisonOperators"
label="Operator"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model="condition.value"
label="Vergleichswert"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<div v-else class="grid grid-cols-2 gap-3">
<v-text-field
v-model="condition.pattern"
label="Regex"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model="condition.patternFlags"
label="Flags"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<v-textarea
v-model="condition.description"
label="Beschreibung"
rows="2"
hide-details
color="cyan"
/>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-widest text-white/70">Transitions</h3>
<div class="flex gap-2">
<v-btn size="small" color="cyan" variant="tonal" prepend-icon="mdi-plus" @click="addTransition()">
Transition
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn v-bind="menuProps" size="small" color="purple" variant="tonal" prepend-icon="mdi-flash">
Auto Presets
</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="preset in autoTriggerPresets" :key="preset.id" @click="applyAutoPreset(preset)">
<v-list-item-title>{{ preset.label }}</v-list-item-title>
<v-list-item-subtitle>{{ preset.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<v-expansion-panels variant="accordion" multiple>
<v-expansion-panel
v-for="(transition, index) in nodeForm.transitions"
:key="transition.key || index"
>
<v-expansion-panel-title>
<div class="flex w-full items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-white/60">{{ transition.type.toUpperCase() }}</span>
<span class="font-semibold">→ {{ transition.target || 'Ziel wählen' }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-white/40">
<span v-if="transition.autoTrigger">Auto</span>
<span v-if="transition.timer">Timer {{ transition.timer.afterSeconds }}s</span>
<v-btn size="x-small" icon variant="text" @click.stop="removeTransition(index)">
<v-icon icon="mdi-delete" size="16" />
</v-btn>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<v-select
v-model="transition.type"
:items="transitionTypes"
label="Typ"
hide-details
density="comfortable"
color="cyan"
/>
<v-text-field
v-model="transition.target"
label="Zielstate"
hide-details
density="comfortable"
color="cyan"
/>
</div>
<v-text-field v-model="transition.label" label="Label" hide-details density="comfortable" color="cyan" />
<v-textarea v-model="transition.description" label="Beschreibung" rows="2" hide-details color="cyan" />
<v-text-field v-model="transition.condition" label="Bedingung" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.guard" label="Guard" hide-details density="comfortable" color="cyan" />
<v-text-field v-model.number="transition.order" label="Reihenfolge" type="number" hide-details density="comfortable" color="cyan" />
<div v-if="transition.type === 'timer'" class="grid grid-cols-2 gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3">
<v-text-field v-model.number="transition.timer.afterSeconds" label="Timer Sekunden" type="number" hide-details density="comfortable" color="amber" />
<v-switch v-model="transition.timer.allowManualProceed" label="Manueller Proceed" hide-details inset density="compact" color="amber" />
</div>
<div class="rounded-xl border border-cyan-500/30 bg-cyan-500/10 p-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-white/80">Auto Trigger</h4>
<v-btn size="x-small" variant="text" color="cyan" @click="toggleAutoTrigger(index)">
{{ transition.autoTrigger ? 'Entfernen' : 'Hinzufügen' }}
</v-btn>
</div>
<div v-if="transition.autoTrigger" class="space-y-3 pt-2">
<v-select v-model="transition.autoTrigger.type" :items="autoTriggerTypes" label="Trigger Typ" hide-details density="comfortable" color="cyan" />
<div v-if="transition.autoTrigger.type === 'telemetry'" class="grid grid-cols-2 gap-3">
<v-select v-model="transition.autoTrigger.parameter" :items="telemetryParameters" label="Parameter" hide-details density="comfortable" color="cyan" />
<v-select v-model="transition.autoTrigger.operator" :items="comparisonOperators" label="Operator" hide-details density="comfortable" color="cyan" />
<v-text-field v-model.number="transition.autoTrigger.value" label="Wert" type="number" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.autoTrigger.unit" label="Einheit" hide-details density="comfortable" color="cyan" />
</div>
<div v-else-if="transition.autoTrigger.type === 'variable'" class="grid grid-cols-2 gap-3">
<v-text-field v-model="transition.autoTrigger.variable" label="Variablenpfad" hide-details density="comfortable" color="cyan" />
<v-select v-model="transition.autoTrigger.operator" :items="comparisonOperators" label="Operator" hide-details density="comfortable" color="cyan" />
<v-text-field v-model="transition.autoTrigger.value" label="Vergleichswert" hide-details density="comfortable" color="cyan" />
</div>
<v-textarea
v-else
v-model="transition.autoTrigger.expression"
label="Bedingungsausdruck"
rows="2"
hide-details
color="cyan"
/>
<v-textarea v-model="transition.autoTrigger.description" label="Beschreibung" rows="2" hide-details color="cyan" />
<div class="grid grid-cols-2 gap-3">
<v-text-field v-model.number="transition.autoTrigger.delayMs" label="Verzögerung (ms)" type="number" hide-details density="comfortable" color="cyan" />
<v-switch v-model="transition.autoTrigger.once" label="Nur einmal" hide-details inset density="compact" color="cyan" />
</div>
</div>
</div>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</v-window-item>
<v-window-item value="llm">
@@ -697,7 +963,9 @@ import DecisionNodeCanvas from '~/components/editor/DecisionNodeCanvas.vue'
import type {
DecisionFlowModel,
DecisionFlowSummary,
DecisionNodeCondition,
DecisionNodeModel,
DecisionNodeTrigger,
DecisionNodeTransition,
DecisionNodeLayout,
} from '~/shared/types/decision'
@@ -771,6 +1039,32 @@ const transitionTypes: DecisionNodeTransition['type'][] = ['next', 'ok', 'bad',
const autoTriggerTypes = ['telemetry', 'variable', 'expression']
const roleOptions = ['pilot', 'atc', 'system']
const nodeTriggerTypeOptions = [
{ value: 'auto_time', title: 'Auto (Zeit)', subtitle: 'Nach einer Verzögerung automatisch aktivieren' },
{ value: 'auto_variable', title: 'Auto (Variable)', subtitle: 'Aktivieren, sobald eine Variable einen Wert erreicht' },
{ value: 'regex', title: 'Regex Match', subtitle: 'Kandidat wenn vorheriger Output passt' },
{ value: 'none', title: 'Fallback', subtitle: 'Kandidat wenn nichts anderes greift' },
] as const
const nodeConditionTypeOptions = [
{ value: 'variable_value', title: 'Variable Vergleich', subtitle: 'Prüft Variablenwerte' },
{ value: 'regex', title: 'Regex Match', subtitle: 'Nur wenn der Output passt' },
{ value: 'regex_not', title: 'Regex kein Match', subtitle: 'Nur wenn der Output nicht passt' },
] as const
const nodeTriggerTypeLabels: Record<DecisionNodeTrigger['type'], string> = {
auto_time: 'Auto (Zeit)',
auto_variable: 'Auto (Variable)',
regex: 'Regex Match',
none: 'Fallback',
}
const nodeConditionTypeLabels: Record<DecisionNodeCondition['type'], string> = {
variable_value: 'Variable Vergleich',
regex: 'Regex Match',
regex_not: 'Regex kein Match',
}
const flows = ref<DecisionFlowSummary[]>([])
const flowsLoading = ref(false)
const flowsError = ref('')
@@ -867,6 +1161,7 @@ const nodeSnapshot = ref<DecisionNodeModel | null>(null)
const nodeSaving = ref(false)
const nodeActionsText = ref('[]')
const nodeActionsError = ref('')
const nodeFormError = ref('')
const nodeIdDraft = ref('')
let lastTitleSuggestion = ''
@@ -1016,6 +1311,7 @@ watch(selectedNodeId, (stateId) => {
nodeSnapshot.value = null
nodeActionsText.value = '[]'
nodeActionsError.value = ''
nodeFormError.value = ''
nodeIdDraft.value = ''
return
}
@@ -1025,6 +1321,7 @@ watch(selectedNodeId, (stateId) => {
nodeSnapshot.value = null
nodeActionsText.value = '[]'
nodeActionsError.value = ''
nodeFormError.value = ''
nodeIdDraft.value = ''
return
}
@@ -1034,6 +1331,8 @@ watch(selectedNodeId, (stateId) => {
if (!clone.layout) clone.layout = { x: 0, y: 0 }
if (!clone.readbackRequired) clone.readbackRequired = []
if (!clone.transitions) clone.transitions = []
if (!clone.triggers) clone.triggers = []
if (!clone.conditions) clone.conditions = []
if (!clone.metadata) clone.metadata = {}
if (!clone.llmTemplate) clone.llmTemplate = { placeholders: [] }
if (!clone.llmTemplate.placeholders) clone.llmTemplate.placeholders = []
@@ -1041,6 +1340,7 @@ watch(selectedNodeId, (stateId) => {
nodeSnapshot.value = cloneNode(clone)
nodeActionsText.value = JSON.stringify(clone.actions ?? [], null, 2)
nodeActionsError.value = ''
nodeFormError.value = ''
nodeIdDraft.value = clone.stateId
lastTitleSuggestion = buildNodeKeyFromText(clone.title || clone.stateId)
pendingNodeHistory = null
@@ -1291,7 +1591,8 @@ async function loadFlows() {
const response = await api.get<DecisionFlowSummary[]>('/api/editor/flows')
flows.value = response
if (!selectedFlowSlug.value && response.length) {
selectedFlowSlug.value = response[0].slug
const preferred = response.find((flow) => flow.slug === 'icao_atc_decision_tree')?.slug
selectedFlowSlug.value = preferred || response[0].slug
}
} catch (error) {
console.error('Failed to load flows', error)
@@ -1362,6 +1663,52 @@ function closeCreateFlow() {
showCreateFlowDialog.value = false
}
async function createInitialNode() {
if (!flowDetail.value) return
const slug = flowDetail.value.flow.slug
const baseStart = flowDetail.value.flow.startState || 'START'
const stateId = baseStart.trim().length ? baseStart.trim().toUpperCase() : 'START'
const role = flowDetail.value.flow.roles?.[0] || 'pilot'
const phase = flowDetail.value.flow.phases?.[0] || 'General'
const payload = {
stateId,
title: flowDetail.value.flow.name ? `${flowDetail.value.flow.name} Start` : 'Erster Node',
summary: '',
role,
phase,
transitions: [],
triggers: [],
conditions: [],
layout: { x: 320, y: 180 },
}
try {
const created = await api.post<DecisionNodeModel>(`/api/editor/flows/${slug}/nodes`, payload)
flowDetail.value.nodes.push(created)
flowDetail.value.flow.startState = created.stateId
if (!Array.isArray(flowDetail.value.flow.endStates) || !flowDetail.value.flow.endStates.length) {
flowDetail.value.flow.endStates = [created.stateId]
flowForm.endStates = [created.stateId]
}
flowForm.startState = created.stateId
selectedNodeId.value = created.stateId
showSnack('Erster Node erstellt.')
const summaryIndex = flows.value.findIndex((flow) => flow.slug === slug)
if (summaryIndex !== -1) {
const previous = flows.value[summaryIndex]
flows.value.splice(summaryIndex, 1, {
...previous,
startState: created.stateId,
nodeCount: (previous.nodeCount || 0) + 1,
})
}
} catch (error) {
console.error('Failed to create initial node', error)
showSnack('Erster Node konnte nicht erstellt werden.', 'red')
}
}
async function createFlow() {
if (!newFlowForm.slug.trim() || !newFlowForm.name.trim()) {
newFlowError.value = 'Slug und Name werden benötigt.'
@@ -1582,6 +1929,7 @@ async function persistNode(options: { silent?: boolean } = {}) {
nodeInitializing = false
})
lastNodeAutosaveError = ''
nodeFormError.value = ''
if (silent) {
flashAutosaveIndicator()
} else {
@@ -1589,10 +1937,11 @@ async function persistNode(options: { silent?: boolean } = {}) {
}
} catch (error: any) {
console.error('Failed to save node', error)
const message = error?.statusMessage || 'Node konnte nicht gespeichert werden.'
const message = error?.data?.formError || error?.statusMessage || 'Node konnte nicht gespeichert werden.'
if (!silent || message !== lastNodeAutosaveError) {
showSnack(message, 'red')
}
nodeFormError.value = message
lastNodeAutosaveError = message
} finally {
nodeSaving.value = false
@@ -1814,6 +2163,7 @@ function resetNode() {
nodeForm.value = cloneNode(nodeSnapshot.value)
nodeActionsText.value = JSON.stringify(nodeSnapshot.value.actions ?? [], null, 2)
nodeActionsError.value = ''
nodeFormError.value = ''
syncNodeLayout()
pendingNodeHistory = null
}
@@ -2112,6 +2462,90 @@ function generateKey(prefix: string) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`
}
function nodeTriggerTypeLabel(type: DecisionNodeTrigger['type']) {
return nodeTriggerTypeLabels[type] || type
}
function nodeTriggerSummary(trigger: DecisionNodeTrigger) {
switch (trigger.type) {
case 'auto_time':
return `${trigger.delaySeconds ?? 0}s Verzögerung`
case 'auto_variable':
return trigger.variable
? `${trigger.variable} ${trigger.operator ?? '=='} ${
trigger.value !== undefined && trigger.value !== '' ? trigger.value : '?'
}`
: 'Variable prüfen'
case 'regex':
return trigger.pattern ? `/${trigger.pattern}/${trigger.patternFlags || ''}` : 'Regex prüfen'
case 'none':
return 'Fallback'
default:
return ''
}
}
function nodeConditionTypeLabel(type: DecisionNodeCondition['type']) {
return nodeConditionTypeLabels[type] || type
}
function nodeConditionSummary(condition: DecisionNodeCondition) {
switch (condition.type) {
case 'variable_value':
return condition.variable
? `${condition.variable} ${condition.operator ?? '=='} ${
condition.value !== undefined && condition.value !== '' ? condition.value : '?'
}`
: 'Variable prüfen'
case 'regex':
return condition.pattern ? `/${condition.pattern}/${condition.patternFlags || ''}` : 'Regex prüfen'
case 'regex_not':
return condition.pattern ? `!= /${condition.pattern}/${condition.patternFlags || ''}` : 'Regex darf nicht matchen'
default:
return ''
}
}
function addNodeTrigger() {
if (!nodeForm.value) return
if (!nodeForm.value.triggers) nodeForm.value.triggers = []
nodeForm.value.triggers.push({
id: generateKey('trigger'),
type: 'auto_time',
delaySeconds: 5,
order: nodeForm.value.triggers.length,
})
}
function removeNodeTrigger(index: number) {
if (!nodeForm.value?.triggers) return
nodeForm.value.triggers.splice(index, 1)
nodeForm.value.triggers.forEach((trigger, idx) => {
trigger.order = idx
})
}
function addNodeCondition() {
if (!nodeForm.value) return
if (!nodeForm.value.conditions) nodeForm.value.conditions = []
nodeForm.value.conditions.push({
id: generateKey('condition'),
type: 'variable_value',
variable: '',
operator: '==',
value: '',
order: nodeForm.value.conditions.length,
})
}
function removeNodeCondition(index: number) {
if (!nodeForm.value?.conditions) return
nodeForm.value.conditions.splice(index, 1)
nodeForm.value.conditions.forEach((condition, idx) => {
condition.order = idx
})
}
function addTransition(type: DecisionNodeTransition['type'] = 'next') {
if (!nodeForm.value) return
const transition: DecisionNodeTransition = {

View File

@@ -2,17 +2,33 @@
<div class="min-h-screen bg-[#050910] text-white">
<div class="mx-auto w-full max-w-[420px] px-4 pb-24 pt-6 sm:px-6">
<!-- Header -->
<header class="flex items-center justify-between pb-6">
<header class="flex flex-col gap-4 pb-6 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.35em] text-cyan-400/80">OpenSquawk</p>
<h1 class="text-2xl font-semibold">Pilot Monitoring</h1>
<p class="mt-1 text-sm text-white/70">Alpha Build Decision Tree VATSIM</p>
</div>
<div class="text-right">
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">
{{ currentState?.id || 'INIT' }}
</v-chip>
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
<div class="flex flex-col items-stretch gap-2 sm:items-end">
<div class="text-right">
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">
{{ currentState?.id || 'INIT' }}
</v-chip>
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
</div>
<v-select
v-model="selectedFlowSlug"
:items="flowOptions"
item-title="title"
item-value="value"
label="Flow"
variant="outlined"
density="compact"
hide-details
color="cyan"
class="min-w-[200px]"
:disabled="flowOptions.length <= 1"
prepend-inner-icon="mdi-sitemap"
/>
</div>
</header>
@@ -696,6 +712,14 @@
</div>
<p class="text-sm text-white font-mono">{{ entry.message }}</p>
<div class="flex items-center gap-2 mt-1">
<v-chip
v-if="entry.flow"
size="x-small"
color="purple"
variant="outlined"
>
{{ entry.flow }}
</v-chip>
<v-chip size="x-small" color="cyan" variant="outlined">{{ entry.frequency || 'N/A' }}</v-chip>
<span class="text-xs text-white/40">{{ entry.state }}</span>
</div>
@@ -953,9 +977,12 @@ const {
flags,
flightContext,
currentStep,
availableFlows,
activeFlow,
initializeFlight,
updateFrequencyVariables,
fetchRuntimeTree,
setActiveFlow,
isReady: engineReady,
processPilotTransmission,
buildLLMContext,
@@ -1023,6 +1050,41 @@ const clearLog = () => {
clearLastTransmission()
}
const selectedFlowSlug = ref('')
const flowOptions = computed(() =>
availableFlows.value.map((flow) => ({
title: flow.name,
value: flow.slug,
subtitle: flow.description,
}))
)
watch(
activeFlow,
(slug) => {
selectedFlowSlug.value = slug || ''
},
{ immediate: true }
)
watch(selectedFlowSlug, (slug, previous) => {
if (!slug || slug === activeFlow.value || slug === previous) {
return
}
handleFlowChange(slug)
})
function handleFlowChange(slug: string) {
if (!slug || slug === activeFlow.value) {
return
}
try {
setActiveFlow(slug)
} catch (error) {
console.error('Failed to activate flow', error)
}
}
// UI State
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
const loading = ref(false)

View File

@@ -17,9 +17,10 @@ interface PTTRequest {
context: {
state_id: string;
state: any;
candidates: Array<{ id: string; state: any }>;
candidates: Array<{ id: string; state: any; flow?: string }>;
variables: Record<string, any>;
flags: Record<string, any>;
flow_slug?: string;
};
moduleId: string;
lessonId: string;
@@ -35,6 +36,8 @@ interface PTTResponse {
controller_say_tpl?: string;
off_schema?: boolean;
radio_check?: boolean;
activate_flow?: string;
resume_previous?: boolean;
};
}

View File

@@ -0,0 +1,6 @@
import { buildRuntimeDecisionSystem } from '../../services/decisionFlowService'
export default defineEventHandler(async () => {
const system = await buildRuntimeDecisionSystem()
return system
})

View File

@@ -6,9 +6,16 @@ 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'])
@@ -47,9 +54,44 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
}
const transitions = Array.isArray(body.transitions)
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
: []
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)
@@ -89,6 +131,8 @@ export default defineEventHandler(async (event) => {
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,

View File

@@ -6,9 +6,16 @@ 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'])
@@ -113,7 +120,49 @@ export default defineEventHandler(async (event) => {
}
if (Array.isArray(body.transitions)) {
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
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)

View File

@@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => {
if (url.pathname.startsWith('/api/service/')) {
return
}
if (url.pathname === '/api/decision-flows/runtime') {
return
}
if (event.node.req.method === 'OPTIONS') {
return
}

View File

@@ -1,11 +1,13 @@
import mongoose from 'mongoose'
import type {
DecisionNodeAutoTrigger,
DecisionNodeCondition,
DecisionNodeLayout,
DecisionNodeLLMPlaceholder,
DecisionNodeLLMTemplate,
DecisionNodeMetadata,
DecisionNodeModel,
DecisionNodeTrigger,
DecisionNodeTransition,
} from '~~/shared/types/decision'
@@ -88,6 +90,37 @@ const autoTriggerSchema = new mongoose.Schema<DecisionNodeAutoTrigger>(
{ _id: false }
)
const triggerSchema = new mongoose.Schema<DecisionNodeTrigger>(
{
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<DecisionNodeCondition>(
{
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<DecisionNodeTransition>(
{
key: { type: String, required: true },
@@ -158,6 +191,8 @@ const decisionNodeSchema = new mongoose.Schema<DecisionNodeDocument>(
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 },

View File

@@ -9,6 +9,7 @@ import type {
RuntimeDecisionAutoTransition,
RuntimeDecisionState,
RuntimeDecisionTree,
RuntimeDecisionSystem,
} from '~~/shared/types/decision'
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
@@ -53,6 +54,8 @@ export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeMo
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,
@@ -170,25 +173,26 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState
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,
}
}
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
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 })
async function buildRuntimeTreeForDoc(
flowDoc: DecisionFlowDocument,
nodeDocs?: DecisionNodeDocument[]
): Promise<RuntimeDecisionTree> {
const nodes = nodeDocs ?? (await DecisionNode.find({ flow: flowDoc._id }))
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
acc[node.stateId] = serializeRuntimeState(node)
return acc
}, {})
return {
slug: flowDoc.slug,
schema_version: flowDoc.schemaVersion || '1.0',
name: flowDoc.slug,
name: flowDoc.name || flowDoc.slug,
description: flowDoc.description || undefined,
start_state: flowDoc.startState,
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
@@ -201,3 +205,51 @@ export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDec
states,
}
}
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
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<RuntimeDecisionSystem> {
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<Record<string, DecisionNodeDocument[]>>((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<Record<string, RuntimeDecisionTree>>((acc, tree) => {
acc[tree.slug] = tree
return acc
}, {})
const order = runtimeTrees.map((tree) => tree.slug)
const main = flows['icao_atc_decision_tree'] ? 'icao_atc_decision_tree' : order[0]
return {
main,
order,
flows,
}
}

View File

@@ -1,197 +1,21 @@
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<string, string> = {
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'
: '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<string, number>()
phases.forEach((phase, index) => phaseColumns.set(phase, index))
const phaseRowCounters = new Map<string, number>()
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)
const { flow, nodes } = await getFlowWithNodes(slug)
return {
flow: serializedFlow,
flow,
nodes,
importedStates: nodes.length,
}
}

View File

@@ -1,16 +1,29 @@
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') {
@@ -43,6 +56,42 @@ function asBoolean(input: any, fallback: boolean): boolean {
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<DecisionNodeAutoTrigger['parameter']> = 'altitude_ft'
): NonNullable<DecisionNodeAutoTrigger['parameter']> {
const parameter = asTrimmedString(input)
if (parameter && TELEMETRY_PARAMETERS.has(parameter)) {
return parameter as NonNullable<DecisionNodeAutoTrigger['parameter']>
}
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
@@ -133,70 +182,97 @@ export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefin
}
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 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(raw.id) || `auto_${randomUUID()}`,
type: type as DecisionNodeAutoTrigger['type'],
id: asTrimmedString(payload.id) || `auto_${randomUUID()}`,
type: normalizedType,
}
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 (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 (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
} else if (normalizedType === 'variable') {
trigger.variable = asTrimmedString(payload.variable) ?? ''
trigger.operator = asComparisonOperatorValue(payload.operator)
trigger.value = asVariableValue(payload.value, '')
}
if (raw.once !== undefined) {
trigger.once = asBoolean(raw.once, true)
}
const delayMs = asNumber(raw.delayMs)
trigger.once = asBoolean(payload.once, true)
const delayMs = asNumber(payload.delayMs)
if (typeof delayMs === 'number') trigger.delayMs = delayMs
const description = asTrimmedString(raw.description)
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')

View File

@@ -2,6 +2,8 @@
import OpenAI from 'openai'
import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech'
import type {LLMDecision, LLMDecisionInput} 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
@@ -200,6 +202,319 @@ 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
}
interface PreparedCandidateResult {
filteredCandidates: DecisionCandidate[]
candidateFlowMap: Map<string, string>
activeFlowSlug: string
}
const RUNTIME_CACHE_TTL_MS = 5_000
let runtimeSystemCache: { system: RuntimeDecisionSystem; index: Map<string, IndexedStateEntry>; timestamp: number } | null = null
function buildRuntimeIndex(system: RuntimeDecisionSystem): Map<string, IndexedStateEntry> {
const index = new Map<string, IndexedStateEntry>()
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<string, IndexedStateEntry> }> {
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): boolean {
const normalizedLeft = normalizeComparable(left)
const normalizedRight = normalizeComparable(parseComparable(right))
switch (operator) {
case '>':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft > normalizedRight
: false
case '>=':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft >= normalizedRight
: false
case '<':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft < normalizedRight
: false
case '<=':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft <= normalizedRight
: false
case '!==':
case '!=':
return normalizedLeft !== normalizedRight
case '===':
case '==':
default:
return normalizedLeft === normalizedRight
}
}
function resolveContextPath(
path: string | undefined,
context: { variables: Record<string, any>; flags: Record<string, any> }
) {
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<string, any>; flags: Record<string, any> },
utterance: string
): boolean {
if (!condition) return true
switch (condition.type) {
case 'regex':
return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
case 'regex_not':
return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
case 'variable_value':
default: {
const left = resolveContextPath(condition.variable, context)
const operator = condition.operator || '=='
return compareValuesSafe(left, operator, condition.value)
}
}
}
function evaluateConditionList(
conditions: DecisionNodeCondition[] | undefined,
context: { variables: Record<string, any>; flags: Record<string, any> },
utterance: string
): boolean {
if (!Array.isArray(conditions) || conditions.length === 0) {
return true
}
const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
for (const condition of ordered) {
if (!evaluateConditionEntry(condition, context, utterance)) {
return false
}
}
return true
}
function filterDecisionCandidates(
candidates: DecisionCandidate[],
utterance: string,
context: { variables: Record<string, any>; flags: Record<string, any> }
): DecisionCandidate[] {
if (!candidates.length) {
return []
}
const regexMatches: DecisionCandidate[] = []
const noneMatches: DecisionCandidate[] = []
for (const candidate of candidates) {
const { matchesRegex, matchesNone } = analyzeTriggers(candidate.state?.triggers, utterance)
if (matchesRegex) {
regexMatches.push(candidate)
} else if (matchesNone) {
noneMatches.push(candidate)
}
}
const pool = regexMatches.length > 0 ? regexMatches : noneMatches
if (!pool.length) {
return []
}
return pool.filter(candidate =>
evaluateConditionList(candidate.state?.conditions, context, utterance)
)
}
async function prepareDecisionCandidates(
input: LLMDecisionInput,
utterance: string
): Promise<PreparedCandidateResult> {
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 uniqueCandidates = new Map<string, DecisionCandidate>()
const addCandidate = (candidate: DecisionCandidate | null | undefined) => {
if (!candidate || !candidate.id || !candidate.state) return
if (uniqueCandidates.has(candidate.id)) return
uniqueCandidates.set(candidate.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
if (!state) continue
addCandidate({ id: raw.id, flow: flow || activeFlowSlug, state })
}
for (const raw of input.candidates || []) {
if (!raw?.id || !raw.state) continue
if (!uniqueCandidates.has(raw.id)) {
addCandidate({ id: raw.id, flow: raw.flow || activeFlowSlug, state: raw.state })
}
}
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
const startStateId = tree.start_state
if (!startStateId) continue
const indexed = index.get(startStateId)
if (!indexed) continue
addCandidate({ id: startStateId, flow: flowSlug, state: { ...indexed.state } })
}
const candidates = Array.from(uniqueCandidates.values())
const context = { variables: input.variables || {}, flags: input.flags || {} }
const filteredCandidates = filterDecisionCandidates(candidates, utterance, context)
const candidateFlowMap = new Map<string, string>()
for (const candidate of filteredCandidates) {
if (candidate.flow) {
candidateFlowMap.set(candidate.id, candidate.flow)
}
}
return {
filteredCandidates,
candidateFlowMap,
activeFlowSlug,
}
}
function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null {
const rawValue = input.variables?.[key]
if (rawValue !== undefined && rawValue !== null) {
@@ -329,8 +644,29 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
const pilotUtterance = (input.pilot_utterance || '').trim()
const pilotText = pilotUtterance.toLowerCase()
const trace: LLMDecisionTrace = {calls: []}
let candidateFlowMap = new Map<string, string>()
let activeFlowSlug = input.flow_slug || ''
const prepared = await prepareDecisionCandidates(input, pilotUtterance)
candidateFlowMap = prepared.candidateFlowMap
if (prepared.activeFlowSlug) {
activeFlowSlug = prepared.activeFlowSlug
input.flow_slug = prepared.activeFlowSlug
}
input.candidates = prepared.filteredCandidates.map(candidate => ({
id: candidate.id,
state: candidate.state,
flow: candidate.flow,
}))
const finalize = (decision: LLMDecision): LLMDecisionResult => {
const targetState = decision.next_state
if (targetState) {
const targetFlow = candidateFlowMap.get(targetState)
if (targetFlow && targetFlow !== activeFlowSlug) {
decision.activate_flow = targetFlow
}
}
if (!trace.calls.length && !trace.fallback) {
return {decision}
}

View File

@@ -1,585 +0,0 @@
const atcDecisionTree = {
"schema_version": "1.0",
"name": "icao_atc_decision_tree",
"description": "Machine-readable ICAO/FAA-style ATC flow for IFR, suited for LLM+Whisper+TTS loop.",
"start_state": "CD_CHECK_ATIS",
"end_states": ["FLOW_COMPLETE"],
"variables": {
"callsign": "DLH39A",
"acf_type": "A320",
"dep": "EDDF",
"dest": "EDDM",
"stand": "A12",
"runway": "25R",
"sid": "ANEKI 7S",
"transition": "ANEKI",
"squawk": "1234",
"initial_altitude_ft": 5000,
"climb_altitude_ft": 7000,
"cruise_flight_level": "FL360",
"star": "RNAV X",
"approach_type": "ILS Z",
"taxi_route": "V A",
"missed_approach": "as published",
"delivery_freq": "121.900",
"ground_freq": "121.700",
"tower_freq": "118.700",
"departure_freq": "125.350",
"approach_freq": "120.800",
"handoff_freq": "121.800",
"atis_freq": "118.025",
"atis_code": "K",
"gate": "B24",
"trans_level": "FL070",
"qnh_hpa": 1015,
"push_delay_min": 5,
"surface_wind": "220/05",
"speed_restriction": "210 knots",
"emergency_heading": "180",
"remarks": "standard",
"time_now": "ISO8601"
},
"flags": {
"in_air": false,
"emergency_active": false,
"current_unit": "DEL",
"stack": []
},
"policies": {
"timeouts": {
"pilot_readback_timeout_s": 8,
"controller_ack_timeout_s": 6,
"no_reply_retry_after_s": 5,
"no_reply_max_retries": 2,
"lost_comms_detect_after_s": 90
},
"no_reply_sequence": [
{"after_s": 5, "controller_say_tpl": "{callsign}, confirm last transmission."},
{"after_s": 10, "controller_say_tpl": "{callsign}, do you read?"},
{"after_s": 20, "controller_say_tpl": "{callsign}, if you read, ident."}
],
"interrupts_allowed_when": {
"MAYDAY": "flags.in_air === true",
"PANPAN": "flags.in_air === true",
"TCAS_RA": "flags.in_air === true",
"GO_AROUND": "true",
"LOST_COMMS": "true",
"UNABLE": "true",
"STANDBY": "true"
}
},
"hooks": {
"on_state_enter": true,
"on_state_exit": true,
"on_timeout": true,
"on_interrupt": true,
"on_handoff": true,
"on_readback_check": true,
"on_pilot_speech": true,
"router_note": "Given pilot utterance → detect intent → if valid in active flow, branch; else jump to flow start with matching intent."
},
"roles": ["pilot", "atc", "system"],
"phases": ["Preflight","Clearance","PushStart","TaxiOut","Departure","Climb","Enroute","Descent","Approach","Landing","TaxiIn","Postflight","Interrupt","LostComms","Missed"],
"states": {
"PREFLIGHT_START": {
"role": "pilot",
"phase": "Preflight",
"prompt_out": "Initial contact with Clearance Delivery.",
"next": [{"to": "CD_CHECK_ATIS"}]
},
"CD_CHECK_ATIS": {
"role": "pilot",
"phase": "Clearance",
"utterance_tpl": "{callsign} information {atis_code}, IFR to {dest}, stand {stand}, request clearance.",
"next": [{"to": "CD_ISSUE_CLR"}],
"on_timeout": [{"after_s": 5, "action": "remind", "say_tpl": "{callsign}, say request."}]
},
"CD_ISSUE_CLR": {
"role": "atc",
"phase": "Clearance",
"say_tpl": "{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.",
"readback_required": ["dest","sid","runway","initial_altitude_ft","squawk"],
"next": [
{"to": "CD_VERIFY_READBACK"},
{"to": "CD_AMEND_CLR", "when": "pilot_requests_amendment === true"}
]
},
"CD_VERIFY_READBACK": {
"role": "pilot",
"phase": "Clearance",
"utterance_tpl": "{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.",
"next": [{"to": "CD_READBACK_CHECK"}]
},
"CD_READBACK_CHECK": {
"role": "atc",
"phase": "Clearance",
"auto": "check_readback",
"readback_required": ["dest","sid","runway","initial_altitude_ft","squawk"],
"ok_next": [{"to": "CD_CLR_COMPLETE"}],
"bad_next": [{"to": "CD_READBACK_CORRECT"}],
"on_timeout": [{"after_s": 6, "action": "say", "say_tpl": "{callsign}, read back clearance."}]
},
"CD_READBACK_CORRECT": {
"role": "atc",
"phase": "Clearance",
"say_tpl": "{callsign}, negative; expect {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.",
"next": [{"to": "CD_VERIFY_READBACK"}]
},
"CD_AMEND_CLR": {
"role": "atc",
"phase": "Clearance",
"say_tpl": "{callsign}, amended clearance: {sid} {transition} departure, runway {runway}.",
"next": [{"to": "CD_VERIFY_READBACK"}]
},
"CD_CLR_COMPLETE": {
"role": "atc",
"phase": "Clearance",
"say_tpl": "{callsign}, readback correct. Start-up at own discretion. Contact Ground {ground_freq} when ready for push and start.",
"actions": [{"set": "flags.current_unit", "to": "DEL"}],
"handoff": {"to": "GROUND","freq": "{ground_freq}"},
"next": [{"to": "GRD_READY_FOR_PUSH"}]
},
"GRD_READY_FOR_PUSH": {
"role": "pilot",
"phase": "PushStart",
"utterance_tpl": "{callsign} stand {stand}, ready for push and start.",
"next": [
{"to": "GRD_PUSH_APPROVE", "when": "push_available === true"},
{"to": "GRD_PUSH_WAIT", "when": "push_available === false"}
]
},
"GRD_PUSH_WAIT": {
"role": "atc",
"phase": "PushStart",
"say_tpl": "{callsign}, push and start approved in {push_delay_min} minutes. Expect taxi via {taxi_route}.",
"timer_next": [{"after_s": 60, "to": "GRD_PUSH_APPROVE"}],
"next": [{"to": "GRD_PUSH_APPROVE"}]
},
"GRD_PUSH_APPROVE": {
"role": "atc",
"phase": "PushStart",
"say_tpl": "{callsign}, push and start approved, facing {runway}. QNH {qnh_hpa}.",
"next": [{"to": "GRD_TAXI_REQUEST"}]
},
"GRD_TAXI_REQUEST": {
"role": "pilot",
"phase": "TaxiOut",
"utterance_tpl": "{callsign}, request taxi.",
"next": [{"to": "GRD_TAXI_INSTR"}]
},
"GRD_TAXI_INSTR": {
"role": "atc",
"phase": "TaxiOut",
"say_tpl": "{callsign}, taxi to runway {runway} via {taxi_route}, hold short runway {runway}.",
"readback_required": ["runway","taxi_route","hold_short"],
"next": [{"to": "GRD_TAXI_READBACK"}]
},
"GRD_TAXI_READBACK": {
"role": "pilot",
"phase": "TaxiOut",
"utterance_tpl": "{callsign} taxi to {runway} via {taxi_route}, holding short {runway}.",
"next": [{"to": "GRD_TAXI_READBACK_CHECK"}]
},
"GRD_TAXI_READBACK_CHECK": {
"role": "atc",
"phase": "TaxiOut",
"auto": "check_readback",
"readback_required": ["runway","taxi_route","hold_short"],
"ok_next": [{"to": "TWR_CONTACT"}],
"bad_next": [{"to": "GRD_TAXI_READBACK_CORRECT"}]
},
"GRD_TAXI_READBACK_CORRECT": {
"role": "atc",
"phase": "TaxiOut",
"say_tpl": "{callsign}, negative; taxi to runway {runway} via {taxi_route}, hold short runway {runway}.",
"next": [{"to": "GRD_TAXI_READBACK"}]
},
"TWR_CONTACT": {
"role": "atc",
"phase": "TaxiOut",
"say_tpl": "{callsign}, contact Tower {tower_freq} when number one.",
"handoff": {"to": "TOWER","freq": "{tower_freq}"},
"next": [{"to": "TWR_LINEUP_REQ"}]
},
"TWR_LINEUP_REQ": {
"role": "pilot",
"phase": "Departure",
"utterance_tpl": "{callsign} holding short {runway}, ready for departure.",
"next": [
{"to": "TWR_LINEUP", "when": "runway_occupied === true"},
{"to": "TWR_TAKEOFF_CLR", "when": "runway_occupied === false"}
]
},
"TWR_LINEUP": {
"role": "atc",
"phase": "Departure",
"say_tpl": "{callsign}, line up and wait runway {runway}.",
"next": [{"to": "TWR_TAKEOFF_CLR"}]
},
"TWR_TAKEOFF_CLR": {
"role": "atc",
"phase": "Departure",
"say_tpl": "{callsign}, wind {surface_wind}, runway {runway} cleared for take-off.",
"readback_required": ["runway","cleared_takeoff"],
"actions": [{"set": "flags.in_air", "to": true}],
"next": [{"to": "TWR_TAKEOFF_READBACK"}]
},
"TWR_TAKEOFF_READBACK": {
"role": "pilot",
"phase": "Departure",
"utterance_tpl": "{callsign} cleared for take-off {runway}.",
"next": [{"to": "TWR_TAKEOFF_READBACK_CHECK"}]
},
"TWR_TAKEOFF_READBACK_CHECK": {
"role": "atc",
"phase": "Departure",
"auto": "check_readback",
"readback_required": ["runway","cleared_takeoff"],
"ok_next": [{"to": "DEP_CONTACT"}],
"bad_next": [{"to": "TWR_TAKEOFF_READBACK_CORRECT"}]
},
"TWR_TAKEOFF_READBACK_CORRECT": {
"role": "atc",
"phase": "Departure",
"say_tpl": "{callsign}, negative; runway {runway}, cleared for take-off.",
"next": [{"to": "TWR_TAKEOFF_READBACK"}]
},
"DEP_CONTACT": {
"role": "atc",
"phase": "Departure",
"say_tpl": "{callsign}, contact Departure {departure_freq}.",
"handoff": {"to": "DEPARTURE","freq": "{departure_freq}"},
"actions": [{"set": "flags.current_unit", "to": "DEP"}],
"next": [{"to": "DEP_IDENT"}]
},
"DEP_IDENT": {
"role": "pilot",
"phase": "Climb",
"utterance_tpl": "{callsign} passing {initial_altitude_ft}, on SID {sid}.",
"next": [{"to": "DEP_CLIMB_INSTR"}]
},
"DEP_CLIMB_INSTR": {
"role": "atc",
"phase": "Climb",
"say_tpl": "{callsign}, climb {climb_altitude_ft} feet, proceed direct {transition} if able.",
"next": [
{"to": "DEP_CLIMB_READBACK", "when": "pilot_able === true"},
{"to": "DEP_UNABLE_DIR", "when": "pilot_able === false"}
]
},
"DEP_UNABLE_DIR": {
"role": "pilot",
"phase": "Climb",
"utterance_tpl": "{callsign} unable direct {transition}.",
"next": [{"to": "DEP_ALT_RTE"}]
},
"DEP_ALT_RTE": {
"role": "atc",
"phase": "Climb",
"say_tpl": "{callsign}, continue SID, report passing {climb_altitude_ft}.",
"next": [{"to": "ENR_HANDOFF"}]
},
"DEP_CLIMB_READBACK": {
"role": "pilot",
"phase": "Climb",
"utterance_tpl": "{callsign} climb {climb_altitude_ft}, direct {transition}.",
"next": [{"to": "ENR_HANDOFF"}]
},
"ENR_HANDOFF": {
"role": "atc",
"phase": "Enroute",
"say_tpl": "{callsign}, contact Center {handoff_freq}.",
"handoff": {"to": "CENTER","freq": "{handoff_freq}"},
"actions": [{"set": "flags.current_unit", "to": "CTR"}],
"next": [{"to": "ENR_CRUISE"}]
},
"ENR_CRUISE": {
"role": "pilot",
"phase": "Enroute",
"auto": "monitor",
"next": [{"to": "DES_INITIATE"}]
},
"DES_INITIATE": {
"role": "atc",
"phase": "Descent",
"say_tpl": "{callsign}, descend via {star} {transition}, QNH {qnh_hpa}.",
"next": [{"to": "DES_READBACK"}]
},
"DES_READBACK": {
"role": "pilot",
"phase": "Descent",
"utterance_tpl": "{callsign} descend via {star} {transition}, QNH {qnh_hpa}.",
"next": [{"to": "APP_HANDOFF"}]
},
"APP_HANDOFF": {
"role": "atc",
"phase": "Descent",
"say_tpl": "{callsign}, contact Approach {approach_freq}.",
"handoff": {"to": "APPROACH","freq": "{approach_freq}"},
"actions": [{"set": "flags.current_unit", "to": "APP"}],
"next": [{"to": "APP_VECTORING"}]
},
"APP_VECTORING": {
"role": "atc",
"phase": "Approach",
"say_tpl": "{callsign}, turn left heading 220, descend to {initial_altitude_ft} feet, reduce speed {speed_restriction}.",
"next": [{"to": "APP_CLEARED_APP"}]
},
"APP_CLEARED_APP": {
"role": "atc",
"phase": "Approach",
"say_tpl": "{callsign}, cleared {approach_type} approach runway {runway}, report established.",
"next": [{"to": "APP_ESTABLISHED"}]
},
"APP_ESTABLISHED": {
"role": "pilot",
"phase": "Approach",
"utterance_tpl": "{callsign} established localizer {runway}.",
"next": [{"to": "TWR_LAND_CONTACT"}]
},
"TWR_LAND_CONTACT": {
"role": "atc",
"phase": "Approach",
"say_tpl": "{callsign}, contact Tower {tower_freq}.",
"handoff": {"to": "TOWER","freq": "{tower_freq}"},
"actions": [{"set": "flags.current_unit", "to": "TWR"}],
"next": [{"to": "TWR_LAND_CLEARABLE"}]
},
"TWR_LAND_CLEARABLE": {
"role": "atc",
"phase": "Landing",
"condition": "runway_available === true",
"say_tpl": "{callsign}, wind {surface_wind}, runway {runway} cleared to land.",
"else_say_tpl": "{callsign}, continue approach, expect late landing clearance.",
"next": [
{"to": "TWR_LAND_READBACK", "when": "runway_available === true"},
{"to": "TWR_CONTINUE_APPROACH", "when": "runway_available === false"}
]
},
"TWR_CONTINUE_APPROACH": {
"role": "atc",
"phase": "Landing",
"say_tpl": "{callsign}, continue approach.",
"next": [{"to": "TWR_LAND_CLEARABLE"}]
},
"TWR_LAND_READBACK": {
"role": "pilot",
"phase": "Landing",
"utterance_tpl": "{callsign} cleared to land {runway}.",
"next": [{"to": "TWR_VACATE"}]
},
"TWR_VACATE": {
"role": "atc",
"phase": "Landing",
"say_tpl": "{callsign}, vacate via {taxi_route}, contact Ground {ground_freq}.",
"actions": [{"set": "flags.in_air", "to": false}],
"handoff": {"to": "GROUND","freq": "{ground_freq}"},
"next": [{"to": "GRD_TAXI_IN_REQ"}]
},
"GRD_TAXI_IN_REQ": {
"role": "pilot",
"phase": "TaxiIn",
"utterance_tpl": "{callsign} runway vacated, request taxi to stand.",
"next": [{"to": "GRD_TAXI_INSTR_IN"}]
},
"GRD_TAXI_INSTR_IN": {
"role": "atc",
"phase": "TaxiIn",
"say_tpl": "{callsign}, taxi to stand {gate} via {taxi_route}.",
"readback_required": ["gate","taxi_route"],
"next": [{"to": "GRD_TAXI_IN_READBACK"}]
},
"GRD_TAXI_IN_READBACK": {
"role": "pilot",
"phase": "TaxiIn",
"utterance_tpl": "{callsign} taxi to stand {gate} via {taxi_route}.",
"next": [{"to": "GRD_TAXI_IN_READBACK_CHECK"}]
},
"GRD_TAXI_IN_READBACK_CHECK": {
"role": "atc",
"phase": "TaxiIn",
"auto": "check_readback",
"readback_required": ["gate","taxi_route"],
"ok_next": [{"to": "FLOW_COMPLETE"}],
"bad_next": [{"to": "GRD_TAXI_IN_READBACK_CORRECT"}]
},
"GRD_TAXI_IN_READBACK_CORRECT": {
"role": "atc",
"phase": "TaxiIn",
"say_tpl": "{callsign}, negative; taxi to stand {gate} via {taxi_route}.",
"next": [{"to": "GRD_TAXI_IN_READBACK"}]
},
"FLOW_COMPLETE": {
"role": "system",
"phase": "Postflight",
"auto": "end",
"next": []
},
/* ===== Interrupts (conditioned) ===== */
"INT_MAYDAY": {
"role": "pilot",
"phase": "Interrupt",
"guard": "flags.in_air === true",
"utterance_tpl": "MAYDAY MAYDAY MAYDAY, {callsign}, {problem}, intentions {intent}.",
"priority": "highest",
"actions": [{"set": "flags.emergency_active", "to": true}],
"next": [{"to": "ATC_MAYDAY_VECTOR"}]
},
"ATC_MAYDAY_VECTOR": {
"role": "atc",
"phase": "Interrupt",
"say_tpl": "{callsign}, roger MAYDAY, fly heading {emergency_heading}, climb/descend {initial_altitude_ft}, cleared direct {dest} when able, QNH {qnh_hpa}.",
"next": [{"to": "ATC_MAYDAY_COORD"}]
},
"ATC_MAYDAY_COORD": {
"role": "system",
"phase": "Interrupt",
"actions": ["alert_emergency_services","notify_adjacent_units"],
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
"INT_PANPAN": {
"role": "pilot",
"phase": "Interrupt",
"guard": "flags.in_air === true",
"utterance_tpl": "PAN PAN PAN, {callsign}, {problem}, request priority.",
"priority": "high",
"actions": [{"set": "flags.emergency_active", "to": true}],
"next": [{"to": "ATC_PAN_ACK"}]
},
"ATC_PAN_ACK": {
"role": "atc",
"phase": "Interrupt",
"say_tpl": "{callsign}, PAN acknowledged, priority granted, expect vectors direct {dest} or nearest suitable.",
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
"INT_TCAS_RA": {
"role": "pilot",
"phase": "Interrupt",
"guard": "flags.in_air === true",
"utterance_tpl": "{callsign} TCAS RA, deviating.",
"actions": ["suspend_clearances"],
"next": [{"to": "ATC_TCAS_ACK"}]
},
"ATC_TCAS_ACK": {
"role": "atc",
"phase": "Interrupt",
"say_tpl": "{callsign}, roger TCAS RA, report clear of conflict.",
"next": [{"to": "ATC_TCAS_RESUME"}]
},
"ATC_TCAS_RESUME": {
"role": "pilot",
"phase": "Interrupt",
"utterance_tpl": "{callsign} clear of conflict, returning to clearance.",
"actions": [{"set": "flags.emergency_active", "to": false}],
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
"INT_GOA": {
"role": "pilot",
"phase": "Missed",
"utterance_tpl": "{callsign} going around, {missed_approach}.",
"actions": [{"set": "flags.in_air", "to": true}],
"next": [{"to": "ATC_GOA_INSTR"}]
},
"ATC_GOA_INSTR": {
"role": "atc",
"phase": "Missed",
"say_tpl": "{callsign}, roger go-around, fly published missed approach, climb {initial_altitude_ft}, contact Approach {approach_freq}.",
"handoff": {"to": "APPROACH","freq": "{approach_freq}"},
"next": [{"to": "APP_VECTORING"}]
},
"INT_NORDO": {
"role": "system",
"phase": "LostComms",
"trigger": "no_reply > policies.timeouts.lost_comms_detect_after_s",
"actions": ["lost_comms_procedure"],
"next": [{"to": "ATC_NORDO_ACTION"}]
},
"ATC_NORDO_ACTION": {
"role": "atc",
"phase": "LostComms",
"say_tpl": "(Transmitted blind) {callsign}, if you read, squawk IDENT and continue per last clearance. Expect vectors.",
"next": [{"to": "SYSTEM_NORDO_COORD"}]
},
"SYSTEM_NORDO_COORD": {
"role": "system",
"phase": "LostComms",
"actions": ["notify_adjacent_units","monitor_light_gun","publish_ATIS_note"],
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
"INT_UNABLE": {
"role": "pilot",
"phase": "Interrupt",
"utterance_tpl": "{callsign} unable {instruction}.",
"next": [{"to": "ATC_ALT_PROPOSAL"}]
},
"ATC_ALT_PROPOSAL": {
"role": "atc",
"phase": "Interrupt",
"say_tpl": "{callsign}, alternative: {alt_instruction}.",
"next": [
{"to": "PILOT_ACCEPT_ALT"},
{"to": "PILOT_REJECT_ALT"}
]
},
"PILOT_ACCEPT_ALT": {
"role": "pilot",
"phase": "Interrupt",
"utterance_tpl": "{callsign} wilco.",
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
"PILOT_REJECT_ALT": {
"role": "pilot",
"phase": "Interrupt",
"utterance_tpl": "{callsign} negative, request {intent}.",
"next": [{"to": "ATC_ALT_PROPOSAL"}]
},
"INT_STANDBY": {
"role": "pilot",
"phase": "Interrupt",
"utterance_tpl": "{callsign} standby.",
"actions": ["pause_exchange"],
"next": [{"to": "RESUME_PRIOR_FLOW"}]
},
/* ===== Generic glue & router ===== */
"RESUME_PRIOR_FLOW": {
"role": "system",
"phase": "Interrupt",
"auto": "pop_stack_or_route_by_intent",
"actions": [
{"if": "flags.emergency_active === true && flags.in_air === true", "set": "flags.emergency_active", "to": false}
],
"next": []
},
"GEN_NO_REPLY": {
"role": "system",
"phase": "Interrupt",
"trigger": "no_reply",
"policy_ref": "policies.no_reply_sequence",
"escalate_to": "INT_NORDO",
"next": []
}
}
}
export default atcDecisionTree;

View File

@@ -33,6 +33,35 @@ export interface DecisionNodeAutoTrigger {
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
@@ -114,6 +143,8 @@ export interface DecisionNodeModel {
trigger?: string
frequency?: string
frequencyName?: string
triggers?: DecisionNodeTrigger[]
conditions?: DecisionNodeCondition[]
transitions: DecisionNodeTransition[]
layout?: DecisionNodeLayout
metadata?: DecisionNodeMetadata
@@ -198,10 +229,13 @@ export interface RuntimeDecisionState {
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
@@ -216,6 +250,12 @@ export interface RuntimeDecisionTree {
states: Record<string, RuntimeDecisionState>
}
export interface RuntimeDecisionSystem {
main: string
order: string[]
flows: Record<string, RuntimeDecisionTree>
}
export interface DecisionFlowSummary {
id: string
slug: string

View File

@@ -1,10 +1,11 @@
export interface LLMDecisionInput {
state_id: string
state: any
candidates: Array<{ id: string; state: any }>
candidates: Array<{ id: string; state: any; flow?: string }>
variables: Record<string, any>
flags: Record<string, any>
pilot_utterance: string
flow_slug?: string
}
export interface LLMDecision {
@@ -14,4 +15,6 @@ export interface LLMDecision {
controller_say_tpl?: string
off_schema?: boolean
radio_check?: boolean
activate_flow?: string
resume_previous?: boolean
}

View File

@@ -1,7 +1,8 @@
// communicationsEngine composable
import { ref, computed, readonly } from 'vue'
import { ref, computed, readonly, reactive } from 'vue'
import type {
RuntimeDecisionTree,
RuntimeDecisionSystem,
RuntimeDecisionState,
RuntimeDecisionAutoTransition,
DecisionNodeAutoTrigger,
@@ -93,6 +94,19 @@ export interface EngineLog {
state: string
radioCheck?: boolean
offSchema?: boolean
flow?: string
}
interface FlowSnapshot {
tree: RuntimeDecisionTree
variables: Record<string, any>
flags: EngineFlags
telemetry: TelemetryState
currentStateId: string
communicationLog: EngineLog[]
autoHistory: Map<string, Set<string>>
flightContext: FlightContext
ready: boolean
}
type TelemetryState = {
@@ -111,6 +125,33 @@ export function normalizeATCText(text: string, context: Record<string, any>): st
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, any>): string {
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
const parts = key.split('.')
@@ -121,9 +162,15 @@ function renderTpl(tpl: string, ctx: Record<string, any>): string {
}
export default function useCommunicationsEngine() {
const runtimeSystem = ref<RuntimeDecisionSystem | null>(null)
const flowOrder = ref<string[]>([])
const activeFlowSlug = ref<string>('')
const tree = ref<RuntimeDecisionTree | null>(null)
const ready = ref(false)
const flowSnapshots = reactive<Record<string, FlowSnapshot>>({})
const states = computed<Record<string, RuntimeDecisionState>>(() => tree.value?.states ?? {})
const variables = ref<Record<string, any>>({})
@@ -148,31 +195,8 @@ export default function useCommunicationsEngine() {
heading_deg: 0,
})
const autoExecutionHistory = new Map<string, Set<string>>()
// Flight context used for pm_alt.vue integration
const flightContext = ref<FlightContext>({
callsign: '',
aircraft: 'A320',
dep: 'EDDF',
dest: 'EDDM',
stand: 'A12',
runway: '25R',
squawk: '1234',
atis_code: 'K',
sid: 'ANEKI7S',
transition: 'ANEKI',
flight_level: 'FL360',
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',
phase: 'clearance'
})
const flightContext = ref<FlightContext>(createDefaultFlightContext())
const currentState = computed<RuntimeDecisionState & { id: string } | null>(() => {
const stateMap = states.value
@@ -183,6 +207,140 @@ export default function useCommunicationsEngine() {
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<string, any>) {
variables.value = next
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].variables = next
}
}
function assignActiveFlags(next: EngineFlags) {
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,
...(baseFlags as EngineFlags),
}
if (!Array.isArray(flags.stack)) {
flags.stack = []
}
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<string, Set<string>>()
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
}
const nextCandidates = computed<string[]>(() => {
const s = currentState.value
if (!s) return []
@@ -210,69 +368,137 @@ export default function useCommunicationsEngine() {
return tree.value
}
function resetAutoHistory(stateId: string) {
autoExecutionHistory.set(stateId, new Set())
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) {
if (!autoExecutionHistory.has(stateId)) {
autoExecutionHistory.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())
}
autoExecutionHistory.get(stateId)!.add(transitionId)
snapshot.autoHistory.get(stateId)!.add(transitionId)
}
function hasAutoExecuted(stateId: string, transitionId: string): boolean {
const set = autoExecutionHistory.get(stateId)
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) {
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,
const system: RuntimeDecisionSystem = {
main: treeData.slug,
order: [treeData.slug],
flows: { [treeData.slug]: treeData },
}
if (!Array.isArray(flags.value.stack)) {
flags.value.stack = []
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]
}
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,
for (const slug of order) {
const treeData = system.flows[slug]
if (!treeData) continue
flowSnapshots[slug] = createSnapshotFromTree(treeData)
}
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,
})
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('')
}
autoExecutionHistory.clear()
resetAutoHistory(currentStateId.value)
flightContext.value.phase = 'clearance'
ready.value = true
evaluateAutoTransitions()
}
function loadRuntimeTree(data: RuntimeDecisionTree) {
resetEngineFromTree(data)
}
function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
resetEngineFromSystem(data, options)
}
const activeFlow = computed(() => activeFlowSlug.value)
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,
}
})
})
function setActiveFlow(slug: string) {
if (!slug || !flowSnapshots[slug]) {
throw new Error(`Flow snapshot not loaded: ${slug}`)
}
activateFlow(slug)
ready.value = true
queueMicrotask(() => evaluateAutoTransitions())
}
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<RuntimeDecisionTree>(`/api/decision-flows/${slug}/runtime`)
resetEngineFromTree(data)
const data = await fetcher<RuntimeDecisionSystem>('/api/decision-flows/runtime')
const activeSlug = slug && data.flows[slug] ? slug : data.main
resetEngineFromSystem(data, { activeSlug })
}
function normalizeComparableValue(value: any): any {
@@ -417,7 +643,7 @@ export default function useCommunicationsEngine() {
function initializeFlight(fpl: any) {
const runtime = ensureTree()
// Set variables
variables.value = {
const nextVariables = {
...variables.value,
callsign: fpl.callsign || fpl.callsign,
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
@@ -448,6 +674,7 @@ export default function useCommunicationsEngine() {
remarks: 'standard',
time_now: new Date().toISOString()
}
assignActiveVariables(nextVariables)
// Update flight context
Object.assign(flightContext.value, {
@@ -455,7 +682,7 @@ export default function useCommunicationsEngine() {
phase: 'clearance'
})
flags.value = {
const nextFlags: EngineFlags = {
...flags.value,
in_air: false,
emergency_active: false,
@@ -464,10 +691,11 @@ export default function useCommunicationsEngine() {
off_schema_count: 0,
radio_checks_done: 0
}
assignActiveFlags(nextFlags)
currentStateId.value = runtime.start_state
communicationLog.value = []
resetAutoHistory(currentStateId.value)
setActiveStateId(runtime.start_state)
assignCommunicationLog([])
resetAutoHistory(runtime.start_state)
}
function updateFrequencyVariables(update: Partial<Record<FrequencyVariableKey, string>>) {
@@ -495,7 +723,7 @@ export default function useCommunicationsEngine() {
throw new Error('Decision state unavailable')
}
const candidates = nextCandidates.value
.map(id => ({ id, state: states.value[id] }))
.map(id => ({ id, state: states.value[id], flow: runtime.slug }))
.filter(candidate => candidate.state)
return {
@@ -506,6 +734,7 @@ export default function useCommunicationsEngine() {
flags: { ...flags.value },
pilot_utterance: pilotTranscript,
tree: runtime.name,
flow_slug: runtime.slug,
}
}
@@ -530,6 +759,14 @@ export default function useCommunicationsEngine() {
flags.value.stack = decision.stack.slice()
}
if (decision.activate_flow && decision.activate_flow !== activeFlowSlug.value) {
try {
setActiveFlow(decision.activate_flow)
} 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}`)
@@ -624,7 +861,7 @@ export default function useCommunicationsEngine() {
const fallback = typeof raw === 'number' ? raw : Number(raw)
next[key] = Number.isNaN(fallback) ? current : fallback
}
telemetry.value = next
assignActiveTelemetry(next)
queueMicrotask(() => evaluateAutoTransitions())
}
@@ -693,7 +930,7 @@ export default function useCommunicationsEngine() {
flags.value.stack.push(currentStateId.value)
}
currentStateId.value = stateId
setActiveStateId(stateId)
resetAutoHistory(stateId)
const s = currentState.value
if (!s) return
@@ -764,7 +1001,8 @@ export default function useCommunicationsEngine() {
normalized: normalizeATCText(msg, exposeCtxFlat()),
state: stateId,
radioCheck: options.radioCheck,
offSchema: options.offSchema
offSchema: options.offSchema,
flow: activeFlowSlug.value || undefined,
}
communicationLog.value.push(entry)
}
@@ -855,7 +1093,9 @@ export default function useCommunicationsEngine() {
nextCandidates,
activeFrequency,
communicationLog: readonly(communicationLog),
clearCommunicationLog: () => { communicationLog.value = [] },
clearCommunicationLog: () => { assignCommunicationLog([]) },
activeFlow,
availableFlows,
// pm_alt.vue integration
flightContext: readonly(flightContext),
@@ -865,7 +1105,9 @@ export default function useCommunicationsEngine() {
initializeFlight,
updateFrequencyVariables,
loadRuntimeTree,
loadRuntimeSystem,
fetchRuntimeTree,
setActiveFlow,
isReady,
// Communication