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)
This commit is contained in:
Hermann_Kitio 2026-04-26 19:50:48 +03:00
parent 28f8373f5d
commit d89b0b1e89
8 changed files with 1218 additions and 254 deletions

View file

@ -1,32 +1,37 @@
import { WebSocket as NodeWebSocket } from 'ws'
export const T2_SYSTEM_PROMPT = `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif).
RÔLE : Tu incarnes agent immobilier.
CONTEXTE : Le candidat cherche un appartement à louer.
RÈGLES ABSOLUES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1.
2. Tu NE corriges JAMAIS les erreurs du candidat.
3. Tu attends que le candidat finisse sa question avant de répondre.
4. Tes réponses sont courtes (15 à 25 mots maximum).
5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises.
6. Si le candidat est vague, réponds de façon évasive pour le pousser à reformuler.
7. Si le candidat reste silencieux, attends. Ne pose JAMAIS de question spontanée après tes réponses. C'est au candidat d'agir.
8. En dernier recours uniquement (silence prolongé) : "Vous avez d'autres questions ?"
9. Ne prends jamais d'initiatives : réponds uniquement aux questions posées.
10. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste.
11. JAMAIS de listes ni de structure numérotée dans tes réponses.
12. Ne mentionne jamais que tu es une IA.
Commence l'exercice en te présentant brièvement dans ton rôle (1 phrase courte),
puis attends que le candidat prenne l'initiative.`
import { WebSocket as NodeWebSocket } from "ws";
export const GEMINI_LIVE_URL =
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
export const GEMINI_LIVE_MODEL = 'models/gemini-2.5-flash-native-audio-latest'
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 :
@ -35,120 +40,333 @@ export const GEMINI_LIVE_MODEL = 'models/gemini-2.5-flash-native-audio-latest'
* - 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
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
geminiFactory?: (url: string) => WebSocketLike;
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
apiKey?: string
apiKey?: string;
}
function buildSetupFrame(): string {
function buildSetupFrame(systemPrompt: string): string {
return JSON.stringify({
setup: {
model: GEMINI_LIVE_MODEL,
systemInstruction: {
parts: [{ text: T2_SYSTEM_PROMPT }],
parts: [{ text: systemPrompt }],
},
generationConfig: {
responseModalities: ['AUDIO'],
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 (modèle + system_instruction).
* - À l'open Gemini : envoie le setup frame avec prompt dynamique + VAD
* + inputAudioTranscription + outputAudioTranscription.
* - Forward transparent des frames audio dans les deux directions.
* - Fermeture coordonnée : close d'un côté → close de l'autre.
* - Erreur Gemini close client avec code 1011.
* - Si GEMINI_API_KEY est absente : close client immédiat avec 1011.
* - 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 = {}
opts: OpenGeminiLiveSessionOptions,
): void {
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
if (!apiKey) {
clientWs.close(1011, 'CONFIG_ERROR')
return
clientWs.close(4005, "GEMINI_CONFIG");
return;
}
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`
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)
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
const geminiWs = factory(url)
const geminiWs = factory(url);
let closed = false
const closeBoth = (code = 1000, reason = '') => {
if (closed) return
closed = true
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 {
clientWs.close(code, reason)
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.close(code, reason)
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('open', () => {
console.log('[T2] Gemini WS opened')
try {
geminiWs.send(buildSetupFrame())
console.log('[T2] Setup frame sent')
} catch {
closeBoth(1011, 'SETUP_FAILED')
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 */
}
}
})
});
geminiWs.on('message', (data) => {
console.log(
'[T2] Gemini message received, type:',
typeof data,
'content:',
(data as { toString?: () => string })?.toString?.().slice(0, 500)
)
clientWs.on("error", () => {
clearTimers();
sessionEnded = true;
try {
clientWs.send(data)
geminiWs.close(1011);
} catch {
closeBoth(1011, 'CLIENT_SEND_FAILED')
/* ignore */
}
})
clientWs.on('message', (data) => {
try {
geminiWs.send(data)
} catch {
closeBoth(1011, 'GEMINI_SEND_FAILED')
}
})
geminiWs.on('close', (code, reason) => {
console.log('[T2] Gemini closed, code:', code, 'reason:', reason)
closeBoth(1000)
})
clientWs.on('close', () => closeBoth(1000))
geminiWs.on('error', (err) => {
console.log('[T2] Gemini error:', (err as Error)?.message)
closeBoth(1011, 'GEMINI_ERROR')
})
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
});
}