Add session timeline logging and admin sessions view

This commit is contained in:
Remi
2025-09-21 23:08:10 +02:00
parent 10cae457f9
commit ba93c494c9
18 changed files with 1303 additions and 808 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,26 +9,19 @@
<p class="mt-1 text-sm text-white/70">Alpha Build Decision Tree VATSIM</p>
</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">
<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>
<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>
@@ -617,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>
@@ -668,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>
@@ -979,10 +1038,11 @@ const {
currentStep,
availableFlows,
activeFlow,
sessionId: engineSessionId,
lastDecisionTrace,
initializeFlight,
updateFrequencyVariables,
fetchRuntimeTree,
setActiveFlow,
isReady: engineReady,
processPilotTransmission,
buildLLMContext,
@@ -1050,39 +1110,49 @@ 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)
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 }
})
function handleFlowChange(slug: string) {
if (!slug || slug === activeFlow.value) {
return
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 ''
}
try {
setActiveFlow(slug)
} catch (error) {
console.error('Failed to activate flow', error)
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
@@ -1538,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) {
@@ -1581,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) {
@@ -1624,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

@@ -0,0 +1,107 @@
import { defineEventHandler, getQuery } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { TransmissionLog } from '../../../models/TransmissionLog'
interface SessionSummaryEntry {
sessionId: string
startedAt: string | null
updatedAt: string | null
entryCount: number
callsign?: string
lastMessage?: {
text: string
role: string
channel: string
createdAt: string
}
}
function toISO(value: any): string | null {
if (!value) {
return null
}
const date = new Date(value)
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
}
function extractCallsign(entry: any): string | undefined {
const fromMetadata = (payload: any) =>
payload?.metadata?.context?.variables?.callsign ||
payload?.metadata?.context?.variables?.CALLSIGN ||
payload?.metadata?.context?.variables?.Callsign
return (
fromMetadata(entry.lastEntry) ||
fromMetadata(entry.firstEntry) ||
entry.lastEntry?.metadata?.variables?.callsign ||
entry.firstEntry?.metadata?.variables?.callsign ||
undefined
)
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
const pageSizeRaw = parseInt(String(query.pageSize ?? query.limit ?? '20'), 10)
const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100)
const skip = (page - 1) * pageSize
const matchStage = { sessionId: { $exists: true, $nin: [null, ''] } }
const [sessionDocs, totalCountAgg] = await Promise.all([
TransmissionLog.aggregate([
{ $match: matchStage },
{ $sort: { sessionId: 1, createdAt: 1 } },
{
$group: {
_id: '$sessionId',
firstEntry: { $first: '$$ROOT' },
lastEntry: { $last: '$$ROOT' },
count: { $sum: 1 },
},
},
{ $sort: { 'lastEntry.createdAt': -1 } },
{ $skip: skip },
{ $limit: pageSize },
]).exec(),
TransmissionLog.aggregate([
{ $match: matchStage },
{ $group: { _id: '$sessionId' } },
{ $count: 'count' },
]).exec(),
])
const total = totalCountAgg?.[0]?.count || 0
const items: SessionSummaryEntry[] = sessionDocs.map((entry: any) => {
const first = entry.firstEntry || {}
const last = entry.lastEntry || {}
return {
sessionId: entry._id,
startedAt: toISO(first.createdAt),
updatedAt: toISO(last.createdAt),
entryCount: entry.count || 0,
callsign: extractCallsign(entry),
lastMessage: last.text
? {
text: last.text,
role: last.role,
channel: last.channel,
createdAt: toISO(last.createdAt) || new Date().toISOString(),
}
: undefined,
}
})
return {
items,
pagination: {
total,
page,
pageSize,
pages: Math.ceil(total / pageSize) || 1,
},
}
})

View File

