Enhance admin transmissions with detailed LLM and TTS context

This commit is contained in:
Remi
2025-09-20 12:42:08 +02:00
committed by itsrubberduck
parent 49fce390a0
commit 79dfc60be1
3 changed files with 462 additions and 4 deletions

View File

@@ -729,9 +729,9 @@
<v-expand-transition>
<div
v-if="expandedLog === entry.id"
class="space-y-3 rounded-2xl border border-cyan-400/30 bg-cyan-500/10 p-4 text-xs text-white/80"
class="space-y-4 rounded-2xl border border-cyan-400/30 bg-cyan-500/10 p-4 text-xs text-white/80"
>
<div v-if="entry.metadata?.decision" class="space-y-2">
<div v-if="entry.metadata?.decision" class="space-y-3">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">LLM Decision Summary</p>
<div class="grid gap-2 md:grid-cols-2">
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
@@ -752,6 +752,159 @@
</v-chip>
</div>
</div>
<template v-for="usage in [buildLlmUsage(entry)]" :key="`${entry.id}-usage`">
<div
v-if="usage"
class="space-y-2 rounded-xl border border-white/10 bg-black/30 p-3 text-[11px] text-white/70"
>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">LLM usage</p>
<p class="text-sm font-semibold text-white">{{ usage.method }}</p>
<div class="flex flex-wrap gap-2">
<v-chip size="x-small" color="cyan" variant="outlined">
Auto decision: {{ usage.autoDecide === false ? 'Disabled' : 'Enabled' }}
</v-chip>
<v-chip size="x-small" color="cyan" variant="outlined">
OpenAI: {{ usage.openaiUsed ? 'Used' : 'Not used' }}
</v-chip>
<v-chip v-if="usage.openaiUsed" size="x-small" color="cyan" variant="outlined">
Calls: {{ usage.callCount }}
</v-chip>
<v-chip v-if="usage.fallbackUsed" size="x-small" color="orange" variant="tonal">
Fallback triggered
</v-chip>
</div>
<p v-if="usage.reason" class="text-white/60">{{ usage.reason }}</p>
</div>
</template>
<div v-if="entry.metadata?.context" class="space-y-3">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">State context snapshot</p>
<div class="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<p class="font-mono text-sm text-white">
{{ entry.metadata.context.stateId || 'Unknown state' }}
</p>
<p v-if="entry.metadata.context.state?.name" class="text-[11px] text-white/60">
{{ entry.metadata.context.state?.name }}
</p>
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-white/60">
<v-chip v-if="entry.metadata.context.state?.role" size="x-small" color="cyan" variant="outlined">
Role: {{ entry.metadata.context.state?.role }}
</v-chip>
<v-chip v-if="entry.metadata.context.state?.phase" size="x-small" color="cyan" variant="text">
Phase: {{ entry.metadata.context.state?.phase }}
</v-chip>
</div>
</div>
<div
v-if="entry.metadata.context.state?.say_tpl"
class="rounded-lg border border-white/10 bg-black/40 p-3"
>
<p class="text-[11px] text-white/50">State Say Template</p>
<p class="font-mono text-sm">{{ entry.metadata.context.state?.say_tpl }}</p>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div>
<p class="text-[11px] text-white/50">Next transitions</p>
<ul
v-if="transitionList(entry.metadata.context.state?.next).length"
class="list-inside list-disc space-y-1 text-[11px] text-white/70"
>
<li
v-for="(transition, index) in transitionList(entry.metadata.context.state?.next)"
:key="`next-${index}`"
>
{{ describeTransition(transition) }}
</li>
</ul>
<p v-else class="text-[11px] text-white/50"></p>
</div>
<div>
<p class="text-[11px] text-white/50">OK transitions</p>
<ul
v-if="transitionList(entry.metadata.context.state?.ok_next).length"
class="list-inside list-disc space-y-1 text-[11px] text-white/70"
>
<li
v-for="(transition, index) in transitionList(entry.metadata.context.state?.ok_next)"
:key="`ok-${index}`"
>
{{ describeTransition(transition) }}
</li>
</ul>
<p v-else class="text-[11px] text-white/50"></p>
</div>
<div>
<p class="text-[11px] text-white/50">Bad transitions</p>
<ul
v-if="transitionList(entry.metadata.context.state?.bad_next).length"
class="list-inside list-disc space-y-1 text-[11px] text-white/70"
>
<li
v-for="(transition, index) in transitionList(entry.metadata.context.state?.bad_next)"
:key="`bad-${index}`"
>
{{ describeTransition(transition) }}
</li>
</ul>
<p v-else class="text-[11px] text-white/50"></p>
</div>
</div>
<div v-if="entry.metadata.context.candidates?.length" class="space-y-2">
<p class="text-[11px] text-white/50 uppercase tracking-[0.3em]">Candidates</p>
<div
v-for="(candidate, index) in entry.metadata.context.candidates"
:key="candidate.id || index"
class="space-y-2 rounded-lg border border-white/10 bg-black/40 p-3"
>
<div class="flex items-center justify-between">
<span class="font-mono text-sm text-white">{{ candidate.id || 'unknown' }}</span>
<v-chip
v-if="candidate.id && candidate.id === entry.metadata?.decision?.next_state"
size="x-small"
color="cyan"
variant="flat"
>
Selected
</v-chip>
</div>
<p v-if="candidate.state?.name" class="text-[11px] text-white/60">
{{ candidate.state?.name }}
</p>
<div class="flex flex-wrap gap-2 text-[11px] text-white/60">
<v-chip v-if="candidate.state?.role" size="x-small" color="cyan" variant="outlined">
Role: {{ candidate.state?.role }}
</v-chip>
<v-chip
v-if="candidate.state?.requires_atc_reply"
size="x-small"
color="cyan"
variant="tonal"
>
Requires ATC reply
</v-chip>
</div>
<p
v-if="candidate.state?.say_tpl"
class="rounded-lg border border-white/10 bg-black/60 p-2 font-mono text-[11px] text-white"
>
{{ candidate.state?.say_tpl }}
</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div v-if="entry.metadata.context.variables">
<p class="text-[11px] text-white/50">Variables snapshot</p>
<pre class="trace-json">{{ formatJson(entry.metadata.context.variables) }}</pre>
</div>
<div v-if="entry.metadata.context.flags">
<p class="text-[11px] text-white/50">Flags snapshot</p>
<pre class="trace-json">{{ formatJson(entry.metadata.context.flags) }}</pre>
</div>
</div>
</div>
</div>
<div v-if="entry.metadata?.decisionTrace?.calls?.length" class="space-y-3">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">OpenAI Decision Calls</p>
<div
@@ -803,6 +956,62 @@
</p>
</div>
</div>
<template v-else>
<template v-for="usage in [buildLlmUsage(entry)]" :key="`${entry.id}-usage-empty`">
<div
v-if="usage"
class="space-y-2 rounded-xl border border-white/10 bg-black/30 p-3 text-[11px] text-white/70"
>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">OpenAI Decision Calls</p>
<p>No OpenAI call was recorded for this transmission.</p>
<p v-if="usage.reason" class="text-white/60">{{ usage.reason }}</p>
</div>
</template>
</template>
<div v-if="entry.channel === 'say'" class="space-y-2">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">Say endpoint invocation</p>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Voice</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.voice || '—' }}</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Signal level</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.level ?? '—' }}</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Speech speed</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.speed ?? '—' }}</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Radio quality</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.radioQuality || '—' }}</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">TTS provider</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.tts?.provider || '—' }}</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Model</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.tts?.model || '—' }}</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Format</p>
<p class="font-mono text-sm text-white">
{{ entry.metadata?.tts?.format || '—' }}
<span v-if="entry.metadata?.tts?.extension">
({{ entry.metadata?.tts?.extension }})
</span>
</p>
</div>
</div>
<div v-if="entry.metadata?.tag" class="rounded-lg border border-white/10 bg-black/30 p-3">
<p class="text-[11px] text-white/50">Tag</p>
<p class="font-mono text-sm text-white">{{ entry.metadata?.tag }}</p>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">Metadata</p>
<pre class="trace-json">{{ formatJson(entry.metadata) }}</pre>
@@ -980,6 +1189,78 @@ interface TransmissionUser {
role: string
}
interface DecisionTraceCall {
stage: 'readback-check' | 'decision'
request: Record<string, any>
response?: any
rawResponseText?: string
error?: string
}
interface DecisionTraceMetadata {
calls?: DecisionTraceCall[]
fallback?: { used?: boolean; reason?: string; selected?: string }
}
interface CandidateSnapshot {
id?: string
state?: Record<string, any>
}
interface TransmissionContextSnapshot {
stateId?: string
state?: Record<string, any>
candidates?: CandidateSnapshot[]
selectedCandidate?: CandidateSnapshot
variables?: Record<string, any>
flags?: Record<string, any>
}
interface LlmUsageMetadata {
autoDecide?: boolean
openaiUsed?: boolean
callCount?: number
fallbackUsed?: boolean
strategy?: 'manual' | 'openai' | 'heuristic' | 'fallback'
reason?: string
}
interface TransmissionMetadata {
moduleId?: string
lessonId?: string
autoDecide?: boolean
decision?: {
next_state?: string
controller_say_tpl?: string
off_schema?: boolean
radio_check?: boolean
}
decisionTrace?: DecisionTraceMetadata
context?: TransmissionContextSnapshot
llm?: LlmUsageMetadata
tts?: {
provider?: string
model?: string
format?: string
extension?: string
}
voice?: string
level?: number
speed?: number
tag?: string | null
radioQuality?: string
[key: string]: any
}
interface LlmUsageSummary {
method: string
openaiUsed: boolean
callCount: number
fallbackUsed: boolean
autoDecide?: boolean
reason?: string
}
interface TransmissionEntry {
id: string
role: string
@@ -988,7 +1269,7 @@ interface TransmissionEntry {
text: string
normalized?: string
createdAt: string
metadata?: Record<string, any>
metadata?: TransmissionMetadata
user?: TransmissionUser
}
@@ -1203,6 +1484,92 @@ function formatJson(value?: any) {
}
}
function transitionList(value: any) {
return Array.isArray(value) ? value : []
}
function describeTransition(transition: any) {
if (!transition) return '—'
if (typeof transition === 'string') return transition
if (typeof transition !== 'object') return String(transition)
const destination = transition.to || transition.id || '—'
const details: string[] = []
if (transition.when) details.push(`when: ${transition.when}`)
if (transition.action) details.push(`action: ${transition.action}`)
if (transition.intent) details.push(`intent: ${transition.intent}`)
if (transition.auto) details.push(`auto: ${transition.auto}`)
if (transition.say_tpl || transition.controller_say_tpl) details.push('say')
if (transition.note) details.push(`note: ${transition.note}`)
if (transition.condition) details.push(`cond: ${transition.condition}`)
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)

View File

@@ -88,6 +88,19 @@ async function convertToWav(inputPath: string, outputPath: string) {
]);
}
function safeClone<T>(value: T): T | undefined {
if (value === undefined) {
return undefined;
}
try {
return JSON.parse(JSON.stringify(value));
} catch (err) {
console.warn("Failed to clone value for transmission metadata", err);
return undefined;
}
}
export default defineEventHandler(async (event) => {
const body = await readBody<PTTRequest>(event);
@@ -161,6 +174,64 @@ export default defineEventHandler(async (event) => {
try {
const user = await getUserFromEvent(event)
const llmCallCount = decisionResult?.trace?.calls?.length || 0;
const fallbackUsed = Boolean(decisionResult?.trace?.fallback?.used);
let llmStrategy: 'manual' | 'openai' | 'heuristic' | 'fallback' = 'manual';
if (shouldAutoDecide) {
if (llmCallCount > 0) {
llmStrategy = 'openai';
} else if (fallbackUsed) {
llmStrategy = 'fallback';
} else {
llmStrategy = 'heuristic';
}
}
const llmUsage = {
autoDecide: shouldAutoDecide,
openaiUsed: llmStrategy === 'openai',
callCount: llmCallCount,
fallbackUsed,
strategy: llmStrategy,
reason:
llmStrategy === 'manual'
? 'Automatic decision disabled in request.'
: llmStrategy === 'openai'
? `Decision derived from OpenAI with ${llmCallCount} call(s).`
: llmStrategy === 'fallback'
? (decisionResult?.trace?.fallback?.reason || 'Fallback triggered after OpenAI failure.')
: 'Decision resolved locally without calling OpenAI.'
};
const contextState = safeClone(body.context.state);
if (contextState && typeof contextState === 'object' && contextState !== null) {
const stateRecord = contextState as Record<string, any>;
if (!('id' in stateRecord)) {
stateRecord.id = body.context.state_id;
}
}
const contextCandidates = Array.isArray(body.context.candidates)
? body.context.candidates.map(candidate => {
const candidateState = safeClone(candidate.state);
if (candidateState && typeof candidateState === 'object' && candidateState !== null) {
const candidateRecord = candidateState as Record<string, any>;
if (!('id' in candidateRecord)) {
candidateRecord.id = candidate.id;
}
}
return {
id: candidate.id,
state: candidateState
};
})
: undefined;
const selectedCandidate = contextCandidates?.find(c => c.id === decision?.next_state);
await TransmissionLog.create({
user: user?._id,
role: "pilot",
@@ -173,6 +244,15 @@ export default defineEventHandler(async (event) => {
decision,
decisionTrace: decisionResult?.trace,
autoDecide: shouldAutoDecide,
llm: llmUsage,
context: {
stateId: body.context.state_id,
state: contextState,
candidates: contextCandidates,
selectedCandidate,
variables: safeClone(body.context.variables),
flags: safeClone(body.context.flags)
}
},
})
} catch (logError) {

View File

@@ -159,6 +159,7 @@ export default defineEventHandler(async (event) => {
let audioBuffer: Buffer;
let modelUsed: string;
let actualMime = mime;
let ttsProvider: 'openai' | 'speaches' | 'piper' = 'openai';
if (useSpeaches) {
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
@@ -171,12 +172,14 @@ export default defineEventHandler(async (event) => {
modelUsed = model;
// Server returns the correct format according to response_format
actualMime = fmtToMime(fmt);
ttsProvider = 'speaches';
} else if (usePiper) {
// Local Piper
audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort);
modelUsed = "piper-local";
// Piper returns WAV
actualMime = "audio/wav";
ttsProvider = 'piper';
} else {
// OpenAI (fallback)
const tts = await normalize.audio.speech.create({
@@ -189,6 +192,7 @@ export default defineEventHandler(async (event) => {
audioBuffer = Buffer.from(await tts.arrayBuffer());
modelUsed = TTS_MODEL;
actualMime = "audio/wav";
ttsProvider = 'openai';
}
// Optional persistence
@@ -208,7 +212,8 @@ export default defineEventHandler(async (event) => {
lessonId: body?.lessonId || null,
files: { audio: fileOut },
model: modelUsed,
format: actualMime
format: actualMime,
ttsProvider
};
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
@@ -229,6 +234,12 @@ export default defineEventHandler(async (event) => {
lessonId: body?.lessonId || null,
tag: body?.tag || null,
radioQuality: radioQuality.description,
tts: {
provider: ttsProvider,
model: modelUsed,
format: actualMime,
extension: ext
}
}
})
} catch (logError) {