mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-12 12:25:39 +08:00
Cleanup old unused code and add id sessionId to /api/atc/ptt
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user