expria-backend/src/lib/geminiLiveT1.ts
Hermann_Kitio 74770b6402
Some checks are pending
CI / quality (push) Waiting to run
fix(t1-live): remove questionnaire dependency from T1 Live session
- buildT1SystemPrompt() now static (no reponses param); examiner
  formulates questions from what it hears in real-time audio stream
- Remove context guard + close 4004 CONTEXT_MISSING; Gemini session
  opens immediately after auth (aligns with T2 flow)
- Remove parseT1Context, validateReponses import from route
- Unknown WS message types silently ignored (debug log + return)
- Update Prompt_t1live.md and CHANGELOG-backend
- Tests: 309/309 green
2026-06-30 02:57:17 +03:00

480 lines
17 KiB
TypeScript

/**
* geminiLiveT1.ts — Sprint 7a (T1 EO Live, examinateur avec interruption
* pilotée par le BACKEND).
*
* Ce module porte la spécificité T1 :
* - buildT1SystemPrompt : le prompt système de l'examinateur ;
* - openGeminiLiveT1Session : le proxy WS + l'horloge probabiliste qui décide
* QUAND interrompre, et l'injection de la relance (clientContent).
*
* Les helpers WS bas niveau (parseAudioChunk, isEndSignal, tryParseGeminiJson,
* reconstructTranscript) et le setup frame paramétrable (buildSetupFrame) sont
* réutilisés depuis `geminiLive.ts` (exports additifs Sprint 7a).
*
* ⚠ Différence fondamentale avec T2 : en T1, l'examinateur DOIT poser des
* questions pour relancer le candidat. La règle 7 du T2 (interdiction absolue
* de poser des questions / bannissement du point d'interrogation) NE DOIT
* JAMAIS être propagée ici. Cf. TD-22 / TD-23.
*
* MODÈLE 1 (acté) : c'est l'HORLOGE PROBABILISTE du backend qui décide seule du
* timing des interruptions. Le backend NE lit PAS la transcription partielle
* pour décider — Gemini formule la relance à partir de son contexte audio
* interne. (Découverte spike : en VAD manuel, inputTranscription n'est flushé
* qu'à l'envoi d'activityEnd, pas en continu.)
*/
import { WebSocket as NodeWebSocket } from "ws";
import {
GEMINI_LIVE_URL,
buildSetupFrame,
isEndSignal,
parseAudioChunk,
reconstructTranscript,
tryParseGeminiJson,
type TranscriptEntry,
type WebSocketLike,
} from "./geminiLive.js";
/**
* Construit le prompt système T1 Live.
*
* L'examinateur formule ses relances à partir de ce qu'il ENTEND en temps réel
* (son contexte audio interne) — il n'existe pas de sujet T1 en base et le flux
* ne dépend plus d'un questionnaire pré-rempli.
*
* Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut
* et ne prend la parole QUE lorsque le backend le lui signale (injection
* `clientContent` au moment choisi par l'horloge probabiliste). C'est le
* BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une
* relance courte à partir de ce que le candidat vient de dire.
*/
export function buildT1SystemPrompt(): string {
return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
RÈGLES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire.
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
8. Tu restes toujours dans ton rôle d'examinateur. Tu ne mentionnes jamais que tu es une IA ou un modèle.`;
}
// ── Constantes nommées (PAS de nombres magiques) ────────────────────────────
/** Timeout total de la session T1 Live (filet de sécurité). */
export const T1_SESSION_TIMEOUT_MS = 180_000;
/** Warning client : 30 s avant le timeout. */
export const T1_SESSION_WARNING_MS = 150_000;
/** Distribution du nombre d'interruptions tirées au début de session. */
export const T1_INTERRUPTION_P0 = 0.2; // P(0 interruption)
export const T1_INTERRUPTION_P1 = 0.6; // P(1 interruption)
export const T1_INTERRUPTION_P2 = 0.2; // P(2 interruptions)
/** Fenêtre temporelle (depuis le début de session) où placer les interruptions. */
export const T1_INTERRUPTION_WINDOW_START_MS = 25_000;
export const T1_INTERRUPTION_WINDOW_END_MS = 75_000;
/** Espacement minimal garanti entre deux interruptions. */
export const T1_INTERRUPTION_MIN_SPACING_MS = 20_000;
/**
* Délai d'attente, après l'activityEnd FINAL, pour laisser Gemini flusher la
* transcription du dernier segment candidat avant de finaliser la session.
*/
export const T1_TERMINAL_FLUSH_GRACE_MS = 3_000;
/** MIME du flux audio candidat (PCM 16 kHz mono), identique au T2. */
const T1_INPUT_AUDIO_MIME = "audio/pcm;rate=16000";
/** VAD MANUEL : c'est le backend qui borne les tours (activityStart/End). */
const T1_MANUAL_VAD = { disabled: true } as const;
/** Consigne interne injectée pour déclencher une relance (jamais lue à voix haute). */
const T1_RELANCE_INSTRUCTION =
"[CONSIGNE INTERNE — ne pas répéter] Interromps maintenant le candidat avec UNE seule question de relance courte et pertinente, liée à ce qu'il vient de dire.";
const ACTIVITY_START_FRAME = JSON.stringify({
realtimeInput: { activityStart: {} },
});
const ACTIVITY_END_FRAME = JSON.stringify({
realtimeInput: { activityEnd: {} },
});
function buildRelanceFrame(): string {
return JSON.stringify({
clientContent: {
turns: [{ role: "user", parts: [{ text: T1_RELANCE_INSTRUCTION }] }],
turnComplete: true,
},
});
}
// ── Logique probabiliste (fonctions pures, testables avec random injecté) ────
/**
* Tire le nombre d'interruptions de la session selon la distribution
* P0/P1/P2. `random()` ∈ [0,1).
*/
export function drawT1InterruptionCount(random: () => number): 0 | 1 | 2 {
const r = random();
if (r < T1_INTERRUPTION_P0) return 0;
if (r < T1_INTERRUPTION_P0 + T1_INTERRUPTION_P1) return 1;
return 2;
}
/**
* Planifie les instants (offsets ms depuis le début de session) des
* interruptions dans la fenêtre [START, END], avec un espacement minimal
* garanti de MIN_SPACING entre deux interruptions.
*/
export function planT1InterruptionInstants(
count: 0 | 1 | 2,
random: () => number,
): number[] {
const start = T1_INTERRUPTION_WINDOW_START_MS;
const end = T1_INTERRUPTION_WINDOW_END_MS;
const spacing = T1_INTERRUPTION_MIN_SPACING_MS;
if (count === 0) return [];
if (count === 1) {
return [start + random() * (end - start)];
}
// count === 2 : premier dans [start, end - spacing], second au moins
// `spacing` après le premier et au plus `end`.
const first = start + random() * (end - spacing - start);
const second = first + spacing + random() * (end - (first + spacing));
return [first, second];
}
// ── Options de session ───────────────────────────────────────────────────────
export interface OpenGeminiLiveT1SessionOptions {
/** Callback de fin de session avec le transcript reconstruit. */
onSessionEnd?: (transcript: string) => void | Promise<void>;
/** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */
timeoutMs?: number;
/** Override warning (défaut T1_SESSION_WARNING_MS). */
warningMs?: number;
/** Surcharge la clé API (défaut process.env.GEMINI_API_KEY). */
apiKey?: string;
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
clientFactory?: (url: string) => WebSocketLike;
/** Source d'aléa injectable (défaut Math.random) pour la testabilité. */
random?: () => number;
}
/**
* Ouvre une session T1 Live : proxy WS bidirectionnel client ⇄ Gemini en VAD
* MANUEL, avec interruption(s) injectée(s) au(x) instant(s) tiré(s) par
* l'horloge probabiliste.
*
* Contrat WS côté client (figé — la suite du sprint 7b en dépend) :
* - {type:'interruption_start'} : l'examinateur prend la parole ;
* - {type:'interruption_end'} : le candidat peut reprendre.
*
* Séquence d'une interruption (Modèle 1) :
* activityEnd → clientContent(relance, turnComplete) → (turnComplete Gemini)
* → activityStart.
*
* FIN DE SESSION : on envoie un activityEnd FINAL pour flusher le dernier
* segment candidat (sinon perdu — la transcription n'est flushée qu'à
* activityEnd en VAD manuel). Cet activityEnd déclenche AUSSI une relance
* examinateur « terminale » : on la SUPPRIME (audio non forwardé au client,
* texte jeté). Cf. point de vigilance dans le handler de message.
*/
export function openGeminiLiveT1Session(
clientWs: WebSocketLike,
opts: OpenGeminiLiveT1SessionOptions,
): void {
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
if (!apiKey) {
clientWs.close(4005, "GEMINI_CONFIG");
return;
}
const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS;
const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS;
const random = opts.random ?? Math.random;
const systemPrompt = buildT1SystemPrompt();
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const factory =
opts.clientFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
const geminiWs = factory(url);
const entries: TranscriptEntry[] = [];
// ── État ──
let started = false; // startSession() exécuté une seule fois
let sessionEnded = false; // endSession() entamé (idempotence)
let finalized = false; // finalize() exécuté une seule fois
let candidateTurnOpen = false; // un tour candidat est ouvert côté Gemini
let injecting = false; // une interruption est en cours
let awaitingRelance = false; // on attend le turnComplete de la relance
// terminalFlush : on a envoyé l'activityEnd FINAL. À partir de là, l'audio et
// le texte de l'examinateur (relance terminale) sont SUPPRIMÉS ; seule la
// transcription candidat reste collectée.
let terminalFlush = false;
const interruptionTimers: ReturnType<typeof setTimeout>[] = [];
let warningTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = () => {
for (const t of interruptionTimers) clearTimeout(t);
interruptionTimers.length = 0;
if (warningTimer !== null) {
clearTimeout(warningTimer);
warningTimer = null;
}
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
};
const geminiSend = (frame: string) => {
try {
geminiWs.send(frame);
} catch (err) {
console.error(
"[T1] Gemini send failed:",
err instanceof Error ? err.message : String(err),
);
void endSession();
}
};
const clientSend = (obj: unknown) => {
try {
clientWs.send(JSON.stringify(obj));
} catch {
/* ignore */
}
};
// ── Injection d'une interruption ──
const doInterruption = () => {
if (sessionEnded || terminalFlush || injecting || !candidateTurnOpen)
return;
injecting = true;
awaitingRelance = true;
candidateTurnOpen = false;
clientSend({ type: "interruption_start" });
geminiSend(ACTIVITY_END_FRAME);
geminiSend(buildRelanceFrame());
};
const resumeAfterInjection = () => {
awaitingRelance = false;
injecting = false;
geminiSend(ACTIVITY_START_FRAME);
candidateTurnOpen = true;
clientSend({ type: "interruption_end" });
};
// ── Démarrage (sur setupComplete) ──
const startSession = () => {
if (started) return;
started = true;
// Ouvre le premier tour candidat.
geminiSend(ACTIVITY_START_FRAME);
candidateTurnOpen = true;
// Tire et planifie les interruptions.
const count = drawT1InterruptionCount(random);
const instants = planT1InterruptionInstants(count, random);
for (const offset of instants) {
interruptionTimers.push(setTimeout(() => doInterruption(), offset));
}
warningTimer = setTimeout(() => {
if (sessionEnded) return;
clientSend({ type: "warning", message: "30 secondes restantes" });
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
};
const finalize = async () => {
if (finalized) return;
finalized = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
if (opts.onSessionEnd) {
try {
await opts.onSessionEnd(reconstructTranscript(entries));
} catch (err) {
console.error(
"[T1] onSessionEnd threw:",
err instanceof Error ? err.message : String(err),
);
}
}
};
// endSession est idempotent : double signal end → un seul flush + finalize.
async function endSession() {
if (sessionEnded) return;
sessionEnded = true;
clearTimers();
terminalFlush = true;
// Flush du dernier segment candidat : indispensable car en VAD manuel la
// transcription candidat n'est émise qu'à l'activityEnd.
if (candidateTurnOpen) {
geminiSend(ACTIVITY_END_FRAME);
candidateTurnOpen = false;
}
// Laisse à Gemini le temps d'émettre l'inputTranscription flushée, puis
// finalise (la relance terminale éventuelle est ignorée — cf. handler).
finalizeTimer = setTimeout(
() => void finalize(),
T1_TERMINAL_FLUSH_GRACE_MS,
);
}
// ── Gemini → client ──
geminiWs.on("open", () => {
geminiSend(buildSetupFrame(systemPrompt, T1_MANUAL_VAD));
});
geminiWs.on("message", (data) => {
const parsed = tryParseGeminiJson(data);
if (parsed?.setupComplete) {
startSession();
}
if (parsed) {
const sc = parsed.serverContent;
// POINT DE VIGILANCE — séparation "audio relance terminale à couper" vs
// "texte candidat final à garder" quand ils arrivent dans le MÊME
// message Gemini : on traite CHAMP PAR CHAMP, pas message par message.
// - serverContent.inputTranscription.text = CANDIDAT → toujours gardé,
// y compris pendant le flush terminal (c'est précisément ce qu'on veut
// récupérer).
// - serverContent.outputTranscription.text = EXAMINATEUR → ignoré
// pendant le flush terminal (relance terminale jetée).
// - serverContent.modelTurn.*.inlineData = audio EXAMINATEUR → non
// forwardé au client pendant le flush terminal (cf. plus bas).
if (sc?.inputTranscription?.text) {
entries.push({ speaker: "candidat", text: sc.inputTranscription.text });
}
if (!terminalFlush && sc?.outputTranscription?.text) {
entries.push({
speaker: "examinateur",
text: sc.outputTranscription.text,
});
}
// Reprise candidat après la relance (jamais pendant le flush terminal :
// on ne rouvre pas de tour, la session se termine).
if (sc?.turnComplete && injecting && awaitingRelance && !terminalFlush) {
resumeAfterInjection();
}
}
// Forward verbatim au client SAUF pendant le flush terminal : ainsi l'audio
// de la relance terminale (modelTurn inlineData) n'est jamais entendu par
// le candidat.
if (!terminalFlush) {
try {
clientWs.send(data);
} catch {
void endSession();
}
}
});
geminiWs.on("close", () => {
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
geminiWs.on("error", () => {
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
// ── Client → Gemini ──
clientWs.on("message", (data) => {
if (isEndSignal(data)) {
void endSession();
return;
}
const audioBase64 = parseAudioChunk(data);
if (audioBase64 === null) {
// Message non reconnu (ni audio ni end). Notamment un éventuel
// {type:'context'} envoyé par un ancien front : ignoré silencieusement —
// jamais de crash ni de close. Cf. point de vigilance Patch 7a.
console.debug("[T1] ignored non-audio client message");
return;
}
if (!sessionEnded && candidateTurnOpen && !injecting) {
geminiSend(
JSON.stringify({
realtimeInput: {
audio: { data: audioBase64, mimeType: T1_INPUT_AUDIO_MIME },
},
}),
);
}
});
clientWs.on("close", () => {
clearTimers();
if (finalizeTimer !== null) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
sessionEnded = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
});
clientWs.on("error", () => {
clearTimers();
if (finalizeTimer !== null) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
sessionEnded = true;
try {
geminiWs.close(1011);
} catch {
/* ignore */
}
});
}