From ba93c494c94e20d80bba3436192d55fb39232c90 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 21 Sep 2025 23:08:10 +0200 Subject: [PATCH] Add session timeline logging and admin sessions view --- app/pages/admin/index.vue | 908 +++++++----------- app/pages/pm.vue | 179 ++-- server/api/admin/logs/sessions.get.ts | 107 +++ .../admin/logs/sessions/[sessionId].get.ts | 85 ++ server/api/admin/logs/transmissions.get.ts | 2 + server/api/atc/ptt.post.ts | 19 +- server/api/atc/say.post.ts | 7 + server/api/editor/flows.post.ts | 12 + server/api/editor/flows/[slug]/index.put.ts | 15 + server/api/llm/decide.post.ts | 6 +- server/models/DecisionFlow.ts | 4 + server/models/TransmissionLog.ts | 2 + server/services/decisionFlowService.ts | 11 +- server/utils/openai.ts | 483 +++++++--- shared/types/decision.ts | 9 + shared/types/llm.ts | 91 +- shared/utils/communicationsEngine.ts | 165 +++- shared/utils/openaiDecision.ts | 6 +- 18 files changed, 1303 insertions(+), 808 deletions(-) create mode 100644 server/api/admin/logs/sessions.get.ts create mode 100644 server/api/admin/logs/sessions/[sessionId].get.ts diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue index 2ccbe6f..5dbc629 100644 --- a/app/pages/admin/index.vue +++ b/app/pages/admin/index.vue @@ -612,443 +612,223 @@ + -
-
-
- - - - - - - Apply filters - -
-
- {{ logPagination.total }} entries · Page {{ logPagination.page }} of {{ logPagination.pages }} -
-
- - - {{ logError }} - - -
- -

Loading radio logs…

-
- -
-
- - -
-
- {{ entry.channel }} - {{ entry.direction }} - {{ entry.role }} - User: {{ entry.user.email }} -
- {{ formatDateTime(entry.createdAt) }} -
-

{{ entry.text }}

-

- Normalized: {{ entry.normalized }} -

-
- Module {{ entry.metadata.moduleId }} - Lesson {{ entry.metadata.lessonId }} - - Auto Decision: {{ entry.metadata.autoDecide ? 'Yes' : 'No' }} - -
-
- - {{ expandedLog === entry.id ? 'Close tracer' : 'Open tracer' }} - -
- -
-
-

LLM Decision Summary

-
-
-

Next State

-

{{ entry.metadata.decision.next_state || '—' }}

-
-
-

Controller

-

{{ entry.metadata.decision.controller_say_tpl || '—' }}

-
-
-
- - Off-script: {{ entry.metadata.decision.off_schema ? 'Yes' : 'No' }} - - - Radio check: {{ entry.metadata.decision.radio_check ? 'Yes' : 'No' }} - -
-
- -
-

State context snapshot

-
-
-
-

- {{ entry.metadata.context.stateId || 'Unknown state' }} -

-

- {{ entry.metadata.context.state?.name }} -

-
-
- - Role: {{ entry.metadata.context.state?.role }} - - - Phase: {{ entry.metadata.context.state?.phase }} - -
-
-
-

State Say Template

-

{{ entry.metadata.context.state?.say_tpl }}

-
-
-
-

Next transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-

OK transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-

Bad transitions

-
    -
  • - {{ describeTransition(transition) }} -
  • -
-

-
-
-
-

Candidates

-
-
- {{ candidate.id || 'unknown' }} - - Selected - -
-

- {{ candidate.state?.name }} -

-
- - Role: {{ candidate.state?.role }} - - - Requires ATC reply - -
-

- {{ candidate.state?.say_tpl }} -

-
-
-
-
-

Variables snapshot

-
{{ formatJson(entry.metadata.context.variables) }}
-
-
-

Flags snapshot

-
{{ formatJson(entry.metadata.context.flags) }}
-
-
-
-
-
-

OpenAI Decision Calls

-
-
- - Step: {{ call.stage === 'decision' ? 'Decision' : 'Readback check' }} - - Call failed -
-
-

Request

-
{{ formatJson(call.request) }}
-
-
-

Response

-
{{ formatJson(call.response) }}
-
-
- No response received. -
-
-

Raw Response

-
{{ call.rawResponseText }}
-
- - {{ call.error }} - -
-
-

Fallback activated

-

