Add session timeline logging and admin sessions view

This commit is contained in:
Remi
2025-09-21 23:08:10 +02:00
parent 6c9f467b94
commit fbec5c4830
8 changed files with 733 additions and 222 deletions

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,