Cleanup old unused code and add id sessionId to /api/atc/ptt

This commit is contained in:
leubeem
2026-05-20 14:13:26 +02:00
parent 19162e2d77
commit ea4b1e0b24
4 changed files with 29 additions and 1273 deletions

View File

@@ -2126,17 +2126,10 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
if (isIntercom) { if (isIntercom) {
const result = await api.post('/api/atc/ptt', { const result = await api.post('/api/atc/ptt', {
audio: base64Audio, audio: base64Audio,
context: {
state_id: currentState.value?.id || 'INTERCOM',
state: {},
candidates: [],
variables: { callsign: vars.value.callsign },
flags: {}
},
moduleId: 'pilot-monitoring-intercom', moduleId: 'pilot-monitoring-intercom',
lessonId: 'intercom', lessonId: 'intercom',
format: 'webm', format: 'webm',
autoDecide: false sessionId: backendSessionId.value || undefined,
}) })
if (result.success) { if (result.success) {
@@ -2152,15 +2145,12 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
} }
} }
} else { } else {
const ctx = buildLLMContext('')
const result = await api.post('/api/atc/ptt', { const result = await api.post('/api/atc/ptt', {
audio: base64Audio, audio: base64Audio,
context: ctx,
moduleId: 'pilot-monitoring', moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general', lessonId: currentState.value?.id || 'general',
format: 'webm', format: 'webm',
autoDecide: false sessionId: backendSessionId.value || undefined,
}) })
if (result.success) { if (result.success) {

View File

@@ -5,8 +5,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { getOpenAIClient, routeDecision } from "../../utils/openai"; import { getOpenAIClient } from "../../utils/openai";
import type { LLMDecisionResult } from "~~/shared/types/llm";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { TransmissionLog } from "../../models/TransmissionLog"; import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth"; import { getUserFromEvent } from "../../utils/auth";
@@ -15,26 +14,20 @@ type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
interface PTTRequest { interface PTTRequest {
audio: string; // Base64 encoded audio audio: string; // Base64 encoded audio
context: {
state_id: string;
state: any;
candidates: Array<{ id: string; state: any; flow?: string }>;
variables: Record<string, any>;
flags: Record<string, any>;
flow_slug?: string;
};
moduleId: string; moduleId: string;
lessonId: string; lessonId: string;
format?: AudioFormat; format?: AudioFormat;
autoDecide?: boolean; sessionId?: string; // Python backend session ID — used for TransmissionLog correlation
context?: { // Legacy field; kept for backwards compat but not used for routing
state_id?: string;
flags?: Record<string, any>;
[key: string]: any;
};
} }
interface PTTResponse { interface PTTResponse {
success: boolean; success: boolean;
transcription: string; transcription: string;
decision?: LLMDecisionResult['decision'];
trace?: LLMDecisionResult['trace'];
active_nodes?: LLMDecisionResult['active_nodes'];
} }
async function sh(cmd: string, args: string[]) { async function sh(cmd: string, args: string[]) {
@@ -46,14 +39,11 @@ async function sh(cmd: string, args: string[]) {
} }
const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 seconds 16kHz mono
const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm']; const AUDIO_FORMAT_SET = new Set<AudioFormat>(['wav', 'mp3', 'ogg', 'webm']);
const AUDIO_FORMAT_SET = new Set<AudioFormat>(ALLOWED_AUDIO_FORMATS);
function resolveAudioFormat(format?: string | null): AudioFormat { function resolveAudioFormat(format?: string | null): AudioFormat {
if (!format) { if (!format) return 'wav';
return 'wav';
}
const normalized = format.trim().toLowerCase() as AudioFormat; const normalized = format.trim().toLowerCase() as AudioFormat;
return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav'; return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav';
} }
@@ -76,37 +66,23 @@ function decodeAudioPayload(encoded: string): Buffer {
return buffer; return buffer;
} }
// Convert audio to WAV for better Whisper compatibility
async function convertToWav(inputPath: string, outputPath: string) { async function convertToWav(inputPath: string, outputPath: string) {
await sh("ffmpeg", [ await sh("ffmpeg", [
"-y", "-i", inputPath, "-y", "-i", inputPath,
"-ar", "16000", // 16 kHz for Whisper "-ar", "16000",
"-ac", "1", // Mono "-ac", "1",
"-f", "wav", "-f", "wav",
outputPath outputPath
]); ]);
} }
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) => { export default defineEventHandler(async (event) => {
const body = await readBody<PTTRequest>(event); const body = await readBody<PTTRequest>(event);
if (!body.audio || !body.context || !body.moduleId || !body.lessonId) { if (!body.audio || !body.moduleId || !body.lessonId) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: "audio, context, moduleId, and lessonId are required" statusMessage: "audio, moduleId, and lessonId are required"
}); });
} }
@@ -116,11 +92,9 @@ export default defineEventHandler(async (event) => {
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`); const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
try { try {
// 1. Decode audio from base64 and save
const audioBuffer = decodeAudioPayload(body.audio); const audioBuffer = decodeAudioPayload(body.audio);
await writeFile(tmpAudioInput, audioBuffer); await writeFile(tmpAudioInput, audioBuffer);
// 2. Convert to WAV if needed (only when FFmpeg is available)
let audioFileForWhisper = tmpAudioInput; let audioFileForWhisper = tmpAudioInput;
if (format !== 'wav') { if (format !== 'wav') {
try { try {
@@ -131,7 +105,6 @@ export default defineEventHandler(async (event) => {
} }
} }
// 3. OpenAI Whisper for transcription
const openai = getOpenAIClient(); const openai = getOpenAIClient();
const transcription = await openai.audio.transcriptions.create({ const transcription = await openai.audio.transcriptions.create({
file: createReadStream(audioFileForWhisper), file: createReadStream(audioFileForWhisper),
@@ -143,98 +116,20 @@ export default defineEventHandler(async (event) => {
const transcribedText = transcription.text.trim(); const transcribedText = transcription.text.trim();
if (!transcribedText) { if (!transcribedText) {
throw createError({ throw createError({ statusCode: 400, statusMessage: "No speech detected in audio" });
statusCode: 400,
statusMessage: "No speech detected in audio"
});
} }
const shouldAutoDecide = body.autoDecide !== false;
let decisionResult: LLMDecisionResult | null = null;
let decision: PTTResponse['decision'];
if (shouldAutoDecide) {
// 4. Call the LLM decision directly with the transcribed text
const decisionInput = {
...body.context,
pilot_utterance: transcribedText
};
decisionResult = await routeDecision(decisionInput);
decision = decisionResult.decision;
}
// 5. Cleanup
await rm(tmpAudioInput).catch(() => {}); await rm(tmpAudioInput).catch(() => {});
if (audioFileForWhisper !== tmpAudioInput) { if (audioFileForWhisper !== tmpAudioInput) {
await rm(tmpAudioWav).catch(() => {}); await rm(tmpAudioWav).catch(() => {});
} }
try { try {
const user = await getUserFromEvent(event) const user = await getUserFromEvent(event);
// Prefer the explicit top-level sessionId (Python backend session).
const llmCallCount = decisionResult?.trace?.calls?.length || 0; // Fall back to the legacy context.flags.session_id for older clients.
const fallbackUsed = Boolean(decisionResult?.trace?.fallback?.used); const sessionId = body.sessionId
?? (typeof body.context?.flags?.session_id === 'string' ? body.context.flags.session_id : undefined);
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,
flow: candidate.flow || undefined,
state: candidateState
};
})
: undefined;
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({ await TransmissionLog.create({
user: user?._id, user: user?._id,
@@ -246,49 +141,19 @@ export default defineEventHandler(async (event) => {
metadata: { metadata: {
moduleId: body.moduleId, moduleId: body.moduleId,
lessonId: body.lessonId, lessonId: body.lessonId,
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) { } catch (logError) {
console.warn("Transmission logging failed", logError) console.warn("Transmission logging failed", logError);
} }
const result: PTTResponse = { return { success: true, transcription: transcribedText } satisfies PTTResponse;
success: true,
transcription: transcribedText
};
if (decision) {
result.decision = decision;
}
if (decisionResult?.trace) {
result.trace = decisionResult.trace;
}
if (decisionResult?.active_nodes?.length) {
result.active_nodes = decisionResult.active_nodes;
}
return result;
} catch (error: any) { } catch (error: any) {
// Cleanup on error
await rm(tmpAudioInput).catch(() => {}); await rm(tmpAudioInput).catch(() => {});
await rm(tmpAudioWav).catch(() => {}); await rm(tmpAudioWav).catch(() => {});
if (error.statusCode) { if (error.statusCode) throw error;
throw error;
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View File

@@ -18,9 +18,6 @@ export default defineEventHandler(async (event) => {
if (url.pathname.startsWith('/api/copilot/')) { if (url.pathname.startsWith('/api/copilot/')) {
return return
} }
if (url.pathname === '/api/decision-flows/runtime') {
return
}
if (event.node.req.method === 'OPTIONS') { if (event.node.req.method === 'OPTIONS') {
return return
} }

File diff suppressed because it is too large Load Diff