- Reason: {{ entry.metadata.decisionTrace.fallback.reason || 'unknown' }} - - · Path: {{ entry.metadata.decisionTrace.fallback.selected }} - -

-
-
- -
-

Say endpoint invocation

-
-
-

Voice

-

{{ entry.metadata?.voice || '—' }}

-
-
-

Signal level

-

{{ entry.metadata?.level ?? '—' }}

-
-
-

Speech speed

-

{{ entry.metadata?.speed ?? '—' }}

-
-
-

Radio quality

-

{{ entry.metadata?.radioQuality || '—' }}

-
-
-
-
-

TTS provider

-

{{ entry.metadata?.tts?.provider || '—' }}

-
-
-

Model

-

{{ entry.metadata?.tts?.model || '—' }}

-
-
-

Format

-

- {{ entry.metadata?.tts?.format || '—' }} - - ({{ entry.metadata?.tts?.extension }}) - -

-
-
-
-

Tag

-

{{ entry.metadata?.tag }}

-
-
-
-

Metadata

-
{{ formatJson(entry.metadata) }}
-
-
-
-
-
-
-

No transmissions found.

- -
-
- Page {{ logPagination.page }} of {{ logPagination.pages }} · {{ logPagination.total }} logs +
+
+
+
+

Sessions

+

{{ sessionPagination.total }} conversations

- Back + Prev Next
+ + + {{ sessionsError }} + + +
+ + Loading sessions… +
+ +
+ + +
+ {{ session.callsign || 'Unknown' }} + {{ formatRelative(session.updatedAt || undefined) }} +
+

Session {{ session.sessionId }}

+
+ {{ session.entryCount }} entries + {{ formatDateTime(session.startedAt || undefined) }} +
+

+ {{ session.lastMessage.role.toUpperCase() }} · {{ session.lastMessage.text }} +

+
+
+ +

+ No sessions recorded yet. +

+
+
+ +
+
+ + Loading session details… +
+ + + {{ sessionDetailError }} + + +
+
+
+
+

Session

+

{{ sessionDetails.sessionId }}

+
+
+ {{ sessionDetails.entryCount }} entries + {{ formatDateTime(sessionDetails.startedAt || undefined) }} + + {{ formatDateTime(sessionDetails.updatedAt || undefined) }} +
+
+

Callsign: {{ sessionDetails.callsign || 'Unknown' }}

+
+ +
+
+
+ + {{ entry.channel }} + {{ entry.role }} + {{ entry.direction }} + + {{ formatDateTime(entry.createdAt) }} +
+

{{ entry.text }}

+

{{ entry.normalized }}

+
+ User: {{ entry.user.email }} + + {{ expandedEntryId === entry.id ? 'Hide decision' : 'Show decision' }} + +
+ +
+
+

Decision trace

+
+ Next: {{ entry.metadata.decision.next_state }} + + Flow: + {{ typeof entry.metadata.decision.activate_flow === 'string' + ? entry.metadata.decision.activate_flow + : entry.metadata.decision.activate_flow.slug }} + +
+
+
+
+
+
+

{{ step.label }}

+

{{ step.stage }}

+
+ {{ step.candidates.length }} candidates +
+

{{ step.note }}

+
+
+
+ {{ candidate.id }} + {{ candidate.flow || 'current' }} +
+

{{ candidate.summary }}

+
+
+
+

Eliminated

+
+
+ {{ elim.candidate.id }} + {{ elim.kind }} +
+

{{ elim.reason }}

+

{{ describeElimination(elim) }}

+
+
+
+
+

No candidate timeline recorded.

+
+
+
+
+ +

+ No messages recorded for this session. +

+
+ +

Select a session to view its transcript.

