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

@ -6,6 +6,35 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
---
## [Unreleased] — 2026-04-26 — Sprint 6a — Backend T2 Live
### Added
- `buildT2SystemPrompt({role, contexte})` dans `geminiLive.ts` — prompt dynamique conforme `Prompt_t2live.md §3`, remplace la constante `T2_SYSTEM_PROMPT` (agent immobilier).
- Accumulation transcripts pendant la session WS : `inputTranscription[]` + `outputTranscription[]` parsés depuis les messages Gemini, reconstruits en transcript chronologique à la fin.
- VAD config dans le setup frame Gemini : `endOfSpeechSensitivity: END_SENSITIVITY_LOW`, `startOfSpeechSensitivity: START_SENSITIVITY_LOW`, `silenceDurationMs: 2000`.
- Timeout session 210 s (3 min 30) + warning client à 180 s (30 s restantes).
- Signal client `{type:'end'}` pour fin anticipée du dialogue.
- Close codes : 4005 `GEMINI_CONFIG`, 4006 `GEMINI_DISCONNECTED`.
- Orchestration `t2live.ts` : fetch sujet par UUID (`?sujet=<uuid>`, validation `mode='EO'` + `tache=2`), close 4004 `SUJET_NOT_FOUND` si absent.
- Post-session : `runT2LiveCorrection` — insert `productions(tache='EO_T2_LIVE')`, appel `deepseekCorrectEO(transcript, 'EO_T2')`, `PHONOLOGY_STUB` (TD-08), persist rapport + score + nclc, envoi `{type:'report'}` au client, close 1000.
- `TacheEO` étendu avec `'EO_T2'` dans `deepseek.ts` + `VALID_TACHES_EO` dans `corrections.ts`.
- 10 tests d'intégration `t2live.test.ts` (auth, sujet, pipeline correction nominal + erreurs).
- 11 tests `geminiLive.test.ts` (7 réécrits + 4 nouveaux : prompt builder, accumulation, timeout/warning, end signal).
### Changed
- `geminiLive.ts` réécrit — setup frame paramétrable, `inputAudioTranscription` + `outputAudioTranscription` activés, callback `onSessionEnd(transcript)`.
- `corrections.ts``VALID_TACHES_EO` inclut `'EO_T2'`.
### Notes
- Tests backend : 292/292 verts (+15 vs baseline 277).
- Phonologie T2 Live = 0 (TD-08 — pas d'audio brut pour évaluation phonologique).
- Le frontend n'est pas encore connecté — test e2e au Sprint 6c.
---
## [Unreleased] — 2026-04-26 — Sprint 5a — Backend billing cleanup
### Added

View file

@ -1,134 +1,287 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import {
openGeminiLiveSession,
T2_SYSTEM_PROMPT,
buildT2SystemPrompt,
type WebSocketLike,
} from '../geminiLive'
} from "../geminiLive";
class FakeWs extends EventEmitter implements WebSocketLike {
public sent: unknown[] = []
public closed = false
public closeCode?: number
public closeReason?: string
public sent: unknown[] = [];
public closed = false;
public closeCode?: number;
public closeReason?: string;
send(data: unknown): void {
this.sent.push(data)
this.sent.push(data);
}
close(code?: number, reason?: string): void {
if (this.closed) return
this.closed = true
this.closeCode = code
this.closeReason = reason
if (this.closed) return;
this.closed = true;
this.closeCode = code;
this.closeReason = reason;
}
}
describe('openGeminiLiveSession', () => {
let originalKey: string | undefined
const SUJET_OPTS = {
role: "un bailleur qui propose un appartement à louer",
contexte:
"Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.",
};
describe("buildT2SystemPrompt", () => {
it("substitue role et contexte dans le template", () => {
const prompt = buildT2SystemPrompt(SUJET_OPTS);
expect(prompt).toContain(
"Tu joues le rôle de un bailleur qui propose un appartement à louer",
);
expect(prompt).toContain("Vous cherchez un appartement");
expect(prompt).toContain("uniquement en français");
expect(prompt).toContain("Tu ne prends PAS la parole en premier");
});
});
describe("openGeminiLiveSession", () => {
let originalKey: string | undefined;
beforeEach(() => {
originalKey = process.env.GEMINI_API_KEY
process.env.GEMINI_API_KEY = 'test-key'
})
originalKey = process.env.GEMINI_API_KEY;
process.env.GEMINI_API_KEY = "test-key";
vi.useFakeTimers();
});
afterEach(() => {
if (originalKey === undefined) {
delete process.env.GEMINI_API_KEY
delete process.env.GEMINI_API_KEY;
} else {
process.env.GEMINI_API_KEY = originalKey
process.env.GEMINI_API_KEY = originalKey;
}
vi.restoreAllMocks()
})
vi.useRealTimers();
vi.restoreAllMocks();
});
it("envoie le setup frame avec T2_SYSTEM_PROMPT a l'open Gemini", () => {
const client = new FakeWs()
const gemini = new FakeWs()
it("envoie le setup frame avec prompt dynamique + VAD + transcriptions", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
});
gemini.emit("open");
expect(gemini.sent).toHaveLength(1)
const setup = JSON.parse(gemini.sent[0] as string)
expect(setup.setup.model).toMatch(/gemini/)
expect(setup.setup.systemInstruction.parts[0].text).toBe(T2_SYSTEM_PROMPT)
expect(setup.setup.generationConfig.responseModalities).toContain('AUDIO')
})
expect(gemini.sent).toHaveLength(1);
const setup = JSON.parse(gemini.sent[0] as string);
expect(setup.setup.model).toMatch(/gemini/);
expect(setup.setup.systemInstruction.parts[0].text).toContain(
"un bailleur qui propose un appartement",
);
expect(setup.setup.generationConfig.responseModalities).toContain("AUDIO");
expect(setup.setup.inputAudioTranscription).toEqual({});
expect(setup.setup.outputAudioTranscription).toEqual({});
expect(
setup.setup.realtimeInputConfig.automaticActivityDetection,
).toMatchObject({
disabled: false,
startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
silenceDurationMs: 2000,
});
});
it('forwarde un message client (Buffer audio) vers Gemini', () => {
const client = new FakeWs()
const gemini = new FakeWs()
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
it("forwarde un chunk audio client (Buffer) vers Gemini", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
});
gemini.emit("open");
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04])
client.emit('message', audioChunk)
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04]);
client.emit("message", audioChunk);
// [0] = setup frame, [1] = audio forwarde
expect(gemini.sent).toHaveLength(2)
expect(gemini.sent[1]).toBe(audioChunk)
})
// [0] = setup, [1] = chunk audio
expect(gemini.sent).toHaveLength(2);
expect(gemini.sent[1]).toBe(audioChunk);
});
it('forwarde un message Gemini vers le client', () => {
const client = new FakeWs()
const gemini = new FakeWs()
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
it("forwarde un chunk audio Gemini (Buffer non-JSON) vers le client sans accumuler de transcript", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
const examinerAudio = Buffer.from([0x10, 0x20])
gemini.emit('message', examinerAudio)
const examinerAudio = Buffer.from([0x10, 0x20, 0x30]);
gemini.emit("message", examinerAudio);
expect(client.sent).toHaveLength(1);
expect(client.sent[0]).toBe(examinerAudio);
expect(client.sent).toHaveLength(1)
expect(client.sent[0]).toBe(examinerAudio)
})
// Fin de session via signal client → transcript vide
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledWith("");
});
it('fermeture client → ferme Gemini avec code 1000', () => {
const client = new FakeWs()
const gemini = new FakeWs()
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
it("accumule inputTranscription et outputTranscription depuis Gemini", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
client.emit('close')
gemini.emit(
"message",
JSON.stringify({
serverContent: {
inputTranscription: { text: "Bonjour, je voudrais louer." },
},
}),
);
gemini.emit(
"message",
JSON.stringify({
serverContent: {
outputTranscription: { text: "Bonjour, cest pour quel quartier ?" },
},
}),
);
gemini.emit(
"message",
JSON.stringify({
serverContent: {
inputTranscription: { text: "Le centre-ville." },
},
}),
);
expect(gemini.closed).toBe(true)
expect(gemini.closeCode).toBe(1000)
})
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
it('fermeture Gemini → ferme client avec code 1000', () => {
const client = new FakeWs()
const gemini = new FakeWs()
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
expect(onSessionEnd).toHaveBeenCalledTimes(1);
const transcript = onSessionEnd.mock.calls[0][0] as string;
expect(transcript).toBe(
"Candidat : Bonjour, je voudrais louer.\nExaminateur : Bonjour, cest pour quel quartier ?\nCandidat : Le centre-ville.",
);
});
gemini.emit('close')
it("ferme Gemini après onSessionEnd, sans fermer le client (réservé à lappelant)", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1000)
})
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
it('erreur Gemini → ferme client avec code 1011 GEMINI_ERROR', () => {
const client = new FakeWs()
const gemini = new FakeWs()
openGeminiLiveSession(client, { geminiFactory: () => gemini })
gemini.emit('open')
expect(gemini.closed).toBe(true);
expect(gemini.closeCode).toBe(1000);
expect(client.closed).toBe(false);
});
gemini.emit('error', new Error('boom'))
it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1011)
expect(client.closeReason).toBe('GEMINI_ERROR')
})
// Avancer à 180 s → warning au client
await vi.advanceTimersByTimeAsync(180_000);
const warningFrame = client.sent.find(
(f) => typeof f === "string" && f.includes('"warning"'),
);
expect(warningFrame).toBeDefined();
expect(JSON.parse(warningFrame as string)).toEqual({
type: "warning",
message: "30 secondes restantes",
});
expect(onSessionEnd).not.toHaveBeenCalled();
it("absence de GEMINI_API_KEY → close client 1011 CONFIG_ERROR sans appel a la factory", () => {
delete process.env.GEMINI_API_KEY
const client = new FakeWs()
const factory = vi.fn(() => new FakeWs())
// Avancer à 210 s total → timeout déclenche endSession
await vi.advanceTimersByTimeAsync(30_000);
expect(onSessionEnd).toHaveBeenCalledTimes(1);
expect(gemini.closed).toBe(true);
});
openGeminiLiveSession(client, { geminiFactory: factory })
it("signal end client déclenche endSession une seule fois (idempotent)", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
expect(factory).not.toHaveBeenCalled()
expect(client.closed).toBe(true)
expect(client.closeCode).toBe(1011)
expect(client.closeReason).toBe('CONFIG_ERROR')
})
})
client.emit("message", JSON.stringify({ type: "end" }));
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledTimes(1);
});
it("fermeture Gemini avant fin → close client 4006 GEMINI_DISCONNECTED", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
});
gemini.emit("open");
gemini.emit("close");
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4006);
expect(client.closeReason).toBe("GEMINI_DISCONNECTED");
});
it("erreur Gemini → close client 4006 GEMINI_DISCONNECTED", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
geminiFactory: () => gemini,
});
gemini.emit("open");
gemini.emit("error", new Error("boom"));
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4006);
});
it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à la factory", () => {
delete process.env.GEMINI_API_KEY;
const client = new FakeWs();
const factory = vi.fn(() => new FakeWs());
openGeminiLiveSession(client, { ...SUJET_OPTS, geminiFactory: factory });
expect(factory).not.toHaveBeenCalled();
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4005);
expect(client.closeReason).toBe("GEMINI_CONFIG");
});
});

View file

@ -29,7 +29,7 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
// ── Types — Sprint 3.6a ──────────────────────────────────────────────────
export type TacheEE = "EE_T1" | "EE_T2" | "EE_T3";
export type TacheEO = "EO_T1" | "EO_T3";
export type TacheEO = "EO_T1" | "EO_T2" | "EO_T3";
export type TacheCorrection = TacheEE | TacheEO;
export type NclcCible = 9 | 10;
@ -156,6 +156,7 @@ const WORD_LIMITS: Record<TacheCorrection, { min: number; max: number }> = {
EE_T2: { min: 120, max: 150 },
EE_T3: { min: 120, max: 180 },
EO_T1: { min: 200, max: 300 },
EO_T2: { min: 250, max: 450 },
EO_T3: { min: 450, max: 620 },
};
@ -168,6 +169,8 @@ const TASK_DESCRIPTIONS: Record<TacheCorrection, string> = {
"Tâche 3 — Texte comparatif (120-180 mots) : Partie 1 (40-60 mots) synthèse des deux points de vue des documents sources ; Partie 2 (80-120 mots) prise de position personnelle argumentée.",
EO_T1:
"T1 — Présentation personnelle (entretien dirigé, 2 minutes) : se présenter, parler de son parcours, de ses projets, de sa motivation. Registre courant, discours fluide et structuré.",
EO_T2:
"T2 — Interaction de service (3 minutes 30) : poser des questions à un interlocuteur (bailleur, vendeur, agent, etc.) pour obtenir les informations nécessaires à une décision concrète du quotidien. Registre courant à standard, formulation de questions claires et adaptées.",
EO_T3:
"T3 — Expression d'un point de vue spontané (4 minutes 30) : exprimer et défendre un point de vue sur une question, illustrer par des exemples concrets, organiser l'argumentation, conclure. Registre courant à standard.",
};

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 {
geminiWs.close(code, reason)
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.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('open', () => {
console.log('[T2] Gemini WS opened')
try {
geminiWs.send(buildSetupFrame())
console.log('[T2] Setup frame sent')
} catch {
closeBoth(1011, 'SETUP_FAILED')
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,
});
}
})
geminiWs.on('message', (data) => {
console.log(
'[T2] Gemini message received, type:',
typeof data,
'content:',
(data as { toString?: () => string })?.toString?.().slice(0, 500)
)
try {
clientWs.send(data)
} catch {
closeBoth(1011, 'CLIENT_SEND_FAILED')
if (parsed.outputText && parsed.outputText.length > 0) {
transcriptEntries.push({
speaker: "examinateur",
text: parsed.outputText,
});
}
})
clientWs.on('message', (data) => {
try {
geminiWs.send(data)
} catch {
closeBoth(1011, 'GEMINI_SEND_FAILED')
}
})
try {
clientWs.send(data);
} catch {
void endSession();
}
});
geminiWs.on('close', (code, reason) => {
console.log('[T2] Gemini closed, code:', code, 'reason:', reason)
closeBoth(1000)
})
clientWs.on('close', () => closeBoth(1000))
clientWs.on("message", (data) => {
if (isEndSignal(data)) {
void endSession();
return;
}
try {
geminiWs.send(data);
} catch {
void endSession();
}
});
geminiWs.on('error', (err) => {
console.log('[T2] Gemini error:', (err as Error)?.message)
closeBoth(1011, 'GEMINI_ERROR')
})
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
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("error", (err) => {
console.log("[T2] Gemini error:", (err as Error)?.message);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
clientWs.on("error", () => {
clearTimers();
sessionEnded = true;
try {
geminiWs.close(1011);
} catch {
/* ignore */
}
});
}

View file

@ -59,14 +59,14 @@ describe("POST /corrections/eo — Sprint 4a", () => {
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si tache invalide (EO_T2 par exemple)", async () => {
it("400 si tache invalide (hors EO_T1/T2/T3)", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T2",
tache: "EE_T1",
transcript: "t",
}),
});

View file

@ -0,0 +1,319 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
// ─── Mocks ───────────────────────────────────────────────────────────────────
vi.mock("../../lib/supabase", () => ({
supabase: {
auth: {
getUser: vi.fn(),
},
from: vi.fn(),
},
}));
vi.mock("../../lib/deepseek", async () => {
const actual =
await vi.importActual<typeof import("../../lib/deepseek")>(
"../../lib/deepseek",
);
return {
...actual,
correctEO: vi.fn(),
};
});
vi.mock("../../lib/geminiPhonology", () => ({
PHONOLOGY_STUB: {
score: 2,
commentaire: "Stub",
note_phonologie: "Stub",
},
}));
import { supabase } from "../../lib/supabase";
import { correctEO as deepseekCorrectEO } from "../../lib/deepseek";
import { authenticate, fetchSujetT2, runT2LiveCorrection } from "../t2live";
import type { WebSocketLike } from "../../lib/geminiLive";
// ─── Helpers ─────────────────────────────────────────────────────────────────
class FakeWs extends EventEmitter implements WebSocketLike {
public sent: unknown[] = [];
public closed = false;
public closeCode?: number;
public closeReason?: string;
send(data: unknown): void {
this.sent.push(data);
}
close(code?: number, reason?: string): void {
if (this.closed) return;
this.closed = true;
this.closeCode = code;
this.closeReason = reason;
}
}
function mockProfileQuery(plan: string | null, userId = "u1") {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
plan === null
? { data: null, error: { message: "not found" } }
: { data: { id: userId, plan }, error: null },
),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockSujetQuery(
row: {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
} | null,
) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
row === null
? { data: null, error: { message: "not found" } }
: { data: row, error: null },
),
})),
})),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockProductionInsert(
resultId: string | null,
errorMsg: string | null = null,
) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
insert: vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn(async () =>
errorMsg
? { data: null, error: { message: errorMsg } }
: { data: { id: resultId }, error: null },
),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockProductionUpdate(errorMsg: string | null = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: vi.fn(() => ({
eq: vi.fn(async () =>
errorMsg ? { error: { message: errorMsg } } : { error: null },
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
const FAKE_RAPPORT = {
score: 14,
nclc: 8,
nclc_cible: 9,
revelation: { croyance: "a", realite: "b", consequence: "c" },
diagnostic: "d",
criteres: [],
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "e", action_prioritaire: "p" },
erreurs_codes: [],
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("authenticate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refuse si token absent → 4001", async () => {
const result = await authenticate(undefined);
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si Supabase rejette le JWT → 4001", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: null } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: { message: "invalid" } as any,
});
const result = await authenticate("bad-token");
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si plan ne donne pas oral_t2_live → 4003", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("standard");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: false,
code: 4003,
reason: "PLAN_INSUFFICIENT",
});
});
it("accepte un utilisateur Premium → ok:true + profile", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("premium");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: true,
profile: { id: "u1", plan: "premium" },
});
});
});
describe("fetchSujetT2", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne null si Supabase ne trouve pas le sujet", async () => {
mockSujetQuery(null);
const result = await fetchSujetT2("unknown-id");
expect(result).toBeNull();
});
it("retourne le sujet si trouvé", async () => {
const row = {
id: "s1",
role: "un bailleur",
contexte: "Vous cherchez un appartement.",
consigne: "Appelez le bailleur.",
};
mockSujetQuery(row);
const result = await fetchSujetT2("s1");
expect(result).toEqual(row);
});
});
describe("runT2LiveCorrection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const profile = { id: "u1", plan: "premium" as const };
const sujet = {
id: "s1",
role: "un bailleur",
contexte: "Recherche appartement.",
consigne: "Appelez le bailleur.",
};
it("transcript vide → envoie EMPTY_TRANSCRIPT et close 1000 sans appeler DeepSeek", async () => {
const ws = new FakeWs();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: " ",
});
expect(deepseekCorrectEO).not.toHaveBeenCalled();
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1000);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent).toMatchObject({ type: "error", code: "EMPTY_TRANSCRIPT" });
});
it("flux nominal : insert production → DeepSeek → update → report → close 1000", async () => {
const ws = new FakeWs();
mockProductionInsert("prod-123");
vi.mocked(deepseekCorrectEO).mockResolvedValueOnce(FAKE_RAPPORT);
mockProductionUpdate();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour\nExaminateur : Bonjour",
});
expect(deepseekCorrectEO).toHaveBeenCalledWith(
"Candidat : Bonjour\nExaminateur : Bonjour",
"EO_T2",
9,
"Appelez le bailleur.",
);
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1000);
const reportFrame = ws.sent.find(
(f) => typeof f === "string" && f.includes('"report"'),
);
expect(reportFrame).toBeDefined();
const parsed = JSON.parse(reportFrame as string);
expect(parsed.type).toBe("report");
// Score textuel 14 + phonologie stub 2 = 16
expect(parsed.data.score).toBe(16);
expect(parsed.data.nclc).toBe(8);
expect(parsed.data.simulation_id).toBe("prod-123");
});
it("insert production échoue → PERSISTENCE_FAILED + close 1011", async () => {
const ws = new FakeWs();
mockProductionInsert(null, "db down");
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour",
});
expect(deepseekCorrectEO).not.toHaveBeenCalled();
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1011);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent.code).toBe("PERSISTENCE_FAILED");
});
it("DeepSeek throw → CORRECTION_FAILED + close 1011", async () => {
const ws = new FakeWs();
mockProductionInsert("prod-456");
vi.mocked(deepseekCorrectEO).mockRejectedValueOnce(new Error("timeout"));
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour",
});
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1011);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent.code).toBe("CORRECTION_FAILED");
});
});

