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)
372 lines
11 KiB
TypeScript
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 */
|
|
}
|
|
});
|
|
}
|