/** * geminiLive.ts — Sprint 6d (revert WS brut). * * Le SDK `@google/genai` fermait la session sans setupComplete ni raison * exploitable. On revient au WebSocket brut (package `ws`) qui était utilisé * par `test-gemini-live.js` et permet de loguer précisément ce que Gemini * répond. Config setup réduite au strict minimum tant que `setupComplete` * n'est pas confirmé en prod ; on réintègre champs un par un ensuite. * * Interface publique (consommée par `routes/t2live.ts`) — INCHANGÉE : * - openGeminiLiveSession(clientWs, opts) * - WebSocketLike, OpenGeminiLiveSessionOptions * - buildT2SystemPrompt({role, contexte}) * - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS */ import { WebSocket as NodeWebSocket } from "ws"; export const GEMINI_LIVE_URL = "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"; /** * Modèle Live cible. `gemini-2.0-flash-live-001` est le modèle Live confirmé * par la doc Google pour les clés API Developer + Express. Format `models/...` * dans le setup frame natif (cf. `test-gemini-live.js`). */ export const GEMINI_LIVE_MODEL = "gemini-3.1-flash-live-preview"; /** Timeout total session WS T2 Live : 3 min 30 (durée TCF) + marge évaluation. */ export const T2_SESSION_TIMEOUT_MS = 210_000; /** Warning au client : 30 s avant le timeout. */ export const T2_SESSION_WARNING_MS = 180_000; /** * Construit le prompt système T2 Live à partir du sujet (role + contexte). * Cf. docs/Prompt_t2live.md §3. Conservé en signature pour usage futur quand * `systemInstruction` sera réintégré dans le setup frame. */ export function buildT2SystemPrompt(input: { role: string; contexte: string; }): string { const { role, contexte } = input; return `Tu joues le rôle de ${role} dans la situation suivante : ${contexte} Règles à respecter impérativement : - Tu réponds uniquement en français, quelle que soit la langue de ton interlocuteur. - Tu joues ton rôle de façon naturelle et réaliste. Tu n'es pas un examinateur — tu es ${role}. - Tu réponds aux questions qu'on te pose de façon honnête et naturelle, comme le ferait une vraie personne dans cette situation. - Tu ne facilites pas la tâche : tu ne reformules pas les questions, tu n'anticipes pas ce que l'interlocuteur veut savoir, tu ne lui suggères pas quoi demander. - Si ton interlocuteur marque une longue pause ou semble avoir terminé, tu peux dire : "Avez-vous d'autres questions ?" — c'est la seule relance autorisée. - Tu ne fais aucun commentaire sur la langue, les erreurs ou le niveau de français de ton interlocuteur. - Tu ne sors jamais de ton rôle. - Tu ne prends PAS la parole en premier. Tu attends que ton interlocuteur s'adresse à toi, puis tu réponds naturellement dans ton rôle. - Tes réponses sont concises et naturelles : ni monosyllabiques, ni des monologues.`; } /** * Subset minimal d'une WebSocket — compatible avec : * - le wrapper exposé par @hono/node-ws (côté client navigateur) * - la WebSocket de `ws` (côté Gemini) * - les fakes basés sur EventEmitter dans les tests */ export interface WebSocketLike { send(data: unknown): void; close(code?: number, reason?: string): void; on(event: "message", listener: (data: unknown) => void): void; on(event: "close", listener: (code?: number, reason?: unknown) => void): void; on(event: "error", listener: (err: unknown) => void): void; on(event: "open", listener: () => void): void; } export interface OpenGeminiLiveSessionOptions { /** Rôle joué par l'IA, injecté dans le prompt système. */ role: string; /** Contexte de la situation, injecté dans le prompt système. */ contexte: string; /** Callback déclenché en fin de session avec le transcript reconstruit. */ onSessionEnd?: (transcript: string) => void | Promise; /** Override timeout (par défaut T2_SESSION_TIMEOUT_MS). */ timeoutMs?: number; /** Override warning (par défaut T2_SESSION_WARNING_MS). */ warningMs?: number; /** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */ apiKey?: string; /** * Injection pour les tests — fabrique de WebSocket vers Gemini. */ clientFactory?: (url: string) => WebSocketLike; } /** * Forme minimale d'un message Gemini Live JSON entrant. */ interface GeminiServerMessage { setupComplete?: unknown; serverContent?: { modelTurn?: { parts?: Array<{ inlineData?: { data?: string; mimeType?: string }; }>; }; inputTranscription?: { text?: string }; outputTranscription?: { text?: string }; interrupted?: boolean; turnComplete?: boolean; }; } interface TranscriptEntry { speaker: "candidat" | "examinateur"; text: string; } function reconstructTranscript(entries: TranscriptEntry[]): string { return entries .map((e) => e.speaker === "candidat" ? `Candidat : ${e.text}` : `Examinateur : ${e.text}`, ) .join("\n"); } /** * Détecte un signal de fin de session envoyé par le client : `{type:'end'}`. */ function isEndSignal(data: unknown): boolean { let text: string; if (typeof data === "string") { text = data; } else if (data instanceof Buffer) { try { text = data.toString("utf8"); } catch { return false; } } else { return false; } if (!text.startsWith("{")) return false; try { const parsed = JSON.parse(text) as { type?: string }; return parsed.type === "end"; } catch { return false; } } /** * Parse un message client `{type:'audio', data: base64}` et renvoie le base64 * si le format est valide, sinon null. */ function parseAudioChunk(data: unknown): string | null { let text: string; if (typeof data === "string") { text = data; } else if (data instanceof Buffer) { try { text = data.toString("utf8"); } catch { return null; } } else { return null; } if (!text.startsWith("{")) return null; try { const parsed = JSON.parse(text) as { type?: string; data?: unknown }; if (parsed.type === "audio" && typeof parsed.data === "string") { return parsed.data; } return null; } catch { return null; } } /** * Tente de parser un message Gemini en JSON. Retourne null si binaire / non-JSON. */ function tryParseGeminiJson(data: unknown): GeminiServerMessage | null { let text: string; if (typeof data === "string") { text = data; } else if (data instanceof Buffer) { try { text = data.toString("utf8"); if (!text.startsWith("{")) return null; } catch { return null; } } else if (typeof data === "object" && data !== null && "toString" in data) { try { text = (data as { toString: () => string }).toString(); if (!text.startsWith("{")) return null; } catch { return null; } } else { return null; } try { return JSON.parse(text) as GeminiServerMessage; } catch { return null; } } /** * Construit le setup frame minimal Gemini Live (équivalent du mode * `minimal` de `test-gemini-live.js`). Les champs `systemInstruction`, * `inputAudioTranscription`, `outputAudioTranscription`, * `realtimeInputConfig.automaticActivityDetection` sont volontairement * retirés tant que `setupComplete` n'est pas confirmé en prod. */ function buildSetupFrame(systemPrompt: string): string { return JSON.stringify({ setup: { model: `models/${GEMINI_LIVE_MODEL}`, generationConfig: { responseModalities: ["AUDIO"], }, systemInstruction: { parts: [{ text: systemPrompt }], }, }, }); } /** * Ouvre une session Gemini Live via WebSocket brut (`ws://...?key=...`) et * proxifie les messages dans les deux sens entre le client (navigateur) et * Gemini. * * - URL : GEMINI_LIVE_URL?key=apiKey * - À l'open Gemini : envoi du setup frame minimal. * - Forward client → Gemini : parse `{type:'audio', data: base64}` → * message JSON `{ realtimeInput: { audio: { data, mimeType } } }`. * - Forward Gemini → client : forward verbatim (string ou Buffer). * - Accumule input/outputTranscription pour la correction finale. * - Détecte `{type:'end'}` du client → fin de session. * - Timer 210 s : warning à 180 s, fin auto à 210 s. * - En fin : `onSessionEnd(transcript)` puis ferme Gemini. Le client WS * n'est PAS fermé ici — c'est l'appelant qui décide. * - Erreur Gemini / close prématurée → close client 4006 GEMINI_DISCONNECTED. * - GEMINI_API_KEY absente → close client 4005 GEMINI_CONFIG. */ export function openGeminiLiveSession( clientWs: WebSocketLike, opts: OpenGeminiLiveSessionOptions, ): void { const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY; if (!apiKey) { clientWs.close(4005, "GEMINI_CONFIG"); return; } const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS; const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS; const systemPrompt = buildT2SystemPrompt({ role: opts.role, contexte: opts.contexte, }); const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; const factory = opts.clientFactory ?? ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***"); console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL); const geminiWs = factory(url); const transcriptEntries: TranscriptEntry[] = []; let sessionEnded = false; let warningTimer: ReturnType | null = null; let timeoutTimer: ReturnType | null = null; const clearTimers = () => { if (warningTimer !== null) { clearTimeout(warningTimer); warningTimer = null; } if (timeoutTimer !== null) { clearTimeout(timeoutTimer); timeoutTimer = null; } }; const endSession = async () => { if (sessionEnded) return; sessionEnded = true; clearTimers(); try { geminiWs.close(1000); } catch { /* ignore */ } if (opts.onSessionEnd) { try { await opts.onSessionEnd(reconstructTranscript(transcriptEntries)); } catch (err) { console.error( "[T2] onSessionEnd threw:", err instanceof Error ? err.message : String(err), ); } } }; geminiWs.on("open", () => { console.log("[T2] Gemini WS open"); const frame = buildSetupFrame(systemPrompt); console.log("[T2] Gemini setup frame:", frame); try { geminiWs.send(frame); } catch (err) { console.error( "[T2] Gemini setup frame send failed:", err instanceof Error ? err.message : String(err), ); try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } return; } // Timers démarrés à l'ouverture de la WS (avant setupComplete éventuel). warningTimer = setTimeout(() => { if (sessionEnded) return; try { clientWs.send( JSON.stringify({ type: "warning", message: "30 secondes restantes", }), ); } catch { /* ignore */ } }, warningMs); timeoutTimer = setTimeout(() => { void endSession(); }, timeoutMs); }); geminiWs.on("message", (data) => { const preview = typeof data === "string" ? data.slice(0, 300) : data instanceof Buffer ? data.toString("utf8").slice(0, 300) : "[binary]"; console.log("[T2] Gemini WS message:", preview); // Accumuler input/outputTranscription. const parsed = tryParseGeminiJson(data); if (parsed) { const sc = parsed.serverContent; if ( sc?.inputTranscription?.text && sc.inputTranscription.text.length > 0 ) { transcriptEntries.push({ speaker: "candidat", text: sc.inputTranscription.text, }); } if ( sc?.outputTranscription?.text && sc.outputTranscription.text.length > 0 ) { transcriptEntries.push({ speaker: "examinateur", text: sc.outputTranscription.text, }); } } // Forward verbatim au client (string ou Buffer audio inlineData). try { clientWs.send(data); } catch { void endSession(); } }); geminiWs.on("close", (code, reason) => { const reasonStr = reason instanceof Buffer ? reason.toString("utf8") : typeof reason === "string" ? reason : ""; console.log( "[T2] Gemini WS close:", JSON.stringify({ code, reason: reasonStr }), ); if (!sessionEnded) { clearTimers(); sessionEnded = true; try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } } }); geminiWs.on("error", (err) => { console.log( "[T2] Gemini WS error:", JSON.stringify(err instanceof Error ? { message: err.message } : err), ); if (!sessionEnded) { clearTimers(); sessionEnded = true; try { clientWs.close(4006, "GEMINI_DISCONNECTED"); } catch { /* ignore */ } } }); // ── Forward client → Gemini ────────────────────────────────────────── clientWs.on("message", (data) => { if (isEndSignal(data)) { void endSession(); return; } const audioBase64 = parseAudioChunk(data); if (audioBase64 !== null && !sessionEnded) { try { geminiWs.send( JSON.stringify({ realtimeInput: { audio: { data: audioBase64, mimeType: "audio/pcm;rate=16000", }, }, }), ); } catch (err) { console.log( "[T2] Gemini WS send (audio) failed:", err instanceof Error ? err.message : String(err), ); void endSession(); } } // Tout autre message client est ignoré. }); clientWs.on("close", () => { clearTimers(); sessionEnded = true; try { geminiWs.close(1000); } catch { /* ignore */ } }); clientWs.on("error", () => { clearTimers(); sessionEnded = true; try { geminiWs.close(1011); } catch { /* ignore */ } }); }