@@ -0,0 +1,85 @@
import { defineEventHandler, createError } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { TransmissionLog } from '../../../../models/TransmissionLog'
function toISO(value: any): string | null {
if (!value) {
return null
}
const date = new Date(value)
return Number.isNaN(date.valueOf()) ? null : date.toISOString()
}
function mapEntry(doc: any) {
return {
id: String(doc._id),
role: doc.role,
channel: doc.channel,
direction: doc.direction,
text: doc.text,
normalized: doc.normalized || undefined,
createdAt: toISO(doc.createdAt) || new Date().toISOString(),
metadata: doc.metadata || undefined,
sessionId: doc.sessionId || undefined,
user: doc.user
? {
id: String(doc.user._id),
email: doc.user.email,
name: doc.user.name || undefined,
role: doc.user.role,
}
: undefined,
}
}
function extractCallsign(entries: any[]): string | undefined {
for (const entry of [...entries].reverse()) {
const callsign =
entry.metadata?.context?.variables?.callsign ||
entry.metadata?.context?.variables?.CALLSIGN ||
entry.metadata?.variables?.callsign
if (callsign) {
return callsign
}
}
return undefined
}
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const sessionId = event.context.params?.sessionId
if (!sessionId || typeof sessionId !== 'string') {
throw createError({ statusCode: 400, statusMessage: 'Session ID is required' })
}
const items = await TransmissionLog.find({ sessionId })
.sort({ createdAt: 1 })
.populate('user', 'email name role')
.lean()
.exec()
if (!items.length) {
return {
sessionId,
startedAt: null,
updatedAt: null,
entryCount: 0,
callsign: undefined,
entries: [],
}
}
const startedAt = toISO(items[0].createdAt)
const updatedAt = toISO(items[items.length - 1].createdAt)
const callsign = extractCallsign(items)
return {
sessionId,
startedAt,
updatedAt,
entryCount: items.length,
callsign,
entries: items.map(mapEntry),
}
})

View File

