This commit is contained in:
itsrubberduck
2025-10-12 15:42:18 +02:00
9 changed files with 1437 additions and 413 deletions

View File

@@ -2,17 +2,26 @@
<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 space-y-1">
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat">
{{ currentState?.id || 'INIT' }}
</v-chip>
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
<div class="flex items-center justify-end gap-2 text-[11px] text-white/60">
<v-chip size="x-small" color="cyan" variant="outlined">{{ activeFlowInfo.name }}</v-chip>
<v-chip size="x-small" color="purple" variant="text">{{ activeFlowInfo.modeLabel }}</v-chip>
</div>
<p v-if="activeFlowInfo.description" class="text-[10px] text-white/40">
{{ activeFlowInfo.description }}
</p>
</div>
</div>
</header>
@@ -601,6 +610,21 @@
<v-chip size="small" color="grey" variant="outlined">LLM</v-chip>
</div>
<div class="flex items-center justify-between text-[11px] text-white/50">
<span>Session: {{ sessionLabel }}</span>
<div class="flex flex-wrap gap-2" v-if="traceAutoSelection || (traceFallback?.used) || timelineUsedFallback">
<v-chip v-if="traceAutoSelection" size="x-small" color="cyan" variant="outlined">
Auto: {{ traceAutoSelection.id }}
</v-chip>
<v-chip v-if="timelineUsedFallback" size="x-small" color="orange" variant="tonal">
Fallback candidates
</v-chip>
<v-chip v-if="traceFallback?.used" size="x-small" color="red" variant="tonal">
Fallback: {{ traceFallback.reason || 'triggered' }}
</v-chip>
</div>
</div>
<div class="space-y-2 rounded-2xl border border-white/10 bg-black/30 p-3">
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Current node</p>
<p class="font-mono text-sm text-white">{{ debugState?.id || '—' }}</p>
@@ -652,6 +676,57 @@
</div>
<p v-else class="text-xs text-white/50">No further decisions available.</p>
</div>
<div class="space-y-2 rounded-2xl border border-white/10 bg-black/30 p-3">
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Decision timeline</p>
<div v-if="timelineSteps.length" class="space-y-3">
<div
v-for="(step, index) in timelineSteps"
:key="`${step.stage}-${index}`"
class="space-y-2 rounded-xl border border-white/10 bg-black/40 p-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-sm text-white">{{ step.label }}</p>
<p class="text-[11px] text-white/50 uppercase tracking-[0.2em]">{{ step.stage }}</p>
</div>
<v-chip size="x-small" color="cyan" variant="outlined">
{{ step.candidates.length }} candidates
</v-chip>
</div>
<p v-if="step.note" class="text-[11px] text-white/50">{{ step.note }}</p>
<div v-if="step.candidates.length" class="space-y-2">
<div
v-for="candidate in step.candidates"
:key="candidate.id"
class="rounded-lg border border-white/10 bg-black/30 p-2"
>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-sm text-white">{{ candidate.id }}</span>
<span class="text-[11px] text-white/50">{{ candidate.flow || 'current' }}</span>
</div>
<p v-if="candidate.summary" class="text-[11px] text-white/60 mt-1">{{ candidate.summary }}</p>
</div>
</div>
<div v-if="step.eliminated?.length" class="space-y-2">
<p class="text-[11px] text-red-200/80 uppercase tracking-[0.25em]">Eliminated</p>
<div
v-for="elim in step.eliminated"
:key="`${step.stage}-${elim.candidate.id}`"
class="space-y-1 rounded-lg border border-red-400/30 bg-red-500/10 p-2 text-xs text-red-100"
>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-sm">{{ elim.candidate.id }}</span>
<span class="text-[11px] text-red-200/80">{{ elim.kind }}</span>
</div>
<p class="text-[11px] text-red-100/80">{{ elim.reason }}</p>
<p v-if="describeElimination(elim)" class="text-[10px] text-red-100/70">{{ describeElimination(elim) }}</p>
</div>
</div>
</div>
</div>
<p v-else class="text-[11px] text-white/50">No decision timeline available yet.</p>
</div>
</v-card-text>
</v-card>
@@ -696,6 +771,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,6 +1036,10 @@ const {
flags,
flightContext,
currentStep,
availableFlows,
activeFlow,
sessionId: engineSessionId,
lastDecisionTrace,
initializeFlight,
updateFrequencyVariables,
fetchRuntimeTree,
@@ -1023,6 +1110,51 @@ const clearLog = () => {
clearLastTransmission()
}
const activeFlowInfo = computed(() => {
const slug = activeFlow.value
const flows = availableFlows.value
const entry = (slug ? flows.find((flow) => flow.slug === slug) : undefined) || flows.find((flow) => flow.mode === 'main') || flows[0]
const resolvedSlug = entry?.slug || slug || ''
const name = entry?.name || resolvedSlug || 'Main Flow'
const description = entry?.description || ''
const mode = entry?.mode || (resolvedSlug && resolvedSlug === slug ? 'parallel' : 'parallel')
const modeLabel = mode === 'main' ? 'Main' : mode === 'linear' ? 'Linear' : 'Parallel'
return { slug: resolvedSlug, name, description, mode, modeLabel }
})
const decisionTrace = computed(() => lastDecisionTrace.value)
const timelineSteps = computed(() => decisionTrace.value?.candidateTimeline?.steps ?? [])
const timelineUsedFallback = computed(() => Boolean(decisionTrace.value?.candidateTimeline?.fallbackUsed))
const traceAutoSelection = computed(() => decisionTrace.value?.autoSelection ?? null)
const traceFallback = computed(() => decisionTrace.value?.fallback ?? null)
const sessionLabel = computed(() => engineSessionId.value || flags.session_id || '-')
function describeElimination(entry: any): string {
if (!entry || typeof entry !== 'object') {
return ''
}
if (entry.kind === 'regex' && entry.context?.patterns?.length) {
const patterns = entry.context.patterns
.map((pattern: any) => pattern?.pattern)
.filter((value: string | undefined) => Boolean(value))
.join(', ')
return patterns ? `Patterns: ${patterns}` : entry.reason
}
if (entry.kind === 'condition' && entry.context?.condition) {
const condition = entry.context.condition
if (condition.type === 'regex' || condition.type === 'regex_not') {
const flag = condition.pattern ? `/${condition.pattern}/${condition.patternFlags || 'i'}` : ''
return flag ? `Condition: ${condition.type} ${flag}` : entry.reason
}
const variable = condition.variable || 'value'
const operator = condition.operator || '=='
const expected = entry.context?.expectedValue ?? condition.value ?? '—'
const actual = entry.context?.actualValue ?? '—'
return `${variable} ${operator} ${expected} (actual: ${actual})`
}
return entry.reason
}
// UI State
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
const loading = ref(false)
@@ -1476,7 +1608,8 @@ const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions =
speed,
moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general',
tag: options.tag || 'controller-reply'
tag: options.tag || 'controller-reply',
sessionId: engineSessionId.value || flags.session_id || undefined,
})
if (response.success && response.audio) {
@@ -1519,7 +1652,8 @@ const speakPlainText = (text: string, options: SpeechOptions = {}) => {
speed,
moduleId: 'pilot-monitoring',
lessonId,
tag: options.tag || 'announcement'
tag: options.tag || 'announcement',
sessionId: engineSessionId.value || flags.session_id || undefined,
})
if (response.success && response.audio) {
@@ -1562,23 +1696,18 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot'
setLastTransmission(`${prefix}: ${transcript}`)
const quickResponse = processPilotTransmission(transcript)
processPilotTransmission(transcript)
if (readbackEnabled.value) {
speakPilotReadback(transcript)
}
if (quickResponse) {
scheduleControllerSpeech(quickResponse)
return
}
const ctx = buildLLMContext(transcript)
try {
const decision = await api.post('/api/llm/decide', ctx)
const { decision, trace } = await api.post('/api/llm/decide', ctx)
applyLLMDecision(decision)
applyLLMDecision(decision, trace)
if (decision.controller_say_tpl && !decision.radio_check) {
scheduleControllerSpeech(decision.controller_say_tpl)

View File

@@ -5,7 +5,8 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { getOpenAIClient, routeDecision, type LLMDecisionResult } from "../../utils/openai";
import { getOpenAIClient, routeDecision } from "../../utils/openai";
import type { LLMDecisionResult } from "~~/shared/types/llm";
import { createReadStream } from "node:fs";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
@@ -17,9 +18,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;
@@ -30,12 +32,9 @@ interface PTTRequest {
interface PTTResponse {
success: boolean;
transcription: string;
decision?: {
next_state: string;
controller_say_tpl?: string;
off_schema?: boolean;
radio_check?: boolean;
};
decision?: LLMDecisionResult['decision'];
trace?: LLMDecisionResult['trace'];
active_nodes?: LLMDecisionResult['active_nodes'];
}
async function sh(cmd: string, args: string[]) {
@@ -225,6 +224,7 @@ export default defineEventHandler(async (event) => {
return {
id: candidate.id,
flow: candidate.flow || undefined,
state: candidateState
};
})
@@ -232,12 +232,17 @@ export default defineEventHandler(async (event) => {
const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state);
const sessionId = typeof body.context?.flags?.session_id === 'string'
? body.context.flags.session_id
: undefined;
await TransmissionLog.create({
user: user?._id,
role: "pilot",
channel: "ptt",
direction: "incoming",
text: transcribedText,
sessionId,
metadata: {
moduleId: body.moduleId,
lessonId: body.lessonId,
@@ -267,6 +272,12 @@ export default defineEventHandler(async (event) => {
if (decision) {
result.decision = decision;
}
if (decisionResult?.trace) {
result.trace = decisionResult.trace;
}
if (decisionResult?.active_nodes?.length) {
result.active_nodes = decisionResult.active_nodes;
}
return result;

View File

@@ -123,10 +123,16 @@ export default defineEventHandler(async (event) => {
lessonId?: string;
tag?: string;
format?: AudioFmt | "smallest";
sessionId?: string;
}>(event);
const user = await requireUserSession(event);
const rawSessionId = typeof body?.sessionId === "string"
? body.sessionId.trim()
: "";
const sessionId = rawSessionId.length ? rawSessionId : undefined;
const raw = (body?.text || "").trim();
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
@@ -226,6 +232,7 @@ export default defineEventHandler(async (event) => {
direction: "outgoing",
text: raw,
normalized,
sessionId,
metadata: {
level,
voice,

View File

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

View File

@@ -10,6 +10,7 @@ export interface TransmissionLogDocument extends mongoose.Document {
text: string
normalized?: string
metadata?: Record<string, any>
sessionId?: string
createdAt: Date
}
@@ -21,6 +22,7 @@ const transmissionSchema = new mongoose.Schema<TransmissionLogDocument>({
text: { type: String, required: true },
normalized: { type: String },
metadata: { type: Schema.Types.Mixed },
sessionId: { type: String, index: true },
createdAt: { type: Date, default: () => new Date() },
})

File diff suppressed because it is too large Load Diff

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
@@ -97,6 +126,8 @@ export interface DecisionNodeMetadata {
complexity?: 'low' | 'medium' | 'high'
}
export type DecisionFlowEntryMode = 'parallel' | 'linear'
export interface DecisionNodeModel {
stateId: string
title?: string
@@ -114,6 +145,8 @@ export interface DecisionNodeModel {
trigger?: string
frequency?: string
frequencyName?: string
triggers?: DecisionNodeTrigger[]
conditions?: DecisionNodeCondition[]
transitions: DecisionNodeTransition[]
layout?: DecisionNodeLayout
metadata?: DecisionNodeMetadata
@@ -166,6 +199,8 @@ export interface DecisionFlowModel {
createdAt: string
updatedAt: string
nodeCount?: number
entryMode?: DecisionFlowEntryMode
isMain?: boolean
}
export interface RuntimeDecisionAutoTransition {
@@ -182,6 +217,8 @@ export interface RuntimeDecisionAutoTransition {
export interface RuntimeDecisionState {
role: DecisionNodeRole
phase: string
name?: string
summary?: string
say_tpl?: string
utterance_tpl?: string
else_say_tpl?: string
@@ -198,10 +235,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
@@ -214,6 +254,13 @@ export interface RuntimeDecisionTree {
roles: DecisionNodeRole[]
phases: string[]
states: Record<string, RuntimeDecisionState>
entry_mode?: 'main' | DecisionFlowEntryMode
}
export interface RuntimeDecisionSystem {
main: string
order: string[]
flows: Record<string, RuntimeDecisionTree>
}
export interface DecisionFlowSummary {
@@ -225,4 +272,6 @@ export interface DecisionFlowSummary {
nodeCount: number
updatedAt: string
createdAt: string
entryMode?: DecisionFlowEntryMode
isMain?: boolean
}

View File

@@ -1,10 +1,83 @@
import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision'
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 type FlowActivationMode = 'main' | 'parallel' | 'linear'
export interface FlowActivationInstruction {
slug: string
mode?: FlowActivationMode
}
export interface ActiveNodeSummary {
flow: string
state: string
role?: string
say_tpl?: string
controller_say_tpl?: string
}
export interface CandidateTraceEntry {
id: string
flow: string
name?: string
summary?: string
role?: string
triggers?: DecisionNodeTrigger[]
conditions?: DecisionNodeCondition[]
}
export type CandidateTraceStage =
| 'regex_candidates'
| 'regex_filtered'
| 'condition_filtered'
| 'fallback_candidates'
| 'fallback_filtered'
| 'final'
export interface CandidateTraceEliminationContext {
patterns?: Array<{ id?: string; pattern?: string; flags?: string }>
transcript?: string
condition?: {
id?: string
type: DecisionNodeCondition['type']
variable?: string
operator?: string
value?: number | string | boolean
pattern?: string
patternFlags?: string
}
actualValue?: any
expectedValue?: any
}
export interface CandidateTraceElimination {
candidate: CandidateTraceEntry
kind: 'regex' | 'condition'
reason: string
context?: CandidateTraceEliminationContext
}
export interface CandidateTraceStep {
stage: CandidateTraceStage
label: string
candidates: CandidateTraceEntry[]
eliminated?: CandidateTraceElimination[]
note?: string
}
export interface DecisionCandidateTimeline {
steps: CandidateTraceStep[]
fallbackUsed?: boolean
autoSelected?: CandidateTraceEntry | null
}
export interface LLMDecision {
@@ -14,4 +87,37 @@ export interface LLMDecision {
controller_say_tpl?: string
off_schema?: boolean
radio_check?: boolean
activate_flow?: string | FlowActivationInstruction
resume_previous?: boolean
}
export interface LLMDecisionTraceCall {
stage: 'readback-check' | 'decision'
request: Record<string, any>
response?: any
rawResponseText?: string
error?: string
}
export interface LLMDecisionTraceFallback {
used: boolean
reason?: string
selected?: string
}
export interface LLMDecisionTrace {
calls: LLMDecisionTraceCall[]
fallback?: LLMDecisionTraceFallback
candidateTimeline?: DecisionCandidateTimeline
autoSelection?: {
id: string
flow: string
reason?: string
}
}
export interface LLMDecisionResult {
decision: LLMDecision
trace?: LLMDecisionTrace
active_nodes?: ActiveNodeSummary[]
}

View File

@@ -1,11 +1,13 @@
// communicationsEngine composable
import { ref, computed, readonly } from 'vue'
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 ---
@@ -19,6 +21,7 @@ interface EngineFlags {
stack: string[]
off_schema_count: number
radio_checks_done: number
session_id: string
[key: string]: any
}
@@ -93,6 +96,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 = {
@@ -106,11 +122,42 @@ type TelemetryState = {
[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 {
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
const parts = key.split('.')
@@ -121,8 +168,17 @@ 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 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 ?? {})
@@ -148,31 +204,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 +216,198 @@ 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 ensureSessionValue(raw?: string): string {
if (raw && typeof raw === 'string' && raw.trim().length) {
sessionId.value = raw.trim()
} else if (!sessionId.value) {
sessionId.value = createSessionId()
}
return sessionId.value
}
function assignActiveFlags(next: EngineFlags) {
const normalizedSession = ensureSessionValue(next?.session_id)
next.session_id = normalizedSession
flags.value = next
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].flags = next
}
}
function assignActiveTelemetry(next: TelemetryState) {
telemetry.value = next
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].telemetry = next
}
}
function assignCommunicationLog(next: EngineLog[]) {
communicationLog.value = next
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].communicationLog = next
}
}
function assignFlightContext(next: FlightContext) {
flightContext.value = next
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].flightContext = next
}
}
function setActiveStateId(stateId: string) {
currentStateId.value = stateId
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
flowSnapshots[activeFlowSlug.value].currentStateId = stateId
}
}
function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot {
const variables = { ...treeData.variables }
const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {}
const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : []
const flags: EngineFlags = {
in_air: Boolean((baseFlags as any).in_air),
emergency_active: Boolean((baseFlags as any).emergency_active),
current_unit: typeof (baseFlags as any).current_unit === 'string'
? (baseFlags as any).current_unit
: 'DEL',
stack,
off_schema_count: Number((baseFlags as any).off_schema_count) || 0,
radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0,
session_id: '',
...(baseFlags as EngineFlags),
}
if (!Array.isArray(flags.stack)) {
flags.stack = []
}
if (typeof flags.session_id !== 'string') {
flags.session_id = ''
}
const telemetry: TelemetryState = {
altitude_ft: Number((baseFlags as any).altitude_ft) || 0,
speed_kts: Number((baseFlags as any).speed_kts) || 0,
groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0,
vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0,
latitude_deg: Number((baseFlags as any).latitude_deg) || 0,
longitude_deg: Number((baseFlags as any).longitude_deg) || 0,
heading_deg: Number((baseFlags as any).heading_deg) || 0,
}
const log: EngineLog[] = []
const snapshotContext = createDefaultFlightContext()
snapshotContext.phase = 'clearance'
const autoHistory = new Map<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 []
@@ -210,69 +435,141 @@ 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)
}
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('')
}
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 mainFlowSlug = computed(() => runtimeSystem.value?.main || '')
const availableFlows = computed(() => {
if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }>
return flowOrder.value
.filter((slug) => Boolean(runtimeSystem.value!.flows[slug]))
.map((slug) => {
const treeData = runtimeSystem.value!.flows[slug]
return {
slug,
name: treeData.name || slug,
description: treeData.description,
start: treeData.start_state,
mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'),
}
})
})
async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') {
ready.value = false
const fetcher: any = (globalThis as any).$fetch
if (typeof fetcher !== 'function') {
throw new Error('Universal fetch is not available in this context')
}
const data = await fetcher<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 +714,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 +745,7 @@ export default function useCommunicationsEngine() {
remarks: 'standard',
time_now: new Date().toISOString()
}
assignActiveVariables(nextVariables)
// Update flight context
Object.assign(flightContext.value, {
@@ -455,19 +753,21 @@ export default function useCommunicationsEngine() {
phase: 'clearance'
})
flags.value = {
const nextFlags: EngineFlags = {
...flags.value,
in_air: false,
emergency_active: false,
current_unit: 'DEL',
stack: [],
off_schema_count: 0,
radio_checks_done: 0
radio_checks_done: 0,
session_id: ensureSessionValue(flags.value.session_id)
}
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 +795,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,14 +806,18 @@ export default function useCommunicationsEngine() {
flags: { ...flags.value },
pilot_utterance: pilotTranscript,
tree: runtime.name,
flow_slug: runtime.slug,
}
}
function applyLLMDecision(decision: any) {
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)
}
@@ -530,6 +834,17 @@ export default function useCommunicationsEngine() {
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}`)
@@ -555,7 +870,10 @@ export default function useCommunicationsEngine() {
: null
if (resumeFlow) {
resumePriorFlow()
const resumed = resumeLinearFlow()
if (!resumed) {
resumeStackedState()
}
} else if (!decision.radio_check && nextState) {
moveTo(nextState)
}
@@ -569,31 +887,8 @@ export default function useCommunicationsEngine() {
if (!ready.value) {
return null
}
// Log pilot input
speak('pilot', transcript, currentStateId.value)
// Radio check detection (fallback if the LLM misses it)
const t = transcript.toLowerCase()
if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) {
const callsign = variables.value.callsign || ''
const response = `${callsign}, read you five by five.`
flags.value.radio_checks_done++
setTimeout(() => {
speak('atc', response, currentStateId.value, { radioCheck: true })
}, 500)
return response
}
// Emergency Interrupts
if (flags.value.in_air && /^(mayday|pan\s*pan)/.test(t)) {
const intId = t.startsWith('mayday') ? 'INT_MAYDAY' : 'INT_PANPAN'
moveTo(intId)
return null
}
return null // Let the LLM decide
return null
}
function processUserTransmission(transcript: string): string | null {
@@ -624,7 +919,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 +988,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
@@ -749,11 +1044,31 @@ export default function useCommunicationsEngine() {
}
}
function resumePriorFlow() {
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 = {
@@ -764,7 +1079,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 +1171,11 @@ export default function useCommunicationsEngine() {
nextCandidates,
activeFrequency,
communicationLog: readonly(communicationLog),
clearCommunicationLog: () => { communicationLog.value = [] },
clearCommunicationLog: () => { assignCommunicationLog([]) },
activeFlow,
availableFlows,
sessionId: readonly(sessionId),
lastDecisionTrace: readonly(lastDecisionTrace),
// pm_alt.vue integration
flightContext: readonly(flightContext),
@@ -865,7 +1185,9 @@ export default function useCommunicationsEngine() {
initializeFlight,
updateFrequencyVariables,
loadRuntimeTree,
loadRuntimeSystem,
fetchRuntimeTree,
setActiveFlow,
isReady,
// Communication
@@ -876,7 +1198,6 @@ export default function useCommunicationsEngine() {
// Flow Control
moveTo,
resumePriorFlow,
// Utilities
normalizeATCText,