feat(t1-live): examinateur avec interruption probabiliste pilotee backend (Sprint 7a)

- Session T1 Live : monologue candidat + interruptions pilotees backend (VAD manuel).
- Voix examinateur native Gemini ; le backend decide le timing (tirage probabiliste 0-2, fenetre [25s,75s]), Gemini formule la relance sur signal d'injection (anti-TD-22).
- Injection : activityEnd -> clientContent -> activityStart ; signaux WS interruption_start/end.
- Fin de session : activityEnd final flushe le dernier segment candidat ; relance terminale coupee (audio non renvoye, texte jete) ; seul le texte candidat conserve pour l'evaluation.
- buildT1SystemPrompt : nouvel artefact, regle 7 du T2 NON propagee (questions autorisees).
- Route /t1/live : auth Premium reutilisee, contexte questionnaire dynamique, persistance EO_T1 (sujet_id null), evaluation via correctEO('EO_T1'), phonologie stub /4 (TD-08 gele).
- geminiLive.ts : exports additifs + buildSetupFrame parametrable VAD (T2 inchange).
- gitignore : exclusion des artefacts jetables de test/spike.
This commit is contained in:
Hermann_Kitio 2026-06-29 22:07:57 +03:00
parent 5f7e52d88a
commit 868bd09397
7 changed files with 1404 additions and 17 deletions

487
src/lib/geminiLiveT1.ts Normal file
View file

@ -0,0 +1,487 @@
/**
* 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 type { PresentationReponses } from "../controllers/presentationController.js";
import {
GEMINI_LIVE_URL,
buildSetupFrame,
isEndSignal,
parseAudioChunk,
reconstructTranscript,
tryParseGeminiJson,
type TranscriptEntry,
type WebSocketLike,
} from "./geminiLive.js";
/**
* Construit le prompt système T1 Live à partir des réponses du questionnaire
* candidat (transmises dynamiquement il n'existe pas de sujet T1 en base).
*
* 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 son contexte audio interne.
*/
export function buildT1SystemPrompt(input: {
reponses: PresentationReponses;
}): string {
const { reponses } = input;
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.
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) :
- Identité : ${reponses.prenom_age_ville}
- Formation / métier : ${reponses.formation_metier}
- Situation familiale : ${reponses.situation_familiale}
- Loisirs : ${reponses.loisirs}
- Projet Canada : ${reponses.motivation_canada}
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 ou à son contexte ci-dessus.
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 {
/** Réponses du questionnaire candidat (contexte du prompt T1). */
reponses: PresentationReponses;
/** 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({ reponses: opts.reponses });
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 &&
!sessionEnded &&
candidateTurnOpen &&
!injecting
) {
geminiSend(
JSON.stringify({
realtimeInput: {
audio: { data: audioBase64, mimeType: T1_INPUT_AUDIO_MIME },
},
}),
);
}
// Tout autre message client est ignoré.
});
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 */
}
});
}