@@ -17,6 +17,7 @@ type TransmissionListItem = {
createdAt: string
metadata?: Record<string, any>
user?: { id: string; email: string; name?: string; role: string }
sessionId?: string
}
function escapeRegExp(input: string) {
@@ -33,6 +34,7 @@ function mapTransmission(doc: any): TransmissionListItem {
normalized: doc.normalized || undefined,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
metadata: doc.metadata || undefined,
sessionId: doc.sessionId || undefined,
user: doc.user
? {
id: String(doc.user._id),

View File

@@ -31,14 +31,8 @@ interface PTTRequest {
interface PTTResponse {
success: boolean;
transcription: string;
decision?: {
next_state: string;
controller_say_tpl?: string;
off_schema?: boolean;
radio_check?: boolean;
activate_flow?: string;
resume_previous?: boolean;
};
decision?: LLMDecisionResult['decision'];
trace?: LLMDecisionResult['trace'];
}
async function sh(cmd: string, args: string[]) {
@@ -228,6 +222,7 @@ export default defineEventHandler(async (event) => {
return {
id: candidate.id,
flow: candidate.flow || undefined,
state: candidateState
};
})
@@ -235,12 +230,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,
@@ -270,6 +270,9 @@ export default defineEventHandler(async (event) => {
if (decision) {
result.decision = decision;
}
if (decisionResult?.trace) {
result.trace = decisionResult.trace;
}
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

@@ -43,6 +43,9 @@ export default defineEventHandler(async (event) => {
.filter((phase: string) => phase.length)
: []
const entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
const isMain = body.isMain === true
const endStates = Array.isArray(body.endStates)
? body.endStates
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
@@ -62,10 +65,19 @@ export default defineEventHandler(async (event) => {
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
roles,
phases,
entryMode,
isMain,
})
await flow.save()
if (isMain) {
await DecisionFlow.updateMany(
{ _id: { $ne: flow._id } },
{ $set: { isMain: false } }
)
}
const { flow: serialized } = await getFlowWithNodes(slug)
return serialized
})

View File

@@ -59,6 +59,14 @@ export default defineEventHandler(async (event) => {
flow.phases = phases
}
if (typeof body.entryMode === 'string') {
flow.entryMode = body.entryMode === 'linear' ? 'linear' : 'parallel'
}
if (typeof body.isMain === 'boolean') {
flow.isMain = body.isMain
}
if (body.variables && typeof body.variables === 'object') {
flow.variables = body.variables
flow.markModified('variables')
@@ -145,6 +153,13 @@ export default defineEventHandler(async (event) => {
await flow.save()
if (flow.isMain) {
await DecisionFlow.updateMany(
{ _id: { $ne: flow._id } },
{ $set: { isMain: false } }
)
}
const data = await getFlowWithNodes(slug)
return data
})

View File

@@ -13,9 +13,9 @@ export default defineEventHandler(async (event) => {
}
try {
const { decision, trace } = await routeDecision(body)
const result = await routeDecision(body)
const { decision, trace } = result
// Log for debugging when off-schema or radio check triggers
if (decision.off_schema) {
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
}
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)')
}
return decision
return result
} catch (err: any) {
console.error('Router failed:', err)
throw createError({ statusCode: 500, statusMessage: err?.message || 'Router failed' })

View File

@@ -19,6 +19,8 @@ export interface DecisionFlowDocument extends mongoose.Document {
phases: string[]
layout?: DecisionFlowLayout
metadata?: DecisionFlowMetadata
entryMode?: 'parallel' | 'linear'
isMain?: boolean
createdAt: Date
updatedAt: Date
}
@@ -37,6 +39,8 @@ const decisionFlowSchema = new mongoose.Schema<DecisionFlowDocument>(
hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
roles: { type: [String], default: () => [] },
phases: { type: [String], default: () => [] },
entryMode: { type: String, enum: ['parallel', 'linear'], default: 'parallel' },
isMain: { type: Boolean, default: false },
layout: {
type: new mongoose.Schema<DecisionFlowLayout>(
{

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() },
})

View File

@@ -32,6 +32,8 @@ export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0):
createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(),
updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(),
nodeCount,
entryMode: doc.entryMode || 'parallel',
isMain: doc.isMain || false,
}
}
@@ -91,6 +93,8 @@ export async function listDecisionFlows(): Promise<DecisionFlowSummary[]> {
nodeCount: countMap[String(flow._id)] || 0,
updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(),
createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(),
entryMode: flow.entryMode || 'parallel',
isMain: Boolean(flow.isMain),
}))
}
@@ -157,6 +161,8 @@ function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState
return {
role: obj.role as any,
phase: obj.phase,
name: obj.title || undefined,
summary: obj.summary || undefined,
say_tpl: obj.sayTemplate || undefined,
utterance_tpl: obj.utteranceTemplate || undefined,
else_say_tpl: obj.elseSayTemplate || undefined,
@@ -203,6 +209,7 @@ async function buildRuntimeTreeForDoc(
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
states,
entry_mode: flowDoc.isMain ? 'main' : flowDoc.entryMode || 'parallel',
}
}
@@ -245,7 +252,9 @@ export async function buildRuntimeDecisionSystem(): Promise<RuntimeDecisionSyste
}, {})
const order = runtimeTrees.map((tree) => tree.slug)
const main = flows['icao_atc_decision_tree'] ? 'icao_atc_decision_tree' : order[0]
const preferredMain = flowDocs.find((doc) => doc.isMain)?.slug
const fallbackMain = flowDocs.find((doc) => doc.slug === 'icao_atc_decision_tree')?.slug
const main = preferredMain || fallbackMain || order[0]
return {
main,

View File

@@ -1,7 +1,17 @@
// server/utils/openai.ts
import OpenAI from 'openai'
import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech'
import type {LLMDecision, LLMDecisionInput} from '../../shared/types/llm'
import type {
CandidateTraceEntry,
CandidateTraceStep,
DecisionCandidateTimeline,
FlowActivationInstruction,
FlowActivationMode,
LLMDecision,
LLMDecisionInput,
LLMDecisionTrace,
LLMDecisionTraceCall,
} from '../../shared/types/llm'
import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision'
import { buildRuntimeDecisionSystem } from '../services/decisionFlowService'
import {getServerRuntimeConfig} from './runtimeConfig'
@@ -64,23 +74,6 @@ export async function decide(system: string, user: string): Promise<string> {
return r.choices?.[0]?.message?.content?.trim() || ''
}
export interface LLMDecisionTraceCall {
stage: 'readback-check' | 'decision'
request: Record<string, any>
response?: any
rawResponseText?: string
error?: string
}
export interface LLMDecisionTrace {
calls: LLMDecisionTraceCall[]
fallback?: {
used: boolean
reason?: string
selected?: string
}
}
export interface LLMDecisionResult {
decision: LLMDecision
trace?: LLMDecisionTrace
@@ -211,12 +204,18 @@ interface DecisionCandidate {
id: string
flow: string
state: RuntimeDecisionState
triggers: DecisionNodeTrigger[]
regexTriggers: DecisionNodeTrigger[]
noneTriggers: DecisionNodeTrigger[]
}
interface PreparedCandidateResult {
filteredCandidates: DecisionCandidate[]
finalCandidates: DecisionCandidate[]
candidateFlowMap: Map<string, string>
activeFlowSlug: string
flowEntryModes: Map<string, FlowActivationMode>
timeline: DecisionCandidateTimeline
autoSelected?: DecisionCandidate | null
}
const RUNTIME_CACHE_TTL_MS = 5_000
@@ -322,34 +321,48 @@ function parseComparable(raw: any): any {
return raw
}
function compareValuesSafe(left: any, operator: string | undefined, right: any): boolean {
function compareValuesSafe(left: any, operator: string | undefined, right: any): {
result: boolean
left: any
right: any
operator: string
} {
const normalizedLeft = normalizeComparable(left)
const normalizedRight = normalizeComparable(parseComparable(right))
switch (operator) {
const op = operator || '=='
let result = false
switch (op) {
case '>':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft > normalizedRight
: false
break
case '>=':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft >= normalizedRight
: false
break
case '<':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft < normalizedRight
: false
break
case '<=':
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
result = typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
? normalizedLeft <= normalizedRight
: false
break
case '!==':
case '!=':
return normalizedLeft !== normalizedRight
result = normalizedLeft !== normalizedRight
break
case '===':
case '==':
default:
return normalizedLeft === normalizedRight
result = normalizedLeft === normalizedRight
break
}
return { result, left: normalizedLeft, right: normalizedRight, operator: op }
}
function resolveContextPath(
@@ -380,18 +393,39 @@ function evaluateConditionEntry(
condition: DecisionNodeCondition | undefined,
context: { variables: Record<string, any>; flags: Record<string, any> },
utterance: string
): boolean {
if (!condition) return true
): { passed: boolean; detail?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } {
if (!condition) return { passed: true }
switch (condition.type) {
case 'regex':
return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
case 'regex_not':
return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
case 'regex': {
const passed = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
return {
passed,
detail: passed ? undefined : { condition },
}
}
case 'regex_not': {
const matched = evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
const passed = !matched
return {
passed,
detail: passed ? undefined : { condition },
}
}
case 'variable_value':
default: {
const left = resolveContextPath(condition.variable, context)
const operator = condition.operator || '=='
return compareValuesSafe(left, operator, condition.value)
const comparison = compareValuesSafe(left, condition.operator, condition.value)
return {
passed: comparison.result,
detail: comparison.result
? undefined
: {
condition,
actualValue: comparison.left,
expectedValue: comparison.right,
operator: comparison.operator,
},
}
}
}
}
@@ -400,48 +434,26 @@ function evaluateConditionList(
conditions: DecisionNodeCondition[] | undefined,
context: { variables: Record<string, any>; flags: Record<string, any> },
utterance: string
): boolean {
): { passed: boolean; failure?: { condition: DecisionNodeCondition; actualValue?: any; expectedValue?: any; operator?: string } } {
if (!Array.isArray(conditions) || conditions.length === 0) {
return true
return { passed: 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
const result = evaluateConditionEntry(condition, context, utterance)
if (!result.passed) {
return {
passed: false,
failure: {
condition,
actualValue: result.detail?.actualValue,
expectedValue: result.detail?.expectedValue,
operator: result.detail?.operator,
},
}
}
}
return 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)
)
return { passed: true }
}
async function prepareDecisionCandidates(
@@ -465,12 +477,42 @@ async function prepareDecisionCandidates(
activeFlowSlug = system.main || Object.keys(system.flows)[0] || ''
}
const uniqueCandidates = new Map<string, DecisionCandidate>()
const flowEntryModes = new Map<string, FlowActivationMode>()
for (const [slug, tree] of Object.entries(system.flows || {})) {
const mode = tree.entry_mode === 'main'
? 'main'
: tree.entry_mode === 'linear'
? 'linear'
: slug === system.main
? 'main'
: 'parallel'
flowEntryModes.set(slug, mode)
}
const addCandidate = (candidate: DecisionCandidate | null | undefined) => {
if (!candidate || !candidate.id || !candidate.state) return
if (uniqueCandidates.has(candidate.id)) return
uniqueCandidates.set(candidate.id, candidate)
const candidateMap = new Map<string, DecisionCandidate>()
const createCandidate = (id: string, flow: string | undefined, state: RuntimeDecisionState | undefined): DecisionCandidate | null => {
if (!id || !state) return null
const triggers = Array.isArray(state.triggers) ? state.triggers.filter(Boolean) : []
const regexTriggers = triggers.filter(trigger => trigger?.type === 'regex')
const noneTriggers = triggers.filter(trigger => trigger?.type === 'none')
return {
id,
flow: flow || activeFlowSlug,
state,
triggers,
regexTriggers,
noneTriggers,
}
}
const addCandidate = (id: string | undefined, flow: string | undefined, state: RuntimeDecisionState | undefined) => {
if (!id) return
if (candidateMap.has(id)) return
const candidate = createCandidate(id, flow, state)
if (candidate) {
candidateMap.set(id, candidate)
}
}
for (const raw of input.candidates || []) {
@@ -478,14 +520,13 @@ async function prepareDecisionCandidates(
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 })
addCandidate(raw.id, flow, 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 })
if (!candidateMap.has(raw.id)) {
addCandidate(raw.id, raw.flow || activeFlowSlug, raw.state)
}
}
@@ -493,25 +534,211 @@ async function prepareDecisionCandidates(
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 state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId]
addCandidate(startStateId, flowSlug, state)
}
const candidates = Array.from(uniqueCandidates.values())
const candidates = Array.from(candidateMap.values())
const context = { variables: input.variables || {}, flags: input.flags || {} }
const filteredCandidates = filterDecisionCandidates(candidates, utterance, context)
const timelineSteps: CandidateTraceStep[] = []
let fallbackUsed = false
const toTraceEntry = (candidate: DecisionCandidate): CandidateTraceEntry => ({
id: candidate.id,
flow: candidate.flow,
name: candidate.state?.name,
summary: candidate.state?.summary,
role: candidate.state?.role,
triggers: candidate.triggers,
conditions: candidate.state?.conditions || [],
})
const recordStep = (
stage: CandidateTraceStage,
label: string,
stepCandidates: DecisionCandidate[],
eliminated: CandidateTraceElimination[] = [],
note?: string
) => {
timelineSteps.push({
stage,
label,
candidates: stepCandidates.map(toTraceEntry),
eliminated: eliminated.length ? eliminated : undefined,
note,
})
}
const regexCandidates = candidates.filter(candidate => candidate.regexTriggers.length > 0)
let workingSet: DecisionCandidate[] = []
if (regexCandidates.length > 0) {
recordStep('regex_candidates', 'Regex candidates', regexCandidates)
const survivors: DecisionCandidate[] = []
const eliminated: CandidateTraceElimination[] = []
for (const candidate of regexCandidates) {
const matched = candidate.regexTriggers.some(trigger =>
evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)
)
if (matched) {
survivors.push(candidate)
} else {
eliminated.push({
candidate: toTraceEntry(candidate),
kind: 'regex',
reason: 'No regex trigger matched the pilot utterance.',
context: {
patterns: candidate.regexTriggers.map(trigger => ({
id: trigger.id,
pattern: trigger.pattern,
flags: trigger.patternFlags,
})),
transcript: utterance,
},
})
}
}
recordStep(
'regex_filtered',
'Regex evaluation',
survivors,
eliminated,
survivors.length ? undefined : 'No regex triggers matched the pilot transmission.'
)
workingSet = survivors
} else {
recordStep('regex_candidates', 'Regex candidates', [], [], 'No regex-triggered transitions available.')
workingSet = []
}
let finalCandidates: DecisionCandidate[] = []
if (workingSet.length > 0) {
const survivors: DecisionCandidate[] = []
const eliminated: CandidateTraceElimination[] = []
for (const candidate of workingSet) {
const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance)
if (evaluation.passed) {
survivors.push(candidate)
} else if (evaluation.failure) {
eliminated.push({
candidate: toTraceEntry(candidate),
kind: 'condition',
reason: 'Node conditions were not satisfied.',
context: {
condition: evaluation.failure.condition,
actualValue: evaluation.failure.actualValue,
expectedValue: evaluation.failure.expectedValue,
operator: evaluation.failure.operator,
},
})
} else {
eliminated.push({
candidate: toTraceEntry(candidate),
kind: 'condition',
reason: 'Node conditions were not satisfied.',
})
}
}
recordStep(
'condition_filtered',
'Condition evaluation',
survivors,
eliminated,
survivors.length ? undefined : 'All regex candidates failed their conditions.'
)
finalCandidates = survivors
}
if (finalCandidates.length === 0) {
fallbackUsed = true
const fallbackCandidates = candidates.filter(candidate =>
candidate.noneTriggers.length > 0 || (candidate.triggers.length === 0 && candidate.regexTriggers.length === 0)
)
if (fallbackCandidates.length > 0) {
recordStep('fallback_candidates', 'Fallback candidates', fallbackCandidates)
const survivors: DecisionCandidate[] = []
const eliminated: CandidateTraceElimination[] = []
for (const candidate of fallbackCandidates) {
const evaluation = evaluateConditionList(candidate.state?.conditions, context, utterance)
if (evaluation.passed) {
survivors.push(candidate)
} else if (evaluation.failure) {
eliminated.push({
candidate: toTraceEntry(candidate),
kind: 'condition',
reason: 'Node conditions were not satisfied.',
context: {
condition: evaluation.failure.condition,
actualValue: evaluation.failure.actualValue,
expectedValue: evaluation.failure.expectedValue,
operator: evaluation.failure.operator,
},
})
} else {
eliminated.push({
candidate: toTraceEntry(candidate),
kind: 'condition',
reason: 'Node conditions were not satisfied.',
})
}
}
recordStep(
'fallback_filtered',
'Fallback evaluation',
survivors,
eliminated,
survivors.length ? undefined : 'No fallback candidates satisfied their conditions.'
)
finalCandidates = survivors
} else {
recordStep('fallback_candidates', 'Fallback candidates', [], [], 'No fallback triggers defined.')
recordStep('fallback_filtered', 'Fallback evaluation', [], [], 'No fallback candidates available.')
}
}
recordStep(
'final',
'Final candidates',
finalCandidates,
[],
finalCandidates.length ? undefined : 'No transitions remain after evaluation.'
)
const autoSelected = finalCandidates.length === 1 ? finalCandidates[0] : null
const candidateFlowMap = new Map<string, string>()
for (const candidate of filteredCandidates) {
for (const candidate of finalCandidates) {
if (candidate.flow) {
candidateFlowMap.set(candidate.id, candidate.flow)
}
}
const timeline: DecisionCandidateTimeline = {
steps: timelineSteps,
fallbackUsed,
autoSelected: autoSelected ? toTraceEntry(autoSelected) : null,
}
return {
filteredCandidates,
finalCandidates,
candidateFlowMap,
activeFlowSlug,
flowEntryModes,
timeline,
autoSelected,
}
}
@@ -649,28 +876,74 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
const prepared = await prepareDecisionCandidates(input, pilotUtterance)
candidateFlowMap = prepared.candidateFlowMap
const flowEntryModes = prepared.flowEntryModes
if (prepared.activeFlowSlug) {
activeFlowSlug = prepared.activeFlowSlug
input.flow_slug = prepared.activeFlowSlug
}
input.candidates = prepared.filteredCandidates.map(candidate => ({
trace.candidateTimeline = prepared.timeline
if (prepared.autoSelected) {
trace.autoSelection = {
id: prepared.autoSelected.id,
flow: prepared.autoSelected.flow,
reason: 'Single candidate remained after trigger and condition evaluation.',
}
}
input.candidates = prepared.finalCandidates.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
const resolveActivationInstruction = (
value?: string | FlowActivationInstruction | null
): FlowActivationInstruction | undefined => {
if (!value) return undefined
if (typeof value === 'string') {
const normalizedMode = flowEntryModes.get(value)
|| (value === prepared.activeFlowSlug ? 'main' : undefined)
return {
slug: value,
mode: normalizedMode || 'parallel',
}
}
if (!trace.calls.length && !trace.fallback) {
return {decision}
if (!value.slug) return undefined
const normalizedMode = value.mode
|| flowEntryModes.get(value.slug)
|| (value.slug === prepared.activeFlowSlug ? 'main' : undefined)
return {
slug: value.slug,
mode: normalizedMode || 'parallel',
}
return {decision, trace}
}
const finalize = (decision: LLMDecision): LLMDecisionResult => {
const targetState = decision.next_state
let activation = resolveActivationInstruction(decision.activate_flow as any)
if (!activation && targetState) {
const targetFlow = candidateFlowMap.get(targetState)
if (targetFlow && targetFlow !== activeFlowSlug) {
activation = resolveActivationInstruction(targetFlow)
}
}
if (activation) {
decision.activate_flow = activation
} else if (decision.activate_flow) {
delete (decision as any).activate_flow
}
const shouldAttachTrace = Boolean(
trace.calls.length
|| trace.fallback
|| (trace.candidateTimeline && trace.candidateTimeline.steps.length)
|| trace.autoSelection
)
if (!shouldAttachTrace) {
return { decision }
}
return { decision, trace }
}
async function handleReadbackCheck(): Promise<LLMDecisionResult> {
@@ -799,24 +1072,6 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
}
}
// Instant detection without the LLM for common cases
if (pilotText.includes('radio check') || pilotText.includes('signal test') ||
(pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) {
return finalize({
next_state: input.state_id,
radio_check: true,
controller_say_tpl: `${input.variables.callsign}, read you five by five.`
})
}
// Emergency ohne LLM
if (pilotText.startsWith('mayday') && input.flags.in_air) {
return finalize({next_state: 'INT_MAYDAY'})
}
if (pilotText.startsWith('pan pan') && input.flags.in_air) {
return finalize({next_state: 'INT_PANPAN'})
}
const optimizedInput = optimizeInputForLLM(input)
// Check whether the next states require ATC responses

View File

@@ -126,6 +126,8 @@ export interface DecisionNodeMetadata {
complexity?: 'low' | 'medium' | 'high'
}
export type DecisionFlowEntryMode = 'parallel' | 'linear'
export interface DecisionNodeModel {
stateId: string
title?: string
@@ -197,6 +199,8 @@ export interface DecisionFlowModel {
createdAt: string
updatedAt: string
nodeCount?: number
entryMode?: DecisionFlowEntryMode
isMain?: boolean
}
export interface RuntimeDecisionAutoTransition {
@@ -213,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
@@ -248,6 +254,7 @@ export interface RuntimeDecisionTree {
roles: DecisionNodeRole[]
phases: string[]
states: Record<string, RuntimeDecisionState>
entry_mode?: 'main' | DecisionFlowEntryMode
}
export interface RuntimeDecisionSystem {
@@ -265,4 +272,6 @@ export interface DecisionFlowSummary {
nodeCount: number
updatedAt: string
createdAt: string
entryMode?: DecisionFlowEntryMode
isMain?: boolean
}

View File

@@ -1,3 +1,5 @@
import type { DecisionNodeCondition, DecisionNodeTrigger } from './decision'
export interface LLMDecisionInput {
state_id: string
state: any
@@ -8,6 +10,68 @@ export interface LLMDecisionInput {
flow_slug?: string
}
export type FlowActivationMode = 'main' | 'parallel' | 'linear'
export interface FlowActivationInstruction {
slug: string
mode?: FlowActivationMode
}
export interface CandidateTraceEntry {
id: string
flow: string
name?: string
summary?: string
role?: string
triggers?: DecisionNodeTrigger[]
conditions?: DecisionNodeCondition[]
}
export type CandidateTraceStage =
| 'regex_candidates'
| 'regex_filtered'
| 'condition_filtered'
| 'fallback_candidates'
| 'fallback_filtered'
| 'final'
export interface CandidateTraceEliminationContext {
patterns?: Array<{ id?: string; pattern?: string; flags?: string }>
transcript?: string
condition?: {
id?: string
type: DecisionNodeCondition['type']
variable?: string
operator?: string
value?: number | string | boolean
pattern?: string
patternFlags?: string
}
actualValue?: any
expectedValue?: any
}
export interface CandidateTraceElimination {
candidate: CandidateTraceEntry
kind: 'regex' | 'condition'
reason: string
context?: CandidateTraceEliminationContext
}
export interface CandidateTraceStep {
stage: CandidateTraceStage
label: string
candidates: CandidateTraceEntry[]
eliminated?: CandidateTraceElimination[]
note?: string
}
export interface DecisionCandidateTimeline {
steps: CandidateTraceStep[]
fallbackUsed?: boolean
autoSelected?: CandidateTraceEntry | null
}
export interface LLMDecision {
next_state: string
updates?: Record<string, any>
@@ -15,6 +79,31 @@ export interface LLMDecision {
controller_say_tpl?: string
off_schema?: boolean
radio_check?: boolean
activate_flow?: string
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
}
}

View File

@@ -7,6 +7,7 @@ import type {
RuntimeDecisionAutoTransition,
DecisionNodeAutoTrigger,
} from '../types/decision'
import type { FlowActivationInstruction, FlowActivationMode, LLMDecisionTrace } from '../types/llm'
import { normalizeRadioPhrase } from './radioSpeech'
// --- DecisionTree runtime types ---
@@ -20,6 +21,7 @@ interface EngineFlags {
stack: string[]
off_schema_count: number
radio_checks_done: number
session_id: string
[key: string]: any
}
@@ -120,6 +122,10 @@ 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)
@@ -165,9 +171,12 @@ 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>>({})
@@ -227,7 +236,18 @@ export default function useCommunicationsEngine() {
}
}
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
@@ -275,11 +295,15 @@ export default function useCommunicationsEngine() {
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,
@@ -341,6 +365,49 @@ export default function useCommunicationsEngine() {
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 []
@@ -416,6 +483,15 @@ export default function useCommunicationsEngine() {
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]
@@ -440,6 +516,7 @@ export default function useCommunicationsEngine() {
stack: [],
off_schema_count: 0,
radio_checks_done: 0,
session_id: ensureSessionValue(),
})
assignActiveTelemetry({
altitude_ft: 0,
@@ -466,6 +543,8 @@ export default function useCommunicationsEngine() {
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
@@ -477,19 +556,11 @@ export default function useCommunicationsEngine() {
name: treeData.name || slug,
description: treeData.description,
start: treeData.start_state,
mode: treeData.entry_mode || (slug === runtimeSystem.value!.main ? 'main' : 'parallel'),
}
})
})
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
@@ -689,7 +760,8 @@ export default function useCommunicationsEngine() {
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)
@@ -738,11 +810,14 @@ export default function useCommunicationsEngine() {
}
}
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)
}
@@ -759,11 +834,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.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)
}
}
}
@@ -792,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)
}
@@ -806,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 {
@@ -986,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 = {
@@ -1096,6 +1174,8 @@ export default function useCommunicationsEngine() {
clearCommunicationLog: () => { assignCommunicationLog([]) },
activeFlow,
availableFlows,
sessionId: readonly(sessionId),
lastDecisionTrace: readonly(lastDecisionTrace),
// pm_alt.vue integration
flightContext: readonly(flightContext),
@@ -1118,7 +1198,6 @@ export default function useCommunicationsEngine() {
// Flow Control
moveTo,
resumePriorFlow,
// Utilities
normalizeATCText,

View File

@@ -1,9 +1,9 @@
// utils/openaiDecision.ts
import type { LLMDecision, LLMDecisionInput } from '../types/llm'
import type { LLMDecisionInput, LLMDecisionResult } from '../types/llm'
/** Client-seitig: ruft den Backend-Endpunkt auf */
export async function decideNextStateLLM(input: LLMDecisionInput): Promise<LLMDecision> {
return await $fetch<LLMDecision>('/api/llm/decide', {
export async function decideNextStateLLM(input: LLMDecisionInput): Promise<LLMDecisionResult> {
return await $fetch<LLMDecisionResult>('/api/llm/decide', {
method: 'POST',
body: input
})