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:
parent
28f8373f5d
commit
d89b0b1e89
8 changed files with 1218 additions and 254 deletions
|
|
@ -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'))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue