Some checks are pending
CI / quality (push) Waiting to run
- Add socks-proxy-agent dependency - Add resolveGeminiProxyAgent() helper reading GEMINI_PROXY_URL env - Apply agent to T1 and T2 Gemini WS factory defaults - No proxy when GEMINI_PROXY_URL is unset (local dev unchanged) - Tests: 311/311 green
486 lines
17 KiB
TypeScript
486 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,
|
|
resolveGeminiProxyAgent,
|
|
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 proxyAgent = resolveGeminiProxyAgent();
|
|
const factory =
|
|
opts.clientFactory ??
|
|
((u: string) =>
|
|
new NodeWebSocket(
|
|
u,
|
|
proxyAgent ? { agent: proxyAgent } : undefined,
|
|
) 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 */
|
|
}
|
|
});
|
|
}
|