diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index ed6c58f..d7f1a88 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -88,6 +88,19 @@ async function convertToWav(inputPath: string, outputPath: string) { ]); } +function safeClone(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(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; + 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; + 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) { diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 6c8a3d6..aa26450 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -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) {