mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-06-28 11:45:37 +08:00
- Pass as top-level field in PTT requests so Whisper STT results are linked to the correct Python backend session - Add namespaced helper in pm.vue (info/warn/error/debug/group) controlled by localStorage PM_DEBUG flag; logs transmit/response cycles, TTS calls, flag/variable syncs, and fallback warnings - Log backend session creation context (flow, start state, vars, flags) in startMonitoring - Fix typo in text input hint: STT fails not PTT fails and fix: sync backend variables to frontend after each transmission The ATC say template was rendered using the frontend engine's local variable defaults (squawk '1234', hardcoded SID, etc.) instead of the authoritative values from the Python backend session. This caused the spoken clearance and the readback prompt to show different squawk codes. - After each backend transmission response, sync all response.variables into vars.value (same pattern already used for flags) - Prefer controller_say_rendered (pre-rendered by backend) over the raw template for TTS scheduling, eliminating any remaining dependency on local variable state for the ATC speech text
1518 lines
54 KiB
TypeScript
1518 lines
54 KiB
TypeScript
// communicationsEngine composable
|
|
import { ref, computed, readonly, reactive } from 'vue'
|
|
import type {
|
|
RuntimeDecisionTree,
|
|
RuntimeDecisionSystem,
|
|
RuntimeDecisionState,
|
|
RuntimeDecisionAutoTransition,
|
|
DecisionNodeAutoTrigger,
|
|
} from '../types/decision'
|
|
import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm'
|
|
import { normalizeRadioPhrase } from './radioSpeech'
|
|
|
|
// --- DecisionTree runtime types ---
|
|
type Role = 'pilot' | 'atc' | 'system'
|
|
type Phase = string
|
|
|
|
interface EngineFlags {
|
|
in_air: boolean
|
|
emergency_active: boolean
|
|
current_unit: string
|
|
stack: string[]
|
|
off_schema_count: number
|
|
radio_checks_done: number
|
|
session_id: string
|
|
[key: string]: any
|
|
}
|
|
|
|
// Flight phases and communication steps for better integration
|
|
export const FLIGHT_PHASES = [
|
|
{ id: 'clearance', name: 'Clearance Delivery', frequency: '121.900', action: 'Request IFR clearance' },
|
|
{ id: 'ground', name: 'Ground Control', frequency: '121.700', action: 'Request pushback and taxi' },
|
|
{ id: 'tower', name: 'Tower', frequency: '118.700', action: 'Request takeoff clearance' },
|
|
{ id: 'departure', name: 'Departure', frequency: '125.350', action: 'Initial contact after takeoff' },
|
|
{ id: 'enroute', name: 'Center', frequency: '121.800', action: 'Cruise flight monitoring' },
|
|
{ id: 'approach', name: 'Approach', frequency: '120.800', action: 'Approach clearance' },
|
|
{ id: 'landing', name: 'Tower (Landing)', frequency: '118.700', action: 'Landing clearance' },
|
|
{ id: 'taxiin', name: 'Ground (Taxi In)', frequency: '121.700', action: 'Taxi to gate' }
|
|
]
|
|
|
|
export const COMMUNICATION_STEPS = [
|
|
{
|
|
id: 'cd_request',
|
|
phase: 'clearance',
|
|
trigger: 'pilot',
|
|
frequency: '121.900',
|
|
frequencyName: 'Clearance Delivery',
|
|
pilot: '{callsign} information {atis_code}, IFR to {dest}, stand {stand}, request clearance.',
|
|
atc: '{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.',
|
|
pilotResponse: '{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.'
|
|
}
|
|
// Additional steps can be defined here
|
|
]
|
|
|
|
type FrequencyVariableKey = 'atis_freq'
|
|
| 'delivery_freq'
|
|
| 'ground_freq'
|
|
| 'tower_freq'
|
|
| 'departure_freq'
|
|
| 'approach_freq'
|
|
| 'handoff_freq'
|
|
|
|
// --- Load ATC decision tree ---
|
|
export interface FlightContext {
|
|
callsign: string
|
|
aircraft: string
|
|
dep: string
|
|
dest: string
|
|
stand: string
|
|
runway: string
|
|
squawk: string
|
|
atis_code: string
|
|
sid: string
|
|
transition: string
|
|
flight_level: string
|
|
ground_freq: string
|
|
tower_freq: string
|
|
departure_freq: string
|
|
approach_freq: string
|
|
handoff_freq: string
|
|
atis_freq?: string
|
|
qnh_hpa: number | string
|
|
taxi_route: string
|
|
remarks?: string
|
|
time_now?: string
|
|
phase: string
|
|
lastTransmission?: string
|
|
awaitingResponse?: boolean
|
|
}
|
|
|
|
export interface EngineLog {
|
|
timestamp: Date
|
|
frequency?: string
|
|
speaker: Role
|
|
message: string
|
|
normalized: string
|
|
state: string
|
|
radioCheck?: boolean
|
|
offSchema?: boolean
|
|
flow?: string
|
|
}
|
|
|
|
interface FlowSnapshot {
|
|
tree: RuntimeDecisionTree
|
|
variables: Record<string, any>
|
|
flags: EngineFlags
|
|
telemetry: TelemetryState
|
|
currentStateId: string
|
|
communicationLog: EngineLog[]
|
|
autoHistory: Map<string, Set<string>>
|
|
flightContext: FlightContext
|
|
ready: boolean
|
|
}
|
|
|
|
type TelemetryState = {
|
|
altitude_ft: number
|
|
speed_kts: number
|
|
groundspeed_kts: number
|
|
vertical_speed_fpm: number
|
|
latitude_deg: number
|
|
longitude_deg: number
|
|
heading_deg: number
|
|
[key: string]: number
|
|
}
|
|
|
|
function createSessionId(): string {
|
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
}
|
|
|
|
export function normalizeATCText(text: string, context: Record<string, any>): string {
|
|
const rendered = renderTpl(text, context)
|
|
return normalizeRadioPhrase(rendered)
|
|
}
|
|
|
|
function createDefaultFlightContext(): FlightContext {
|
|
return {
|
|
callsign: '',
|
|
aircraft: 'A320',
|
|
dep: 'EDDF',
|
|
dest: 'EDDM',
|
|
stand: 'A12',
|
|
runway: '25R',
|
|
squawk: '1234',
|
|
atis_code: 'K',
|
|
sid: 'ANEKI7S',
|
|
transition: 'ANEKI',
|
|
flight_level: 'FL360',
|
|
atis_freq: '118.025',
|
|
ground_freq: '121.700',
|
|
tower_freq: '118.700',
|
|
departure_freq: '125.350',
|
|
approach_freq: '120.800',
|
|
handoff_freq: '121.800',
|
|
qnh_hpa: 1015,
|
|
taxi_route: 'A, V',
|
|
remarks: 'standard',
|
|
time_now: undefined,
|
|
phase: 'clearance',
|
|
}
|
|
}
|
|
|
|
function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
|
// Handles both {variable} (old schema) and {{variable}} (new YAML/Jinja2 schema).
|
|
// Double-brace is matched first so {{x}} isn't partially matched as {x} with trailing brace.
|
|
return tpl.replace(/\{\{([\w.]+)\}\}|\{([\w.]+)\}/g, (_m, key1, key2) => {
|
|
const key = key1 ?? key2
|
|
const parts = key.split('.')
|
|
let cur: any = ctx
|
|
for (const p of parts) cur = cur?.[p]
|
|
return (cur ?? '').toString()
|
|
})
|
|
}
|
|
|
|
function stateSayTpl(s: RuntimeDecisionState): string | undefined {
|
|
return s.say_tpl ?? s.say_template
|
|
}
|
|
|
|
function stateUtteranceTpl(s: RuntimeDecisionState): string | undefined {
|
|
return s.utterance_tpl ?? s.expected_pilot_template
|
|
}
|
|
|
|
export default function useCommunicationsEngine() {
|
|
const runtimeSystem = ref<RuntimeDecisionSystem | null>(null)
|
|
const flowOrder = ref<string[]>([])
|
|
const activeFlowSlug = ref<string>('')
|
|
const sessionId = ref<string>('')
|
|
const flowStack = ref<string[]>([])
|
|
|
|
const tree = ref<RuntimeDecisionTree | null>(null)
|
|
const ready = ref(false)
|
|
const lastDecisionTrace = ref<LLMDecisionTrace | null>(null)
|
|
|
|
const flowSnapshots = reactive<Record<string, FlowSnapshot>>({})
|
|
|
|
const states = computed<Record<string, RuntimeDecisionState>>(() => tree.value?.states ?? {})
|
|
|
|
const variables = ref<Record<string, any>>({})
|
|
const flags = ref<EngineFlags>({
|
|
in_air: false,
|
|
emergency_active: false,
|
|
current_unit: 'DEL',
|
|
stack: [],
|
|
off_schema_count: 0,
|
|
radio_checks_done: 0,
|
|
session_id: '',
|
|
})
|
|
const currentStateId = ref<string>('')
|
|
const communicationLog = ref<EngineLog[]>([])
|
|
|
|
const telemetry = ref<TelemetryState>({
|
|
altitude_ft: 0,
|
|
speed_kts: 0,
|
|
groundspeed_kts: 0,
|
|
vertical_speed_fpm: 0,
|
|
latitude_deg: 0,
|
|
longitude_deg: 0,
|
|
heading_deg: 0,
|
|
})
|
|
|
|
// Flight context used for pm_alt.vue integration
|
|
const flightContext = ref<FlightContext>(createDefaultFlightContext())
|
|
|
|
const currentState = computed<RuntimeDecisionState & { id: string } | null>(() => {
|
|
const stateMap = states.value
|
|
const fallbackId = tree.value?.start_state
|
|
const id = currentStateId.value || fallbackId
|
|
if (!id) return null
|
|
const base = stateMap[id]
|
|
return base ? { ...base, id } : null
|
|
})
|
|
|
|
function ensureSnapshot(slug: string): FlowSnapshot {
|
|
const snapshot = flowSnapshots[slug]
|
|
if (!snapshot) {
|
|
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
function getActiveSnapshot(): FlowSnapshot | null {
|
|
if (!activeFlowSlug.value) return null
|
|
return flowSnapshots[activeFlowSlug.value] || null
|
|
}
|
|
|
|
function assignActiveVariables(next: Record<string, any>) {
|
|
variables.value = next
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].variables = next
|
|
}
|
|
}
|
|
|
|
function ensureSessionValue(raw?: string): string {
|
|
if (raw && typeof raw === 'string' && raw.trim().length) {
|
|
sessionId.value = raw.trim()
|
|
} else if (!sessionId.value) {
|
|
sessionId.value = createSessionId()
|
|
}
|
|
return sessionId.value
|
|
}
|
|
|
|
function assignActiveFlags(next: EngineFlags) {
|
|
const normalizedSession = ensureSessionValue(next?.session_id)
|
|
next.session_id = normalizedSession
|
|
flags.value = next
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].flags = next
|
|
}
|
|
}
|
|
|
|
function assignActiveTelemetry(next: TelemetryState) {
|
|
telemetry.value = next
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].telemetry = next
|
|
}
|
|
}
|
|
|
|
function assignCommunicationLog(next: EngineLog[]) {
|
|
communicationLog.value = next
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].communicationLog = next
|
|
}
|
|
}
|
|
|
|
function assignFlightContext(next: FlightContext) {
|
|
flightContext.value = next
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].flightContext = next
|
|
}
|
|
}
|
|
|
|
function setActiveStateId(stateId: string) {
|
|
currentStateId.value = stateId
|
|
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
|
flowSnapshots[activeFlowSlug.value].currentStateId = stateId
|
|
}
|
|
}
|
|
|
|
function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot {
|
|
// The Python backend serializes variables/flags as VariableDefinition/FlagDefinition objects
|
|
// ({ name, type, initial, mutable_by }) rather than raw values. Unwrap them here so
|
|
// template rendering gets the primitive value instead of [object Object].
|
|
const rawVars = treeData.variables ?? {}
|
|
const variables = Object.fromEntries(
|
|
Object.entries(rawVars).map(([k, v]) => [
|
|
k,
|
|
v !== null && typeof v === 'object' && 'initial' in v ? (v as any).initial : v,
|
|
])
|
|
)
|
|
const rawFlags = treeData.flags ?? {}
|
|
const unwrappedFlags = Object.fromEntries(
|
|
Object.entries(rawFlags).map(([k, v]) => [
|
|
k,
|
|
v !== null && typeof v === 'object' && 'initial' in v ? (v as any).initial : v,
|
|
])
|
|
)
|
|
const baseFlags = typeof unwrappedFlags === 'object' ? unwrappedFlags : {}
|
|
const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : []
|
|
const flags: EngineFlags = {
|
|
in_air: Boolean((baseFlags as any).in_air),
|
|
emergency_active: Boolean((baseFlags as any).emergency_active),
|
|
current_unit: typeof (baseFlags as any).current_unit === 'string'
|
|
? (baseFlags as any).current_unit
|
|
: 'DEL',
|
|
stack,
|
|
off_schema_count: Number((baseFlags as any).off_schema_count) || 0,
|
|
radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0,
|
|
session_id: '',
|
|
...(baseFlags as EngineFlags),
|
|
}
|
|
if (!Array.isArray(flags.stack)) {
|
|
flags.stack = []
|
|
}
|
|
if (typeof flags.session_id !== 'string') {
|
|
flags.session_id = ''
|
|
}
|
|
|
|
const telemetry: TelemetryState = {
|
|
altitude_ft: Number((baseFlags as any).altitude_ft) || 0,
|
|
speed_kts: Number((baseFlags as any).speed_kts) || 0,
|
|
groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0,
|
|
vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0,
|
|
latitude_deg: Number((baseFlags as any).latitude_deg) || 0,
|
|
longitude_deg: Number((baseFlags as any).longitude_deg) || 0,
|
|
heading_deg: Number((baseFlags as any).heading_deg) || 0,
|
|
}
|
|
|
|
const log: EngineLog[] = []
|
|
const snapshotContext = createDefaultFlightContext()
|
|
snapshotContext.phase = 'clearance'
|
|
|
|
const autoHistory = new Map<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
|
|
}
|
|
|
|
function resolveFlowMode(slug: string | undefined): FlowActivationMode {
|
|
if (!slug) return 'parallel'
|
|
const system = runtimeSystem.value
|
|
if (!system) return 'parallel'
|
|
if (slug === system.main) return 'main'
|
|
const treeData = system.flows[slug]
|
|
if (!treeData) return 'parallel'
|
|
if (treeData.entry_mode === 'main') return 'main'
|
|
if (treeData.entry_mode === 'linear') return 'linear'
|
|
return 'parallel'
|
|
}
|
|
|
|
function normalizeFlowInstruction(target: string | FlowActivationInstruction | null | undefined): FlowActivationInstruction | null {
|
|
if (!target) return null
|
|
if (typeof target === 'string') {
|
|
return { slug: target, mode: resolveFlowMode(target) }
|
|
}
|
|
if (!target.slug) return null
|
|
return { slug: target.slug, mode: target.mode ?? resolveFlowMode(target.slug) }
|
|
}
|
|
|
|
function setActiveFlow(target: string | FlowActivationInstruction, options: { skipStack?: boolean } = {}) {
|
|
const instruction = normalizeFlowInstruction(target)
|
|
if (!instruction) {
|
|
throw new Error(`Flow snapshot not loaded: ${typeof target === 'string' ? target : target?.slug}`)
|
|
}
|
|
const slug = instruction.slug
|
|
if (!slug || !flowSnapshots[slug]) {
|
|
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
|
}
|
|
const previous = activeFlowSlug.value
|
|
const shouldPush = !options.skipStack
|
|
&& instruction.mode === 'linear'
|
|
&& previous
|
|
&& previous !== slug
|
|
if (shouldPush) {
|
|
flowStack.value.push(previous)
|
|
}
|
|
activateFlow(slug)
|
|
ready.value = true
|
|
queueMicrotask(() => evaluateAutoTransitions())
|
|
}
|
|
const nextCandidates = computed<string[]>(() => {
|
|
const s = currentState.value
|
|
if (!s) return []
|
|
const entries = [
|
|
...(s.next ?? []),
|
|
...(s.ok_next ?? []),
|
|
...(s.bad_next ?? []),
|
|
...(s.timer_next?.map(t => ({ to: t.to })) ?? []),
|
|
]
|
|
return Array.from(
|
|
new Set(
|
|
entries
|
|
.map(entry => entry?.to)
|
|
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
)
|
|
)
|
|
})
|
|
|
|
const isReady = computed(() => ready.value)
|
|
|
|
function ensureTree(): RuntimeDecisionTree {
|
|
if (!tree.value) {
|
|
throw new Error('Decision tree not loaded')
|
|
}
|
|
return tree.value
|
|
}
|
|
|
|
function resetAutoHistory(stateId: string, slug = activeFlowSlug.value) {
|
|
if (!slug) return
|
|
const snapshot = ensureSnapshot(slug)
|
|
snapshot.autoHistory.set(stateId, new Set())
|
|
}
|
|
|
|
function markAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value) {
|
|
if (!slug) return
|
|
const snapshot = ensureSnapshot(slug)
|
|
if (!snapshot.autoHistory.has(stateId)) {
|
|
snapshot.autoHistory.set(stateId, new Set())
|
|
}
|
|
snapshot.autoHistory.get(stateId)!.add(transitionId)
|
|
}
|
|
|
|
function hasAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value): boolean {
|
|
if (!slug) return false
|
|
const snapshot = ensureSnapshot(slug)
|
|
const set = snapshot.autoHistory.get(stateId)
|
|
return set ? set.has(transitionId) : false
|
|
}
|
|
|
|
function resetEngineFromTree(treeData: RuntimeDecisionTree) {
|
|
const system: RuntimeDecisionSystem = {
|
|
main: treeData.slug,
|
|
order: [treeData.slug],
|
|
flows: { [treeData.slug]: treeData },
|
|
}
|
|
resetEngineFromSystem(system, { activeSlug: treeData.slug })
|
|
}
|
|
|
|
function resetEngineFromSystem(system: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
|
runtimeSystem.value = system
|
|
const order = Array.isArray(system.order) && system.order.length
|
|
? [...system.order]
|
|
: Object.keys(system.flows)
|
|
flowOrder.value = order
|
|
|
|
for (const key of Object.keys(flowSnapshots)) {
|
|
delete flowSnapshots[key]
|
|
}
|
|
|
|
for (const slug of order) {
|
|
const treeData = system.flows[slug]
|
|
if (!treeData) continue
|
|
flowSnapshots[slug] = createSnapshotFromTree(treeData)
|
|
}
|
|
|
|
flowStack.value = []
|
|
sessionId.value = createSessionId()
|
|
for (const slug of order) {
|
|
const snapshot = flowSnapshots[slug]
|
|
if (snapshot) {
|
|
snapshot.flags.session_id = sessionId.value
|
|
}
|
|
}
|
|
|
|
const preferred = options.activeSlug && system.flows[options.activeSlug]
|
|
? options.activeSlug
|
|
: system.main && system.flows[system.main]
|
|
? system.main
|
|
: order[0]
|
|
|
|
if (preferred) {
|
|
activateFlow(preferred)
|
|
ready.value = true
|
|
const snapshot = ensureSnapshot(preferred)
|
|
resetAutoHistory(snapshot.currentStateId, preferred)
|
|
evaluateAutoTransitions()
|
|
} else {
|
|
activeFlowSlug.value = ''
|
|
tree.value = null
|
|
ready.value = false
|
|
assignActiveVariables({})
|
|
assignActiveFlags({
|
|
in_air: false,
|
|
emergency_active: false,
|
|
current_unit: 'DEL',
|
|
stack: [],
|
|
off_schema_count: 0,
|
|
radio_checks_done: 0,
|
|
session_id: ensureSessionValue(),
|
|
})
|
|
assignActiveTelemetry({
|
|
altitude_ft: 0,
|
|
speed_kts: 0,
|
|
groundspeed_kts: 0,
|
|
vertical_speed_fpm: 0,
|
|
latitude_deg: 0,
|
|
longitude_deg: 0,
|
|
heading_deg: 0,
|
|
})
|
|
assignCommunicationLog([])
|
|
assignFlightContext(createDefaultFlightContext())
|
|
setActiveStateId('')
|
|
}
|
|
}
|
|
|
|
function loadRuntimeTree(data: RuntimeDecisionTree) {
|
|
resetEngineFromTree(data)
|
|
}
|
|
|
|
function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
|
resetEngineFromSystem(data, options)
|
|
}
|
|
|
|
const activeFlow = computed(() => activeFlowSlug.value)
|
|
|
|
const mainFlowSlug = computed(() => runtimeSystem.value?.main || '')
|
|
|
|
const availableFlows = computed(() => {
|
|
if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string; mode: string }>
|
|
return flowOrder.value
|
|
.filter((slug) => Boolean(runtimeSystem.value!.flows[slug]))
|
|
.map((slug) => {
|
|
const treeData = runtimeSystem.value!.flows[slug]
|
|
return {
|
|
slug,
|
|
name: treeData.name || slug,
|
|
description: treeData.description,
|
|
start: treeData.start_state,
|
|
mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'),
|
|
}
|
|
})
|
|
})
|
|
|
|
async function fetchRuntimeTree(slug = 'icao_atc_decision_tree', baseUrl?: string) {
|
|
ready.value = false
|
|
let data: RuntimeDecisionSystem
|
|
if (baseUrl) {
|
|
const res = await fetch(`${baseUrl}/api/decision-flows/runtime`)
|
|
if (!res.ok) throw new Error(`Failed to load flows from ${baseUrl}: ${res.status}`)
|
|
data = await res.json() as RuntimeDecisionSystem
|
|
} else {
|
|
const fetcher: any = (globalThis as any).$fetch
|
|
if (typeof fetcher !== 'function') {
|
|
throw new Error('Universal fetch is not available in this context')
|
|
}
|
|
data = (await fetcher('/api/decision-flows/runtime')) as RuntimeDecisionSystem
|
|
}
|
|
const activeSlug = slug && data.flows[slug] ? slug : data.main
|
|
resetEngineFromSystem(data, { activeSlug })
|
|
}
|
|
|
|
function normalizeComparableValue(value: any): any {
|
|
if (typeof value === 'number') return value
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim()
|
|
const numeric = Number(trimmed)
|
|
if (!Number.isNaN(numeric)) return numeric
|
|
if (trimmed.toLowerCase() === 'true') return true
|
|
if (trimmed.toLowerCase() === 'false') return false
|
|
return trimmed.toLowerCase()
|
|
}
|
|
return value
|
|
}
|
|
|
|
function parseComparisonValue(raw: any): any {
|
|
if (typeof raw === 'number' || typeof raw === 'boolean') return raw
|
|
if (typeof raw !== 'string') return raw
|
|
const trimmed = raw.trim()
|
|
if (!trimmed.length) return trimmed
|
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
|
|
return trimmed.slice(1, -1)
|
|
}
|
|
const numeric = Number(trimmed)
|
|
if (!Number.isNaN(numeric)) return numeric
|
|
if (trimmed.toLowerCase() === 'true') return true
|
|
if (trimmed.toLowerCase() === 'false') return false
|
|
return trimmed
|
|
}
|
|
|
|
function compareValues(left: any, operator: string, right: any): boolean {
|
|
const leftValue = normalizeComparableValue(left)
|
|
const rightValue = normalizeComparableValue(parseComparisonValue(right))
|
|
switch (operator) {
|
|
case '>':
|
|
return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue > rightValue : false
|
|
case '>=':
|
|
return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue >= rightValue : false
|
|
case '<':
|
|
return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue < rightValue : false
|
|
case '<=':
|
|
return typeof leftValue === 'number' && typeof rightValue === 'number' ? leftValue <= rightValue : false
|
|
case '===':
|
|
case '==':
|
|
return leftValue === rightValue
|
|
case '!==':
|
|
case '!=':
|
|
return leftValue !== rightValue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
function getValueByPath(path: string): any {
|
|
if (!path || typeof path !== 'string') return undefined
|
|
const segments = path.split('.').map(part => part.trim()).filter(Boolean)
|
|
if (!segments.length) return undefined
|
|
let sourceKey = segments[0]
|
|
let current: any
|
|
if (sourceKey === 'variables') {
|
|
current = variables.value
|
|
segments.shift()
|
|
} else if (sourceKey === 'flags') {
|
|
current = flags.value
|
|
segments.shift()
|
|
} else if (sourceKey === 'telemetry') {
|
|
current = telemetry.value
|
|
segments.shift()
|
|
} else {
|
|
current = variables.value
|
|
}
|
|
for (const segment of segments) {
|
|
if (current == null) return undefined
|
|
current = current[segment]
|
|
}
|
|
return current
|
|
}
|
|
|
|
function evaluateSimpleCondition(condition: string): boolean {
|
|
const expr = condition.trim()
|
|
if (!expr) return true
|
|
const pattern = /^(variables|flags|telemetry)\.([A-Za-z0-9_.]+)\s*(===|==|!==|!=|>=|<=|>|<)\s*(.+)$/
|
|
const match = expr.match(pattern)
|
|
if (match) {
|
|
const [, source, path, operator, rawValue] = match
|
|
const fullPath = `${source}.${path}`
|
|
const left = getValueByPath(fullPath)
|
|
return compareValues(left, operator, rawValue)
|
|
}
|
|
// Allow shorthand without namespace (defaults to variables)
|
|
const fallbackPattern = /^([A-Za-z0-9_.]+)\s*(===|==|!==|!=|>=|<=|>|<)\s*(.+)$/
|
|
const fallback = expr.match(fallbackPattern)
|
|
if (fallback) {
|
|
const [, path, operator, rawValue] = fallback
|
|
const left = getValueByPath(path)
|
|
return compareValues(left, operator, rawValue)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function evaluateConditionExpression(expression?: string): boolean {
|
|
if (!expression || !expression.trim()) return true
|
|
const expr = expression.trim()
|
|
if (expr.includes('||')) {
|
|
return expr.split('||').some(part => evaluateConditionExpression(part.trim()))
|
|
}
|
|
if (expr.includes('&&')) {
|
|
return expr.split('&&').every(part => evaluateConditionExpression(part.trim()))
|
|
}
|
|
return evaluateSimpleCondition(expr)
|
|
}
|
|
|
|
const activeFrequency = computed<string | undefined>(() => {
|
|
const unit = typeof flags.value.current_unit === 'string' ? flags.value.current_unit.toUpperCase() : 'DEL'
|
|
switch (unit) {
|
|
case 'DEL': return variables.value.delivery_freq
|
|
case 'GROUND': return variables.value.ground_freq
|
|
case 'TOWER': return variables.value.tower_freq
|
|
case 'DEP': return variables.value.departure_freq
|
|
case 'APP': return variables.value.approach_freq
|
|
case 'CTR': return variables.value.handoff_freq
|
|
default: return undefined
|
|
}
|
|
})
|
|
|
|
// Current step for pm_alt.vue integration
|
|
const currentStep = computed(() => {
|
|
const phase = flightContext.value.phase
|
|
const step = COMMUNICATION_STEPS.find(s => s.phase === phase)
|
|
if (step) {
|
|
return {
|
|
...step,
|
|
pilot: renderTpl(step.pilot, { ...variables.value, ...flags.value }),
|
|
atc: step.atc ? renderTpl(step.atc, { ...variables.value, ...flags.value }) : undefined,
|
|
pilotResponse: step.pilotResponse ? renderTpl(step.pilotResponse, { ...variables.value, ...flags.value }) : undefined
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
function initializeFlight(fpl: any) {
|
|
const runtime = ensureTree()
|
|
// Set variables
|
|
const nextVariables = {
|
|
...variables.value,
|
|
callsign: fpl.callsign || fpl.callsign,
|
|
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
|
|
dep: fpl.dep || fpl.departure || 'EDDF',
|
|
dest: fpl.arr || fpl.arrival || 'EDDM',
|
|
stand: genStand(),
|
|
runway: genRunway(),
|
|
squawk: fpl.assignedsquawk || genSquawk(),
|
|
atis_code: genATIS(),
|
|
sid: genSID(fpl.route || ''),
|
|
transition: 'DCT',
|
|
cruise_flight_level: fpl.altitude ? `FL${String(Math.floor(parseInt(fpl.altitude) / 100)).padStart(3, '0')}` : 'FL360',
|
|
initial_altitude_ft: 5000,
|
|
climb_altitude_ft: 7000,
|
|
taxi_route: 'A, V',
|
|
atis_freq: '118.025',
|
|
delivery_freq: '121.900',
|
|
ground_freq: '121.700',
|
|
tower_freq: '118.700',
|
|
departure_freq: '125.350',
|
|
approach_freq: '120.800',
|
|
handoff_freq: '121.800',
|
|
qnh_hpa: 1015,
|
|
push_delay_min: 0,
|
|
surface_wind: '220/05',
|
|
speed_restriction: '210 knots',
|
|
emergency_heading: '180',
|
|
remarks: 'standard',
|
|
time_now: new Date().toISOString()
|
|
}
|
|
assignActiveVariables(nextVariables)
|
|
|
|
// Update flight context
|
|
Object.assign(flightContext.value, {
|
|
...variables.value,
|
|
phase: 'clearance'
|
|
})
|
|
|
|
const nextFlags: EngineFlags = {
|
|
...flags.value,
|
|
in_air: false,
|
|
emergency_active: false,
|
|
current_unit: 'DEL',
|
|
stack: [],
|
|
off_schema_count: 0,
|
|
radio_checks_done: 0,
|
|
session_id: ensureSessionValue(flags.value.session_id)
|
|
}
|
|
assignActiveFlags(nextFlags)
|
|
|
|
setActiveStateId(runtime.start_state)
|
|
assignCommunicationLog([])
|
|
resetAutoHistory(runtime.start_state)
|
|
}
|
|
|
|
function updateFrequencyVariables(update: Partial<Record<FrequencyVariableKey, string>>) {
|
|
if (!update) return
|
|
|
|
const sanitizedEntries = Object.entries(update)
|
|
.filter(([, value]) => typeof value === 'string' && value.trim().length)
|
|
.map(([key, value]) => [key, value!.trim()]) as [FrequencyVariableKey, string][]
|
|
|
|
if (!sanitizedEntries.length) {
|
|
return
|
|
}
|
|
|
|
for (const [key, value] of sanitizedEntries) {
|
|
variables.value[key] = value
|
|
}
|
|
|
|
Object.assign(flightContext.value, Object.fromEntries(sanitizedEntries))
|
|
}
|
|
|
|
function buildLLMContext(pilotTranscript: string) {
|
|
const runtime = ensureTree()
|
|
const s = currentState.value
|
|
if (!s) {
|
|
throw new Error('Decision state unavailable')
|
|
}
|
|
let candidates = nextCandidates.value
|
|
.map(id => ({ id, state: states.value[id], flow: runtime.slug }))
|
|
.filter(candidate => candidate.state)
|
|
|
|
// "Look through" auto-behavior check states (e.g. CD_READBACK_CHECK).
|
|
// If ALL candidates are check_readback/monitor states, expand them to
|
|
// their ok_next + bad_next targets so the LLM can evaluate the pilot's
|
|
// readback directly and route to the correct outcome.
|
|
const allAutoCheck = candidates.length > 0 && candidates.every(c =>
|
|
c.state.auto === 'check_readback' || c.state.auto === 'monitor'
|
|
)
|
|
if (allAutoCheck) {
|
|
const expanded: typeof candidates = []
|
|
const seen = new Set<string>()
|
|
for (const c of candidates) {
|
|
const targets = [
|
|
...(c.state.ok_next ?? []),
|
|
...(c.state.bad_next ?? []),
|
|
...(c.state.next ?? []),
|
|
]
|
|
for (const t of targets) {
|
|
if (!t?.to || seen.has(t.to)) continue
|
|
seen.add(t.to)
|
|
const targetState = states.value[t.to]
|
|
if (targetState) {
|
|
expanded.push({ id: t.to, state: targetState, flow: runtime.slug })
|
|
}
|
|
}
|
|
}
|
|
if (expanded.length > 0) {
|
|
candidates = expanded
|
|
}
|
|
}
|
|
|
|
return {
|
|
state_id: s.id,
|
|
state: { ...s },
|
|
candidates,
|
|
variables: { ...variables.value },
|
|
flags: { ...flags.value },
|
|
pilot_utterance: pilotTranscript,
|
|
tree: runtime.name,
|
|
flow_slug: runtime.slug,
|
|
}
|
|
}
|
|
|
|
function applyLLMDecision(decision: any, trace?: LLMDecisionTrace | null) {
|
|
if (!decision || typeof decision !== 'object') {
|
|
lastDecisionTrace.value = null
|
|
return
|
|
}
|
|
|
|
lastDecisionTrace.value = trace ?? null
|
|
|
|
if (decision.updates && typeof decision.updates === 'object') {
|
|
Object.assign(variables.value, decision.updates)
|
|
}
|
|
|
|
if (decision.flags && typeof decision.flags === 'object') {
|
|
Object.assign(flags.value, decision.flags)
|
|
}
|
|
|
|
if (decision.telemetry && typeof decision.telemetry === 'object') {
|
|
updateTelemetry(decision.telemetry)
|
|
}
|
|
|
|
if (Array.isArray(decision.stack)) {
|
|
flags.value.stack = decision.stack.slice()
|
|
}
|
|
|
|
if (decision.activate_flow) {
|
|
const activation = normalizeFlowInstruction(decision.activate_flow as any)
|
|
if (activation && (activation.slug !== activeFlowSlug.value || activation.mode === 'main')) {
|
|
try {
|
|
setActiveFlow(activation)
|
|
} catch (err) {
|
|
console.warn('[Engine] Failed to activate flow from decision', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (decision.off_schema) {
|
|
flags.value.off_schema_count++
|
|
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
|
|
}
|
|
|
|
if (decision.radio_check) {
|
|
flags.value.radio_checks_done++
|
|
console.log(`[Engine] Radio check #${flags.value.radio_checks_done}`)
|
|
}
|
|
|
|
if (decision.controller_say_tpl) {
|
|
speak('atc', decision.controller_say_tpl, currentStateId.value, {
|
|
radioCheck: decision.radio_check,
|
|
offSchema: decision.off_schema
|
|
})
|
|
}
|
|
|
|
const resumeFlow = decision.resume_previous === true
|
|
const nextState = typeof decision.next_state === 'string'
|
|
? decision.next_state
|
|
: typeof decision.nextState === 'string'
|
|
? decision.nextState
|
|
: null
|
|
|
|
if (resumeFlow) {
|
|
const resumed = resumeLinearFlow()
|
|
if (!resumed) {
|
|
resumeStackedState()
|
|
}
|
|
} else if (!decision.radio_check && nextState) {
|
|
moveTo(nextState)
|
|
}
|
|
|
|
if (!decision.radio_check) {
|
|
queueMicrotask(() => evaluateAutoTransitions())
|
|
}
|
|
}
|
|
|
|
function processPilotTransmission(transcript: string): string | null {
|
|
if (!ready.value) {
|
|
return null
|
|
}
|
|
speak('pilot', transcript, currentStateId.value)
|
|
return null
|
|
}
|
|
|
|
function processUserTransmission(transcript: string): string | null {
|
|
return processPilotTransmission(transcript)
|
|
}
|
|
|
|
/**
|
|
* After a decision lands on a state, walk forward through all non-pilot
|
|
* states (ATC replies, system checks, handoffs) collecting ATC messages
|
|
* that need TTS playback, until we reach the next pilot state.
|
|
*
|
|
* This is the core auto-advance mechanism for the live ATC flow.
|
|
* It handles:
|
|
* - ATC states with say_tpl (collect for TTS, advance)
|
|
* - System states (check_readback, monitor, etc) — advance via ok_next
|
|
* - Handoff states (advance automatically)
|
|
* - States with single unambiguous transitions
|
|
*/
|
|
function collectAtcStatesUntilPilotTurn(maxHops = 30): Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> {
|
|
const messages: Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> = []
|
|
const visited = new Set<string>()
|
|
let hops = 0
|
|
|
|
while (hops++ < maxHops) {
|
|
const s = currentState.value
|
|
if (!s) break
|
|
|
|
// Prevent infinite loops
|
|
if (visited.has(s.id)) break
|
|
visited.add(s.id)
|
|
|
|
// If we're on a pilot state, stop — it's the pilot's turn to speak
|
|
if (s.role === 'pilot') break
|
|
|
|
// If this is an end state, stop
|
|
const endStates = tree.value?.end_states ?? []
|
|
if (endStates.includes(s.id)) break
|
|
|
|
// Collect ATC/system messages for TTS
|
|
const sayTpl = stateSayTpl(s)
|
|
if (sayTpl) {
|
|
messages.push({
|
|
stateId: s.id,
|
|
say_tpl: sayTpl,
|
|
rendered: renderTpl(sayTpl, exposeCtx()),
|
|
normalized: normalizeATCText(sayTpl, exposeCtxFlat()),
|
|
})
|
|
}
|
|
|
|
// Determine the next state to advance to.
|
|
// Strategy:
|
|
// 1. For auto-behavior states (check_readback, monitor, pop_stack),
|
|
// prefer ok_next (assume success for auto-advance)
|
|
// 2. For states with a single eligible transition, take it
|
|
// 3. For ambiguous states (multiple eligible), stop
|
|
let nextId: string | null = null
|
|
|
|
const auto = s.auto
|
|
if (auto === 'check_readback' || auto === 'monitor' || auto === 'pop_stack_or_route_by_intent') {
|
|
// Auto-behavior states: prefer ok_next, then next
|
|
const okTransitions = (s.ok_next ?? []).filter(t => {
|
|
if (!t?.to) return false
|
|
if (t.when && !evaluateConditionExpression(t.when)) return false
|
|
if (t.guard && !evaluateConditionExpression(t.guard)) return false
|
|
return true
|
|
})
|
|
if (okTransitions.length > 0) {
|
|
nextId = okTransitions[0].to
|
|
}
|
|
}
|
|
|
|
if (!nextId) {
|
|
// Collect all eligible transitions — also include new-schema auto_transitions
|
|
// (used by ATC states in the new YAML that have no ok_next/next entries)
|
|
const autoTransitionTargets = (s.auto_transitions ?? [])
|
|
.map(t => ({ to: (t as any).to as string | undefined }))
|
|
.filter(t => !!t.to)
|
|
|
|
const allTransitions = [
|
|
...(s.next ?? []),
|
|
...(s.ok_next ?? []),
|
|
...autoTransitionTargets,
|
|
] as Array<{ to?: string; when?: string; guard?: string }>
|
|
|
|
const eligible = allTransitions.filter(t => {
|
|
if (!t?.to) return false
|
|
if (t.when && !evaluateConditionExpression(t.when)) return false
|
|
if (t.guard && !evaluateConditionExpression(t.guard)) return false
|
|
return true
|
|
})
|
|
|
|
// Only advance if there's exactly one unambiguous path
|
|
if (eligible.length === 1) {
|
|
nextId = eligible[0].to!
|
|
}
|
|
}
|
|
|
|
if (!nextId || !states.value[nextId]) break
|
|
|
|
// Advance to the next state (moveTo handles logging, actions, handoffs)
|
|
moveTo(nextId)
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
/**
|
|
* Computed: expected pilot phrases for the current state.
|
|
* If we're on a pilot state, returns that state's utterance_tpl rendered.
|
|
* If we're on an ATC state, looks at the next candidates that are pilot states.
|
|
*/
|
|
const expectedPilotPhrases = computed<Array<{ stateId: string; text: string; normalized: string }>>(() => {
|
|
const s = currentState.value
|
|
if (!s) return []
|
|
|
|
// If current state IS a pilot state with utterance_tpl / expected_pilot_template, show it
|
|
const currentUtteranceTpl = stateUtteranceTpl(s)
|
|
if (s.role === 'pilot' && currentUtteranceTpl) {
|
|
return [{
|
|
stateId: s.id,
|
|
text: renderTpl(currentUtteranceTpl, exposeCtx()),
|
|
normalized: normalizeATCText(currentUtteranceTpl, exposeCtxFlat()),
|
|
}]
|
|
}
|
|
|
|
// Otherwise look at next candidates that are pilot states
|
|
const results: Array<{ stateId: string; text: string; normalized: string }> = []
|
|
for (const id of nextCandidates.value) {
|
|
const state = states.value[id]
|
|
if (!state) continue
|
|
const utteranceTpl = stateUtteranceTpl(state)
|
|
if (state.role === 'pilot' && utteranceTpl) {
|
|
results.push({
|
|
stateId: id,
|
|
text: renderTpl(utteranceTpl, exposeCtx()),
|
|
normalized: normalizeATCText(utteranceTpl, exposeCtxFlat()),
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
})
|
|
|
|
function resolveTelemetryValue(parameter: string) {
|
|
const value = (telemetry.value as any)[parameter]
|
|
if (value !== undefined) return value
|
|
return (variables.value as any)[parameter]
|
|
}
|
|
|
|
function updateTelemetry(update: Partial<TelemetryState> | null | undefined) {
|
|
if (!update || typeof update !== 'object') {
|
|
return
|
|
}
|
|
const next: TelemetryState = { ...telemetry.value }
|
|
for (const [key, raw] of Object.entries(update)) {
|
|
if (raw === undefined || raw === null) continue
|
|
const current = telemetry.value[key]
|
|
if (typeof current === 'number') {
|
|
const numeric = typeof raw === 'number' ? raw : Number(raw)
|
|
if (!Number.isNaN(numeric)) {
|
|
next[key] = numeric
|
|
continue
|
|
}
|
|
}
|
|
const fallback = typeof raw === 'number' ? raw : Number(raw)
|
|
next[key] = Number.isNaN(fallback) ? current : fallback
|
|
}
|
|
assignActiveTelemetry(next)
|
|
queueMicrotask(() => evaluateAutoTransitions())
|
|
}
|
|
|
|
function evaluateAutoTrigger(trigger: DecisionNodeAutoTrigger | null | undefined): boolean {
|
|
if (!trigger) return false
|
|
if (trigger.type === 'expression') {
|
|
return evaluateConditionExpression(trigger.expression)
|
|
}
|
|
if (trigger.type === 'telemetry') {
|
|
if (!trigger.parameter || !trigger.operator) return false
|
|
const left = resolveTelemetryValue(trigger.parameter)
|
|
return compareValues(left, trigger.operator, trigger.value)
|
|
}
|
|
if (trigger.type === 'variable') {
|
|
const target = trigger.variable ? getValueByPath(trigger.variable) : undefined
|
|
if (trigger.operator) {
|
|
return compareValues(target, trigger.operator, trigger.value)
|
|
}
|
|
return Boolean(target)
|
|
}
|
|
return false
|
|
}
|
|
|
|
let pendingSimpleAutoTransition: { from: string; to: string } | null = null
|
|
|
|
function evaluateSimpleAutoFlow(loopGuard = 0) {
|
|
if (!ready.value || loopGuard > 20) return
|
|
if (pendingSimpleAutoTransition?.from === currentStateId.value) {
|
|
return
|
|
}
|
|
|
|
const state = currentState.value
|
|
if (!state) return
|
|
if (state.role === 'atc' && stateSayTpl(state)) return
|
|
|
|
const transitions = [
|
|
...(state.next ?? []),
|
|
...(state.ok_next ?? []),
|
|
...(state.bad_next ?? []),
|
|
] as Array<{ to?: string, guard?: string, when?: string, type?: string | null }>
|
|
|
|
const eligible = transitions.filter(candidate => {
|
|
if (!candidate || typeof candidate.to !== 'string' || !candidate.to.length) {
|
|
return false
|
|
}
|
|
if (candidate.type === 'auto') {
|
|
return false
|
|
}
|
|
if (candidate.when && !evaluateConditionExpression(candidate.when)) {
|
|
return false
|
|
}
|
|
if (candidate.guard && !evaluateConditionExpression(candidate.guard)) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
if (eligible.length !== 1) return
|
|
|
|
const targetId = eligible[0].to!
|
|
const targetState = states.value[targetId]
|
|
if (!targetState) return
|
|
|
|
const silentSystemHop = targetState.role === 'system' && !stateSayTpl(targetState)
|
|
const delay = silentSystemHop
|
|
? 50
|
|
: Math.floor(Math.random() * 1000) + 1000
|
|
|
|
const fromStateId = currentStateId.value
|
|
pendingSimpleAutoTransition = { from: fromStateId, to: targetId }
|
|
setTimeout(() => {
|
|
pendingSimpleAutoTransition = null
|
|
if (currentStateId.value !== fromStateId) {
|
|
return
|
|
}
|
|
moveTo(targetId)
|
|
evaluateSimpleAutoFlow(loopGuard + 1)
|
|
}, delay)
|
|
}
|
|
|
|
function evaluateAutoTransitions(loopGuard = 0) {
|
|
if (!ready.value || loopGuard > 8) return
|
|
const state = currentState.value
|
|
if (!state) return
|
|
|
|
const autoTransitions = state.auto_transitions ?? []
|
|
|
|
for (const transition of autoTransitions) {
|
|
if (!transition || !transition.trigger) continue
|
|
if (transition.trigger.once !== false && hasAutoExecuted(state.id, transition.id)) {
|
|
continue
|
|
}
|
|
if (transition.condition && !evaluateConditionExpression(transition.condition)) {
|
|
continue
|
|
}
|
|
if (transition.guard && !evaluateConditionExpression(transition.guard)) {
|
|
continue
|
|
}
|
|
if (!evaluateAutoTrigger(transition.trigger)) {
|
|
continue
|
|
}
|
|
markAutoExecuted(state.id, transition.id)
|
|
const delay = transition.trigger.delayMs ?? 0
|
|
if (delay > 0) {
|
|
setTimeout(() => {
|
|
moveTo(transition.to)
|
|
evaluateAutoTransitions(loopGuard + 1)
|
|
}, delay)
|
|
} else {
|
|
moveTo(transition.to)
|
|
evaluateAutoTransitions(loopGuard + 1)
|
|
}
|
|
return
|
|
}
|
|
|
|
evaluateSimpleAutoFlow(loopGuard + 1)
|
|
}
|
|
|
|
function moveTo(stateId: string) {
|
|
ensureTree()
|
|
if (!states.value[stateId]) {
|
|
console.warn(`[Engine] Unknown state: ${stateId}`)
|
|
return
|
|
}
|
|
|
|
if (stateId.startsWith('INT_')) {
|
|
flags.value.stack.push(currentStateId.value)
|
|
}
|
|
|
|
setActiveStateId(stateId)
|
|
resetAutoHistory(stateId)
|
|
const s = currentState.value
|
|
if (!s) return
|
|
|
|
// Execute actions
|
|
for (const act of s.actions ?? []) {
|
|
if (typeof act === 'string') continue
|
|
if (act.if && !safeEvalBoolean(act.if)) continue
|
|
if (act.set) {
|
|
setByPath({ variables: variables.value, flags: flags.value, telemetry: telemetry.value }, act.set, act.to)
|
|
}
|
|
}
|
|
|
|
// Handoff
|
|
if (s.handoff?.to) {
|
|
flags.value.current_unit = unitFromHandoff(s.handoff.to)
|
|
if (s.handoff.freq) {
|
|
variables.value.handoff_freq = renderTpl(s.handoff.freq, exposeCtx())
|
|
}
|
|
}
|
|
|
|
// Auto-Say
|
|
const sayTplMoveTo = stateSayTpl(s)
|
|
if (sayTplMoveTo) {
|
|
speak(s.role, sayTplMoveTo, s.id!)
|
|
}
|
|
|
|
// Update flight context phase
|
|
updateFlightPhase(s.phase)
|
|
queueMicrotask(() => evaluateAutoTransitions())
|
|
}
|
|
|
|
// Like moveTo but does NOT schedule auto-transitions — used when the backend
|
|
// is driving state and we only need to sync the local cursor + side-effects
|
|
// (actions, handoffs, communication log) without the engine trying to advance further.
|
|
function moveToSilent(stateId: string) {
|
|
ensureTree()
|
|
if (!states.value[stateId]) {
|
|
console.warn(`[Engine] Unknown state for silent move: ${stateId}`)
|
|
return
|
|
}
|
|
|
|
if (stateId.startsWith('INT_')) {
|
|
flags.value.stack.push(currentStateId.value)
|
|
}
|
|
|
|
setActiveStateId(stateId)
|
|
resetAutoHistory(stateId)
|
|
const s = currentState.value
|
|
if (!s) return
|
|
|
|
for (const act of s.actions ?? []) {
|
|
if (typeof act === 'string') continue
|
|
if (act.if && !safeEvalBoolean(act.if)) continue
|
|
if (act.set) {
|
|
setByPath({ variables: variables.value, flags: flags.value, telemetry: telemetry.value }, act.set, act.to)
|
|
}
|
|
}
|
|
|
|
if (s.handoff?.to) {
|
|
flags.value.current_unit = unitFromHandoff(s.handoff.to)
|
|
if (s.handoff.freq) {
|
|
variables.value.handoff_freq = renderTpl(s.handoff.freq, exposeCtx())
|
|
}
|
|
}
|
|
|
|
const sayTplSilent = stateSayTpl(s)
|
|
if (sayTplSilent) {
|
|
speak(s.role, sayTplSilent, s.id!)
|
|
}
|
|
|
|
updateFlightPhase(s.phase)
|
|
// deliberately no evaluateAutoTransitions — backend owns the next move
|
|
}
|
|
|
|
function updateFlightPhase(phase: Phase) {
|
|
const phaseMap: Record<Phase, string> = {
|
|
'Clearance': 'clearance',
|
|
'PushStart': 'ground',
|
|
'TaxiOut': 'ground',
|
|
'Departure': 'tower',
|
|
'Climb': 'departure',
|
|
'Enroute': 'enroute',
|
|
'Descent': 'enroute',
|
|
'Approach': 'approach',
|
|
'Landing': 'landing',
|
|
'TaxiIn': 'taxiin',
|
|
'Preflight': 'clearance',
|
|
'Postflight': 'taxiin',
|
|
'Interrupt': flightContext.value.phase,
|
|
'LostComms': flightContext.value.phase,
|
|
'Missed': 'approach'
|
|
}
|
|
|
|
if (phaseMap[phase]) {
|
|
flightContext.value.phase = phaseMap[phase]
|
|
}
|
|
}
|
|
|
|
function resumeStackedState() {
|
|
const prev = flags.value.stack.pop()
|
|
if (prev) moveTo(prev)
|
|
}
|
|
|
|
function resumeLinearFlow(): boolean {
|
|
const previousFlow = flowStack.value.pop()
|
|
if (previousFlow) {
|
|
try {
|
|
setActiveFlow({ slug: previousFlow, mode: resolveFlowMode(previousFlow) }, { skipStack: true })
|
|
return true
|
|
} catch (err) {
|
|
console.warn('[Engine] Failed to resume linear flow', err)
|
|
}
|
|
} else if (mainFlowSlug.value && activeFlowSlug.value !== mainFlowSlug.value) {
|
|
try {
|
|
setActiveFlow({ slug: mainFlowSlug.value, mode: 'main' }, { skipStack: true })
|
|
return true
|
|
} catch (err) {
|
|
console.warn('[Engine] Failed to restore main flow', err)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function speak(speaker: Role, tpl: string, stateId: string, options: { radioCheck?: boolean, offSchema?: boolean } = {}) {
|
|
const msg = renderTpl(tpl, exposeCtx())
|
|
const entry: EngineLog = {
|
|
timestamp: new Date(),
|
|
frequency: activeFrequency.value,
|
|
speaker,
|
|
message: msg,
|
|
normalized: normalizeATCText(msg, exposeCtxFlat()),
|
|
state: stateId,
|
|
radioCheck: options.radioCheck,
|
|
offSchema: options.offSchema,
|
|
flow: activeFlowSlug.value || undefined,
|
|
}
|
|
communicationLog.value.push(entry)
|
|
}
|
|
|
|
function renderATCMessage(tpl: string) {
|
|
return renderTpl(tpl, exposeCtx())
|
|
}
|
|
|
|
function exposeCtx() {
|
|
return {
|
|
...variables.value,
|
|
...flags.value,
|
|
variables: variables.value,
|
|
flags: flags.value,
|
|
telemetry: telemetry.value,
|
|
}
|
|
}
|
|
|
|
function exposeCtxFlat() {
|
|
return { ...variables.value, ...flags.value, ...telemetry.value }
|
|
}
|
|
|
|
function unitFromHandoff(to: string) {
|
|
if (/GROUND/i.test(to)) return 'GROUND'
|
|
if (/TOWER/i.test(to)) return 'TOWER'
|
|
if (/DEPART/i.test(to)) return 'DEP'
|
|
if (/APPROACH/i.test(to)) return 'APP'
|
|
if (/CENTER|CTR/i.test(to)) return 'CTR'
|
|
if (/DEL|DELIVERY/i.test(to)) return 'DEL'
|
|
return flags.value.current_unit
|
|
}
|
|
|
|
function getStateDetails(stateId: string) {
|
|
const s = states.value[stateId]
|
|
if (!s) return null
|
|
return { ...s, id: stateId }
|
|
}
|
|
|
|
function genStand() {
|
|
const arr = ['A12','B15','C23','D8','E41','F18','G7','H33']
|
|
return arr[Math.floor(Math.random() * arr.length)]
|
|
}
|
|
|
|
function genRunway() {
|
|
const arr = ['25L','25R','07L','07R','18','36','09','27']
|
|
return arr[Math.floor(Math.random() * arr.length)]
|
|
}
|
|
|
|
function genSquawk() {
|
|
return String(Math.floor(Math.random() * 8000 + 1000)).padStart(4, '0')
|
|
}
|
|
|
|
function genATIS() {
|
|
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
return letters[Math.floor(Math.random() * letters.length)]
|
|
}
|
|
|
|
function genSID(_route: string) {
|
|
const sids = ['SULUS5S', 'TOBAK2E', 'MARUN7F', 'CINDY1A', 'HELEN4B']
|
|
return sids[Math.floor(Math.random() * sids.length)]
|
|
}
|
|
|
|
function safeEvalBoolean(expr?: string): boolean {
|
|
return evaluateConditionExpression(expr)
|
|
}
|
|
|
|
function setByPath(root: Record<string, any>, path: string, val: any) {
|
|
const parts = path.split('.').map(part => part.trim()).filter(Boolean)
|
|
if (!parts.length) return
|
|
let cur = root
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const p = parts[i]
|
|
if (!(p in cur) || typeof cur[p] !== 'object') {
|
|
cur[p] = {}
|
|
}
|
|
cur = cur[p]
|
|
}
|
|
cur[parts[parts.length - 1]] = val
|
|
}
|
|
|
|
return {
|
|
// State
|
|
currentState: computed(() => currentState.value),
|
|
currentStateId: readonly(currentStateId),
|
|
variables: readonly(variables),
|
|
flags: readonly(flags),
|
|
telemetry: readonly(telemetry),
|
|
nextCandidates,
|
|
activeFrequency,
|
|
communicationLog: readonly(communicationLog),
|
|
clearCommunicationLog: () => { assignCommunicationLog([]) },
|
|
activeFlow,
|
|
availableFlows,
|
|
sessionId: readonly(sessionId),
|
|
lastDecisionTrace: readonly(lastDecisionTrace),
|
|
|
|
// pm_alt.vue integration
|
|
flightContext: readonly(flightContext),
|
|
currentStep,
|
|
|
|
// Lifecycle
|
|
initializeFlight,
|
|
updateFrequencyVariables,
|
|
loadRuntimeTree,
|
|
loadRuntimeSystem,
|
|
fetchRuntimeTree,
|
|
setActiveFlow,
|
|
isReady,
|
|
|
|
// Communication
|
|
processPilotTransmission,
|
|
processUserTransmission,
|
|
buildLLMContext,
|
|
applyLLMDecision,
|
|
collectAtcStatesUntilPilotTurn,
|
|
expectedPilotPhrases,
|
|
|
|
// Flow Control
|
|
moveTo,
|
|
moveToSilent,
|
|
|
|
// Utilities
|
|
normalizeATCText,
|
|
renderATCMessage,
|
|
getStateDetails,
|
|
updateTelemetry,
|
|
}
|
|
}
|