+
@@ -1252,15 +1032,6 @@ interface TransmissionMetadata { [key: string]: any } -interface LlmUsageSummary { - method: string - openaiUsed: boolean - callCount: number - fallbackUsed: boolean - autoDecide?: boolean - reason?: string -} - interface TransmissionEntry { id: string role: string @@ -1271,13 +1042,37 @@ interface TransmissionEntry { createdAt: string metadata?: TransmissionMetadata user?: TransmissionUser + sessionId?: string } -interface LogsResponse { - items: TransmissionEntry[] +interface SessionSummary { + sessionId: string + startedAt: string | null + updatedAt: string | null + entryCount: number + callsign?: string + lastMessage?: { + text: string + role: string + channel: string + createdAt: string + } +} + +interface SessionsResponse { + items: SessionSummary[] pagination: { total: number; page: number; pageSize: number; pages: number } } +interface SessionDetail { + sessionId: string + startedAt: string | null + updatedAt: string | null + entryCount: number + callsign?: string + entries: TransmissionEntry[] +} + interface WaitlistEntryItem { id: string email: string @@ -1387,38 +1182,16 @@ const waitlistStatusOptions = [ ] const waitlistStats = reactive({ total: 0, updates: 0, activated: 0, pending: 0 }) -const logs = ref([]) -const logPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 }) -const logLoading = ref(false) -const logError = ref('') -const logSearch = ref('') -const logChannel = ref<'all' | 'ptt' | 'say' | 'text'>('all') -const logDirection = ref<'all' | 'incoming' | 'outgoing'>('all') -const logRole = ref<'all' | 'pilot' | 'atc'>('all') -const logTimeframe = ref<'24h' | '7d' | '30d' | 'all'>('24h') -const logChannelOptions = [ - { title: 'All channels', value: 'all' }, - { title: 'PTT', value: 'ptt' }, - { title: 'Say', value: 'say' }, - { title: 'Text', value: 'text' }, -] -const logDirectionOptions = [ - { title: 'All directions', value: 'all' }, - { title: 'Incoming', value: 'incoming' }, - { title: 'Outgoing', value: 'outgoing' }, -] -const logRoleOptions = [ - { title: 'All roles', value: 'all' }, - { title: 'Pilot', value: 'pilot' }, - { title: 'ATC', value: 'atc' }, -] -const logTimeframeOptions = [ - { title: 'Last 24 hours', value: '24h' }, - { title: '7 days', value: '7d' }, - { title: '30 days', value: '30d' }, - { title: 'All time', value: 'all' }, -] -const expandedLog = ref(null) +const sessions = ref([]) +const sessionsLoading = ref(false) +const sessionsError = ref('') +const sessionsLoaded = ref(false) +const sessionPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 10 }) +const selectedSessionId = ref(null) +const sessionDetails = ref(null) +const sessionDetailLoading = ref(false) +const sessionDetailError = ref('') +const expandedEntryId = ref(null) const showCreateInvite = ref(false) const newInviteLabel = ref('') @@ -1430,12 +1203,11 @@ const createInviteResult = ref<{ code: string; expiresAt?: string } | null>(null const usersLoaded = ref(false) const invitationsLoaded = ref(false) const waitlistLoaded = ref(false) -const logsLoaded = ref(false) let userSearchTimeout: ReturnType | undefined let invitationSearchTimeout: ReturnType | undefined -let logSearchTimeout: ReturnType | undefined let waitlistSearchTimeout: ReturnType | undefined +let activeSessionDetailRequest: string | null = null function extractErrorMessage(error: any, fallback: string) { return ( @@ -1507,69 +1279,6 @@ function describeTransition(transition: any) { return details.length ? `${destination} (${details.join(', ')})` : destination } -function buildLlmUsage(entry: TransmissionEntry): LlmUsageSummary | null { - const metadata = entry.metadata - if (!metadata) return null - - const hasDecisionData = - metadata.autoDecide !== undefined || - Boolean(metadata.llm) || - Boolean(metadata.decisionTrace?.calls?.length) || - Boolean(metadata.decisionTrace?.fallback?.used) - - if (!hasDecisionData) return null - - const autoDecide = metadata.llm?.autoDecide ?? metadata.autoDecide - const callCount = metadata.llm?.callCount ?? metadata.decisionTrace?.calls?.length ?? 0 - const fallbackUsed = metadata.llm?.fallbackUsed ?? Boolean(metadata.decisionTrace?.fallback?.used) - const openaiUsed = metadata.llm?.openaiUsed ?? callCount > 0 - - const strategy = - metadata.llm?.strategy || - (!autoDecide - ? 'manual' - : openaiUsed - ? 'openai' - : fallbackUsed - ? 'fallback' - : 'heuristic') - - let method: string - switch (strategy) { - case 'openai': - method = `OpenAI decision (${callCount} ${callCount === 1 ? 'call' : 'calls'})` - break - case 'fallback': - method = 'Fallback decision after OpenAI error' - break - case 'manual': - method = 'Manual routing (auto decision disabled)' - break - default: - method = 'Heuristic decision (no OpenAI call)' - break - } - - const reason = - metadata.llm?.reason || - (strategy === 'openai' - ? `Decision derived from OpenAI with ${callCount} ${callCount === 1 ? 'call' : 'calls'}.` - : strategy === 'fallback' - ? metadata.decisionTrace?.fallback?.reason || 'Fallback executed because OpenAI response could not be used.' - : strategy === 'manual' - ? 'Automatic decision was disabled for this transmission.' - : 'Rules and heuristics resolved the decision without contacting OpenAI.') - - return { - method, - openaiUsed, - callCount, - fallbackUsed, - autoDecide, - reason, - } -} - function isExpired(expiresAt?: string) { if (!expiresAt) return false const date = new Date(expiresAt) @@ -1583,14 +1292,6 @@ function invitationStatusLabel(inv: InvitationItem) { return 'active' } -function computeSince(timeframe: '24h' | '7d' | '30d' | 'all') { - const now = Date.now() - if (timeframe === '24h') return new Date(now - 24 * 60 * 60 * 1000) - if (timeframe === '7d') return new Date(now - 7 * 24 * 60 * 60 * 1000) - if (timeframe === '30d') return new Date(now - 30 * 24 * 60 * 60 * 1000) - return null -} - async function loadOverview(force = false) { if (overview.value && !force) return overviewLoading.value = true @@ -1727,48 +1428,107 @@ function changeWaitlistPage(page: number) { fetchWaitlist() } -function computeLogQuery() { - const query: Record = { - page: logPagination.page, - pageSize: logPagination.pageSize, - } - if (logSearch.value.trim()) query.search = logSearch.value.trim() - if (logChannel.value !== 'all') query.channel = logChannel.value - if (logDirection.value !== 'all') query.direction = logDirection.value - if (logRole.value !== 'all') query.role = logRole.value - const since = computeSince(logTimeframe.value) - if (since) query.since = since.toISOString() - return query -} - -async function fetchLogs(resetPage = false) { +async function fetchSessions(resetPage = false, options: { forceDetail?: boolean } = {}) { if (resetPage) { - logPagination.page = 1 + sessionPagination.page = 1 } - logLoading.value = true - logError.value = '' + + sessionsLoading.value = true + sessionsError.value = '' try { - const response = await api.get('/api/admin/logs/transmissions', { - query: computeLogQuery(), - }) - logs.value = response.items - Object.assign(logPagination, response.pagination) - logsLoaded.value = true + const query: Record = { + page: sessionPagination.page, + pageSize: sessionPagination.pageSize, + } + const response = await api.get('/api/admin/logs/sessions', { query }) + sessions.value = response.items + Object.assign(sessionPagination, response.pagination) + sessionsLoaded.value = true + + if (!response.items.length) { + sessionDetails.value = null + selectedSessionId.value = null + expandedEntryId.value = null + sessionDetailError.value = '' + sessionDetailLoading.value = false + activeSessionDetailRequest = null + return + } + + const hasSelected = selectedSessionId.value + ? response.items.some((item) => item.sessionId === selectedSessionId.value) + : false + + if (!hasSelected) { + await selectSession(response.items[0].sessionId, { force: true }) + } else if (options.forceDetail && selectedSessionId.value) { + await selectSession(selectedSessionId.value, { force: true }) + } } catch (error) { - logError.value = extractErrorMessage(error, 'Could not load radio logs.') + sessionsError.value = extractErrorMessage(error, 'Could not load sessions.') } finally { - logLoading.value = false + sessionsLoading.value = false } } -function changeLogPage(page: number) { - if (page < 1 || page > logPagination.pages) return - logPagination.page = page - fetchLogs() +function changeSessionPage(page: number) { + if (page < 1 || page > sessionPagination.pages || page === sessionPagination.page) return + sessionPagination.page = page + fetchSessions() } -function toggleLog(id: string) { - expandedLog.value = expandedLog.value === id ? null : id +async function fetchSessionDetail(sessionId: string) { + if (!sessionId) { + sessionDetails.value = null + return + } + + const requestId = sessionId + activeSessionDetailRequest = requestId + sessionDetailLoading.value = true + sessionDetailError.value = '' + + try { + const detail = await api.get(`/api/admin/logs/sessions/${sessionId}`) + if (activeSessionDetailRequest !== requestId) { + return + } + sessionDetails.value = detail + } catch (error) { + if (activeSessionDetailRequest !== requestId) { + return + } + sessionDetailError.value = extractErrorMessage(error, 'Could not load session details.') + sessionDetails.value = null + } finally { + if (activeSessionDetailRequest === requestId) { + sessionDetailLoading.value = false + activeSessionDetailRequest = null + } + } +} + +async function selectSession(sessionId: string | null, options: { force?: boolean } = {}) { + if (!sessionId) { + selectedSessionId.value = null + sessionDetails.value = null + sessionDetailError.value = '' + expandedEntryId.value = null + sessionDetailLoading.value = false + activeSessionDetailRequest = null + return + } + + if (!options.force && selectedSessionId.value === sessionId && sessionDetails.value) { + expandedEntryId.value = null + return + } + + selectedSessionId.value = sessionId + sessionDetails.value = null + sessionDetailError.value = '' + expandedEntryId.value = null + await fetchSessionDetail(sessionId) } async function submitCreateInvite() { @@ -1812,7 +1572,7 @@ async function refreshActiveTab() { } else if (activeTab.value === 'waitlist') { await fetchWaitlist(true) } else { - await fetchLogs(true) + await fetchSessions(true, { forceDetail: true }) } } finally { refreshing.value = false @@ -1824,11 +1584,6 @@ watch(invitationStatus, () => fetchInvitations(true)) watch(invitationChannel, () => fetchInvitations(true)) watch(waitlistSubscription, () => fetchWaitlist(true)) watch(waitlistStatus, () => fetchWaitlist(true)) -watch(logChannel, () => fetchLogs(true)) -watch(logDirection, () => fetchLogs(true)) -watch(logRole, () => fetchLogs(true)) -watch(logTimeframe, () => fetchLogs(true)) - watch(userSearch, () => { if (userSearchTimeout) clearTimeout(userSearchTimeout) userSearchTimeout = setTimeout(() => fetchUsers(true), 400) @@ -1844,11 +1599,6 @@ watch(waitlistSearch, () => { waitlistSearchTimeout = setTimeout(() => fetchWaitlist(true), 400) }) -watch(logSearch, () => { - if (logSearchTimeout) clearTimeout(logSearchTimeout) - logSearchTimeout = setTimeout(() => fetchLogs(true), 400) -}) - watch(activeTab, (tab) => { if (tab === 'overview') { loadOverview() @@ -1858,8 +1608,8 @@ watch(activeTab, (tab) => { fetchInvitations(true) } else if (tab === 'waitlist' && !waitlistLoaded.value) { fetchWaitlist(true) - } else if (tab === 'logs' && !logsLoaded.value) { - fetchLogs(true) + } else if (tab === 'logs' && !sessionsLoaded.value) { + fetchSessions(true) } }) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 48162ed..b777089 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -9,26 +9,19 @@

Alpha Build • Decision Tree • VATSIM

-
- +
+ {{ currentState?.id || 'INIT' }}
{{ currentState?.phase || 'Setup' }}
+
+ {{ activeFlowInfo.name }} + {{ activeFlowInfo.modeLabel }} +
+

+ {{ activeFlowInfo.description }} +

-
@@ -617,6 +610,21 @@ LLM
+
+ Session: {{ sessionLabel }} +
+ + Auto: {{ traceAutoSelection.id }} + + + Fallback candidates + + + Fallback: {{ traceFallback.reason || 'triggered' }} + +
+
+

Current node

{{ debugState?.id || '—' }}

@@ -668,6 +676,57 @@

No further decisions available.

+ +
+

Decision timeline

+
+
+
+
+

{{ step.label }}

+

{{ step.stage }}

+
+ + {{ step.candidates.length }} candidates + +
+

{{ step.note }}

+
+
+
+ {{ candidate.id }} + {{ candidate.flow || 'current' }} +
+

{{ candidate.summary }}

+
+
+
+

Eliminated

+
+
+ {{ elim.candidate.id }} + {{ elim.kind }} +
+

{{ elim.reason }}

+

{{ describeElimination(elim) }}

+
+
+
+
+

No decision timeline available yet.

+
@@ -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) diff --git a/server/api/admin/logs/sessions.get.ts b/server/api/admin/logs/sessions.get.ts new file mode 100644 index 0000000..adec230 --- /dev/null +++ b/server/api/admin/logs/sessions.get.ts @@ -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, + }, + } +}) diff --git a/server/api/admin/logs/sessions/[sessionId].get.ts b/server/api/admin/logs/sessions/[sessionId].get.ts new file mode 100644 index 0000000..4cd3f73 --- /dev/null +++ b/server/api/admin/logs/sessions/[sessionId].get.ts @@ -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), + } +}) diff --git a/server/api/admin/logs/transmissions.get.ts b/server/api/admin/logs/transmissions.get.ts index 822417e..64e6950 100644 --- a/server/api/admin/logs/transmissions.get.ts +++ b/server/api/admin/logs/transmissions.get.ts @@ -17,6 +17,7 @@ type TransmissionListItem = { createdAt: string metadata?: Record 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), diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 7ff4692..2d01093 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -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; diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index aa26450..afa5ca3 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -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, diff --git a/server/api/editor/flows.post.ts b/server/api/editor/flows.post.ts index 315431d..7260eca 100644 --- a/server/api/editor/flows.post.ts +++ b/server/api/editor/flows.post.ts @@ -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 }) diff --git a/server/api/editor/flows/[slug]/index.put.ts b/server/api/editor/flows/[slug]/index.put.ts index a9326c8..d202e62 100644 --- a/server/api/editor/flows/[slug]/index.put.ts +++ b/server/api/editor/flows/[slug]/index.put.ts @@ -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 }) diff --git a/server/api/llm/decide.post.ts b/server/api/llm/decide.post.ts index c004539..93e8dce 100644 --- a/server/api/llm/decide.post.ts +++ b/server/api/llm/decide.post.ts @@ -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' }) diff --git a/server/models/DecisionFlow.ts b/server/models/DecisionFlow.ts index 7e06b9c..a97cff6 100644 --- a/server/models/DecisionFlow.ts +++ b/server/models/DecisionFlow.ts @@ -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( 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( { diff --git a/server/models/TransmissionLog.ts b/server/models/TransmissionLog.ts index 7ea3e2d..922fe6c 100644 --- a/server/models/TransmissionLog.ts +++ b/server/models/TransmissionLog.ts @@ -10,6 +10,7 @@ export interface TransmissionLogDocument extends mongoose.Document { text: string normalized?: string metadata?: Record + sessionId?: string createdAt: Date } @@ -21,6 +22,7 @@ const transmissionSchema = new mongoose.Schema({ text: { type: String, required: true }, normalized: { type: String }, metadata: { type: Schema.Types.Mixed }, + sessionId: { type: String, index: true }, createdAt: { type: Date, default: () => new Date() }, }) diff --git a/server/services/decisionFlowService.ts b/server/services/decisionFlowService.ts index def3b66..530afb1 100644 --- a/server/services/decisionFlowService.ts +++ b/server/services/decisionFlowService.ts @@ -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 { 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 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, diff --git a/server/utils/openai.ts b/server/utils/openai.ts index a565063..a7ec3dd 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -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 { return r.choices?.[0]?.message?.content?.trim() || '' } -export interface LLMDecisionTraceCall { - stage: 'readback-check' | 'decision' - request: Record - 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 activeFlowSlug: string + flowEntryModes: Map + 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; flags: Record }, 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; flags: Record }, 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; flags: Record } -): 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() + const flowEntryModes = new Map() + 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() + + 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() - 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 ({ + 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 { @@ -799,24 +1072,6 @@ export async function routeDecision(input: LLMDecisionInput): Promise + entry_mode?: 'main' | DecisionFlowEntryMode } export interface RuntimeDecisionSystem { @@ -265,4 +272,6 @@ export interface DecisionFlowSummary { nodeCount: number updatedAt: string createdAt: string + entryMode?: DecisionFlowEntryMode + isMain?: boolean } diff --git a/shared/types/llm.ts b/shared/types/llm.ts index 95b6820..58de96b 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -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 @@ -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 + 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 + } +} diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 6f39598..252da90 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -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 { const rendered = renderTpl(text, context) return normalizeRadioPhrase(rendered) @@ -165,9 +171,12 @@ export default function useCommunicationsEngine() { const runtimeSystem = ref(null) const flowOrder = ref([]) const activeFlowSlug = ref('') + const sessionId = ref('') + const flowStack = ref([]) const tree = ref(null) const ready = ref(false) + const lastDecisionTrace = ref(null) const flowSnapshots = reactive>({}) @@ -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(() => { 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, diff --git a/shared/utils/openaiDecision.ts b/shared/utils/openaiDecision.ts index 2014c90..e36f857 100644 --- a/shared/utils/openaiDecision.ts +++ b/shared/utils/openaiDecision.ts @@ -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 { - return await $fetch('/api/llm/decide', { +export async function decideNextStateLLM(input: LLMDecisionInput): Promise { + return await $fetch('/api/llm/decide', { method: 'POST', body: input })