expria-backend/src/lib/geminiLive.ts
Hermann_Kitio d89b0b1e89 Sprint 6a — Backend T2 Live (WS proxy + correction + persistance)
feat(geminiLive): dynamic prompt builder, transcript accumulation,
  VAD config (END_SENSITIVITY_LOW, 2s silence), 210s timeout + 180s warning
feat(t2live): sujet fetch + validation, correction pipeline (deepseekCorrectEO
  + PHONOLOGY_STUB TD-08), production insert + report delivery via WS
feat(deepseek): TacheEO extended with EO_T2, VALID_TACHES_EO updated
test: 11 geminiLive tests (rewritten + 4 new), 10 t2live integration tests
  292/292 backend tests green (+15)
2026-04-26 19:53:37 +03:00

372 lines
11 KiB
TypeScript

import { WebSocket as NodeWebSocket } from "ws";
export const GEMINI_LIVE_URL =
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
export const GEMINI_LIVE_MODEL = "models/gemini-2.5-flash-native-audio-latest";
/** 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.
*/
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<void>;
/** Override timeout (par défaut T2_SESSION_TIMEOUT_MS). */
timeoutMs?: number;
/** Override warning (par défaut T2_SESSION_WARNING_MS). */
warningMs?: number;
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
geminiFactory?: (url: string) => WebSocketLike;
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
apiKey?: string;
}
function buildSetupFrame(systemPrompt: string): string {
return JSON.stringify({
setup: {
model: GEMINI_LIVE_MODEL,
systemInstruction: {
parts: [{ text: systemPrompt }],
},
generationConfig: {
responseModalities: ["AUDIO"],
},
inputAudioTranscription: {},
outputAudioTranscription: {},
realtimeInputConfig: {
automaticActivityDetection: {
disabled: false,
startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
silenceDurationMs: 2000,
},
},
},
});
}
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");
}
/**
* Tente de parser un message Gemini en JSON pour en extraire les transcripts.
* Retourne null si non-JSON (chunks audio binaires).
*/
function tryParseGeminiMessage(data: unknown): {
inputText?: string;
outputText?: string;
} | null {
let text: string;
if (typeof data === "string") {
text = data;
} else if (data instanceof Buffer) {
// Heuristique : tenter de parser comme JSON UTF-8 ; si ça échoue, c'est binaire.
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 {
const parsed = JSON.parse(text) as {
serverContent?: {
inputTranscription?: { text?: string };
outputTranscription?: { text?: string };
};
};
const sc = parsed.serverContent;
if (!sc) return {};
return {
inputText: sc.inputTranscription?.text,
outputText: sc.outputTranscription?.text,
};
} catch {
return null;
}
}
/**
* 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;
}
}
/**
* Ouvre une session Gemini Live et proxifie les messages
* dans les deux sens entre le client (navigateur) et Gemini.
*
* - À l'open Gemini : envoie le setup frame avec prompt dynamique + VAD
* + inputAudioTranscription + outputAudioTranscription.
* - Forward transparent des frames audio dans les deux directions.
* - Accumule les transcripts (input = candidat, output = examinateur IA).
* - Détecte signal client `{type:'end'}` → déclenche fin de session.
* - Timeout 210 s : warning client à 180 s, fin auto à 210 s.
* - En fin de session : appelle `onSessionEnd(transcript)` puis ferme Gemini.
* Le client WS n'est PAS fermé ici — c'est l'appelant qui décide (envoi du
* rapport puis close 1000).
* - Erreur Gemini → 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.geminiFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
const geminiWs = factory(url);
const transcriptEntries: TranscriptEntry[] = [];
let sessionEnded = false;
let warningTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | 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 opened");
try {
geminiWs.send(buildSetupFrame(systemPrompt));
console.log("[T2] Setup frame sent");
// Démarrer les timers une fois la session Gemini effectivement ouverte.
warningTimer = setTimeout(() => {
if (sessionEnded) return;
try {
clientWs.send(
JSON.stringify({
type: "warning",
message: "30 secondes restantes",
}),
);
} catch {
/* ignore */
}
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
} catch {
try {
clientWs.close(4005, "GEMINI_CONFIG");
} catch {
/* ignore */
}
}
});
geminiWs.on("message", (data) => {
// Tentative d'extraction des transcripts — si JSON, on accumule ;
// dans tous les cas (JSON ou audio binaire), on forward au client.
const parsed = tryParseGeminiMessage(data);
if (parsed) {
if (parsed.inputText && parsed.inputText.length > 0) {
transcriptEntries.push({
speaker: "candidat",
text: parsed.inputText,
});
}
if (parsed.outputText && parsed.outputText.length > 0) {
transcriptEntries.push({
speaker: "examinateur",
text: parsed.outputText,
});
}
}
try {
clientWs.send(data);
} catch {
void endSession();
}
});
clientWs.on("message", (data) => {
if (isEndSignal(data)) {
void endSession();
return;
}
try {
geminiWs.send(data);
} catch {
void endSession();
}
});
geminiWs.on("close", () => {
console.log("[T2] Gemini closed");
if (!sessionEnded) {
clearTimers();
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
clientWs.on("close", () => {
clearTimers();
sessionEnded = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
});
geminiWs.on("error", (err) => {
console.log("[T2] Gemini error:", (err as Error)?.message);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
clientWs.on("error", () => {
clearTimers();
sessionEnded = true;
try {
geminiWs.close(1011);
} catch {
/* ignore */
}
});
}