View file

@ -4,7 +4,7 @@ import type { AppVariables } from "../middleware/auth.js";
import * as correctionController from "../controllers/correctionController.js";
const VALID_TACHES_EE = ["EE_T1", "EE_T2", "EE_T3"];
const VALID_TACHES_EO = ["EO_T1", "EO_T3"];
const VALID_TACHES_EO = ["EO_T1", "EO_T2", "EO_T3"];
const corrections = new Hono<{ Variables: AppVariables }>();
@ -200,7 +200,7 @@ corrections.post("/eo", authMiddleware, async (c) => {
const result = await correctionController.correctEO(
{
simulationId: body.simulationId,
tache: body.tache as "EO_T1" | "EO_T3",
tache: body.tache as "EO_T1" | "EO_T2" | "EO_T3",
nclcCible,
transcript: hasTranscript ? (body.transcript as string) : undefined,
audioBase64: hasAudio ? (body.audioBase64 as string) : undefined,

View file

@ -1,101 +1,343 @@
import { Hono } from 'hono'
import type { UpgradeWebSocket } from 'hono/ws'
import { EventEmitter } from 'node:events'
import { supabase } from '../lib/supabase.js'
import { checkFeatureAccess } from '../lib/access.js'
import type { Plan } from '../lib/access.js'
import { Hono } from "hono";
import type { UpgradeWebSocket } from "hono/ws";
import { EventEmitter } from "node:events";
import { supabase } from "../lib/supabase.js";
import { checkFeatureAccess } from "../lib/access.js";
import type { Plan } from "../lib/access.js";
import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
import {
openGeminiLiveSession,
type WebSocketLike,
} from '../lib/geminiLive.js'
type OpenGeminiLiveSessionOptions,
} from "../lib/geminiLive.js";
interface SujetRow {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
}
interface Profile {
id: string;
plan: Plan;
}
interface AuthSucces {
ok: true;
profile: Profile;
}
interface AuthFailure {
ok: false;
code: number;
reason: string;
}
export async function authenticate(
token: string | undefined,
): Promise<AuthSucces | AuthFailure> {
if (!token) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token);
if (authError || !user) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("id, plan")
.eq("id", user.id)
.single();
if (profileError || !profile) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
if (!checkFeatureAccess(profile.plan as Plan, "oral_t2_live")) {
return { ok: false, code: 4003, reason: "PLAN_INSUFFICIENT" };
}
return {
ok: true,
profile: { id: profile.id as string, plan: profile.plan as Plan },
};
} catch {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
}
export async function fetchSujetT2(sujetId: string): Promise<SujetRow | null> {
const { data, error } = await supabase
.from("sujets")
.select("id, role, contexte, consigne")
.eq("id", sujetId)
.eq("mode", "EO")
.eq("tache", 2)
.single();
if (error || !data) return null;
return data as SujetRow;
}
/**
* Pipeline post-session : crée la production, lance la correction EO sur le
* transcript reconstruit, persiste le rapport, envoie au client puis ferme.
*
* Cf. docs/IMPLEMENTATION_T2_LIVE.md §3 Phase 3.
*
* Notes :
* - tache='EO_T2' pour la correction (le pipeline DeepSeek), tache='EO_T2_LIVE'
* pour la persistance (enum DB).
* - Phonologie = PHONOLOGY_STUB (TD-08 pas d'audio brut côté backend).
*/
export async function runT2LiveCorrection(args: {
clientWs: WebSocketLike;
profile: Profile;
sujet: SujetRow;
transcript: string;
}): Promise<void> {
const { clientWs, profile, sujet, transcript } = args;
if (transcript.trim().length === 0) {
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "EMPTY_TRANSCRIPT",
message: "Aucun échange enregistré.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000, "EMPTY_TRANSCRIPT");
} catch {
/* ignore */
}
return;
}
// 1. Créer la production (rapport=null pour l'instant).
const { data: created, error: insertError } = await supabase
.from("productions")
.insert({
user_id: profile.id,
tache: "EO_T2_LIVE",
mode: "entrainement",
sujet_id: sujet.id,
contenu: transcript,
})
.select("id")
.single();
if (insertError || !created) {
console.error("[T2] production insert failed:", insertError?.message);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "PERSISTENCE_FAILED",
message: "Impossible d'enregistrer la session.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "PERSISTENCE_FAILED");
} catch {
/* ignore */
}
return;
}
const productionId = (created as { id: string }).id;
// 2. Lancer la correction EO via DeepSeek.
let rapport;
try {
rapport = await deepseekCorrectEO(
transcript,
"EO_T2",
9,
sujet.consigne ?? null,
);
} catch (err) {
console.error(
"[T2] DeepSeek correction failed:",
err instanceof Error ? err.message : String(err),
);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "CORRECTION_FAILED",
message: "Erreur lors de la correction.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "CORRECTION_FAILED");
} catch {
/* ignore */
}
return;
}
// 3. Appliquer phonologie stub (TD-08) : score textuel /16 + phonologie /4 = /20.
const scoreTextuel = rapport.score;
const scoreFinal = scoreTextuel + PHONOLOGY_STUB.score;
// 4. Persister le rapport.
const { error: updateError } = await supabase
.from("productions")
.update({
rapport,
score: scoreFinal,
nclc: rapport.nclc,
})
.eq("id", productionId);
if (updateError) {
console.error("[T2] production update failed:", updateError.message);
}
// 5. Envoyer le rapport au client puis fermer.
try {
clientWs.send(
JSON.stringify({
type: "report",
data: {
...rapport,
score: scoreFinal,
simulation_id: productionId,
},
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000);
} catch {
/* ignore */
}
}
export interface CreateT2LiveRoutesOptions {
/** Injection pour les tests : fabrique de WebSocket vers Gemini. */
geminiFactory?: OpenGeminiLiveSessionOptions["geminiFactory"];
/** Injection pour les tests : override timeout/warning. */
timeoutMs?: number;
warningMs?: number;
}
/**
* Crée le router pour `WS /t2/live`.
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
* - Refus auth close 4001, refus plan close 4003
* - OK openGeminiLiveSession (proxy vers Gemini Live)
* - Sujet : id passé en query param `?sujet=<uuid>` table `sujets` (mode='EO', tache=2)
* - Refus auth 4001, refus plan 4003, sujet introuvable 4004
* - OK openGeminiLiveSession onSessionEnd : correction EO + persistance + report
*/
export default function createT2LiveRoutes(
upgradeWebSocket: UpgradeWebSocket
upgradeWebSocket: UpgradeWebSocket,
opts: CreateT2LiveRoutesOptions = {},
) {
const app = new Hono()
const app = new Hono();
app.get(
'/live',
"/live",
upgradeWebSocket(async (c) => {
const token = c.req.query('token')
let denyCode: number | null = null
let denyReason = ''
const token = c.req.query("token");
const sujetId = c.req.query("sujet");
if (!token) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
let denyCode: number | null = null;
let denyReason = "";
let profile: Profile | null = null;
let sujet: SujetRow | null = null;
const auth = await authenticate(token);
if (!auth.ok) {
denyCode = auth.code;
denyReason = auth.reason;
} else {
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token)
if (authError || !user) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
profile = auth.profile;
if (!sujetId) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('plan')
.eq('id', user.id)
.single()
if (profileError || !profile) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
} else if (
!checkFeatureAccess(profile.plan as Plan, 'oral_t2_live')
) {
denyCode = 4003
denyReason = 'PLAN_INSUFFICIENT'
sujet = await fetchSujetT2(sujetId);
if (!sujet) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else if (!sujet.role || !sujet.contexte) {
// Sécurité : un sujet T2 sans role/contexte ne peut pas alimenter le prompt.
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
}
}
} catch {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
}
}
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
const adapter = new EventEmitter() as EventEmitter & WebSocketLike
adapter.send = () => {}
adapter.close = () => {}
const adapter = new EventEmitter() as EventEmitter & WebSocketLike;
adapter.send = () => {};
adapter.close = () => {};
return {
onOpen(_evt, ws) {
adapter.send = (data: unknown) =>
ws.send(data as Parameters<typeof ws.send>[0])
ws.send(data as Parameters<typeof ws.send>[0]);
adapter.close = (code?: number, reason?: string) =>
ws.close(code, reason)
ws.close(code, reason);
if (denyCode !== null) {
ws.send(JSON.stringify({ error: true, code: denyReason }))
setTimeout(() => ws.close(denyCode!, denyReason), 100)
return
try {
ws.send(JSON.stringify({ error: true, code: denyReason }));
} catch {
/* ignore */
}
setTimeout(() => ws.close(denyCode!, denyReason), 100);
return;
}
openGeminiLiveSession(adapter)
// À ce stade : profile et sujet sont garantis non-null par les checks ci-dessus.
const profileNonNull = profile!;
const sujetNonNull = sujet!;
openGeminiLiveSession(adapter, {
role: sujetNonNull.role!,
contexte: sujetNonNull.contexte!,
geminiFactory: opts.geminiFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
onSessionEnd: async (transcript) => {
await runT2LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
sujet: sujetNonNull,
transcript,
});
},
});
},
onMessage(evt) {
adapter.emit('message', evt.data)
adapter.emit("message", evt.data);
},
onClose() {
adapter.emit('close')
adapter.emit("close");
},
onError() {
adapter.emit('error', new Error('CLIENT_ERROR'))
adapter.emit("error", new Error("CLIENT_ERROR"));
},
}
})
)
};
}),
);
return app
return app;
}