From d89b0b1e89d652f32140406cdd5e50a5ada73c80 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 19:50:48 +0300 Subject: [PATCH] =?UTF-8?q?Sprint=206a=20=E2=80=94=20Backend=20T2=20Live?= =?UTF-8?q?=20(WS=20proxy=20+=20correction=20+=20persistance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/CHANGELOG-backend.md | 29 ++ src/lib/__tests__/geminiLive.test.ts | 339 ++++++++++++----- src/lib/deepseek.ts | 5 +- src/lib/geminiLive.ts | 406 ++++++++++++++++----- src/routes/__tests__/correctionsEO.test.ts | 4 +- src/routes/__tests__/t2live.test.ts | 319 ++++++++++++++++ src/routes/corrections.ts | 4 +- src/routes/t2live.ts | 366 +++++++++++++++---- 8 files changed, 1218 insertions(+), 254 deletions(-) create mode 100644 src/routes/__tests__/t2live.test.ts diff --git a/docs/CHANGELOG-backend.md b/docs/CHANGELOG-backend.md index d688dd4..fe89a46 100644 --- a/docs/CHANGELOG-backend.md +++ b/docs/CHANGELOG-backend.md @@ -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=`, 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 diff --git a/src/lib/__tests__/geminiLive.test.ts b/src/lib/__tests__/geminiLive.test.ts index f6fb8ad..fc59f68 100644 --- a/src/lib/__tests__/geminiLive.test.ts +++ b/src/lib/__tests__/geminiLive.test.ts @@ -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, c’est 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, c’est pour quel quartier ?\nCandidat : Le centre-ville.", + ); + }); - gemini.emit('close') + it("ferme Gemini après onSessionEnd, sans fermer le client (réservé à l’appelant)", 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"); + }); +}); diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index 6514462..e941b82 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -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 = { 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 = { "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.", }; diff --git a/src/lib/geminiLive.ts b/src/lib/geminiLive.ts index 087934a..58e622f 100644 --- a/src/lib/geminiLive.ts +++ b/src/lib/geminiLive.ts @@ -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; + /** 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 | null = null; + let timeoutTimer: ReturnType | null = null; + + const clearTimers = () => { + if (warningTimer !== null) { + clearTimeout(warningTimer); + warningTimer = null; + } + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + }; + + const endSession = async () => { + if (sessionEnded) return; + sessionEnded = true; + clearTimers(); try { - clientWs.close(code, reason) + geminiWs.close(1000); } catch { /* ignore */ } + if (opts.onSessionEnd) { + try { + await opts.onSessionEnd(reconstructTranscript(transcriptEntries)); + } catch (err) { + console.error( + "[T2] onSessionEnd threw:", + err instanceof Error ? err.message : String(err), + ); + } + } + }; + + geminiWs.on("open", () => { + console.log("[T2] Gemini WS opened"); try { - geminiWs.close(code, reason) + geminiWs.send(buildSetupFrame(systemPrompt)); + console.log("[T2] Setup frame sent"); + + // Démarrer les timers une fois la session Gemini effectivement ouverte. + warningTimer = setTimeout(() => { + if (sessionEnded) return; + try { + clientWs.send( + JSON.stringify({ + type: "warning", + message: "30 secondes restantes", + }), + ); + } catch { + /* ignore */ + } + }, warningMs); + + timeoutTimer = setTimeout(() => { + void endSession(); + }, timeoutMs); + } catch { + try { + clientWs.close(4005, "GEMINI_CONFIG"); + } catch { + /* ignore */ + } + } + }); + + geminiWs.on("message", (data) => { + // Tentative d'extraction des transcripts — si JSON, on accumule ; + // dans tous les cas (JSON ou audio binaire), on forward au client. + const parsed = tryParseGeminiMessage(data); + if (parsed) { + if (parsed.inputText && parsed.inputText.length > 0) { + transcriptEntries.push({ + speaker: "candidat", + text: parsed.inputText, + }); + } + if (parsed.outputText && parsed.outputText.length > 0) { + transcriptEntries.push({ + speaker: "examinateur", + text: parsed.outputText, + }); + } + } + try { + clientWs.send(data); + } catch { + void endSession(); + } + }); + + clientWs.on("message", (data) => { + if (isEndSignal(data)) { + void endSession(); + return; + } + try { + geminiWs.send(data); + } catch { + void endSession(); + } + }); + + geminiWs.on("close", () => { + console.log("[T2] Gemini closed"); + if (!sessionEnded) { + clearTimers(); + try { + clientWs.close(4006, "GEMINI_DISCONNECTED"); + } catch { + /* ignore */ + } + } + }); + + clientWs.on("close", () => { + clearTimers(); + sessionEnded = true; + try { + geminiWs.close(1000); } catch { /* ignore */ } - } + }); - geminiWs.on('open', () => { - console.log('[T2] Gemini WS opened') - try { - geminiWs.send(buildSetupFrame()) - console.log('[T2] Setup frame sent') - } catch { - closeBoth(1011, 'SETUP_FAILED') + geminiWs.on("error", (err) => { + console.log("[T2] Gemini error:", (err as Error)?.message); + if (!sessionEnded) { + clearTimers(); + sessionEnded = true; + try { + clientWs.close(4006, "GEMINI_DISCONNECTED"); + } catch { + /* ignore */ + } } - }) + }); - geminiWs.on('message', (data) => { - console.log( - '[T2] Gemini message received, type:', - typeof data, - 'content:', - (data as { toString?: () => string })?.toString?.().slice(0, 500) - ) + clientWs.on("error", () => { + clearTimers(); + sessionEnded = true; try { - clientWs.send(data) + geminiWs.close(1011); } catch { - closeBoth(1011, 'CLIENT_SEND_FAILED') + /* ignore */ } - }) - - clientWs.on('message', (data) => { - try { - geminiWs.send(data) - } catch { - closeBoth(1011, 'GEMINI_SEND_FAILED') - } - }) - - geminiWs.on('close', (code, reason) => { - console.log('[T2] Gemini closed, code:', code, 'reason:', reason) - closeBoth(1000) - }) - clientWs.on('close', () => closeBoth(1000)) - - geminiWs.on('error', (err) => { - console.log('[T2] Gemini error:', (err as Error)?.message) - closeBoth(1011, 'GEMINI_ERROR') - }) - clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR')) + }); } diff --git a/src/routes/__tests__/correctionsEO.test.ts b/src/routes/__tests__/correctionsEO.test.ts index 6683561..48df391 100644 --- a/src/routes/__tests__/correctionsEO.test.ts +++ b/src/routes/__tests__/correctionsEO.test.ts @@ -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", }), }); diff --git a/src/routes/__tests__/t2live.test.ts b/src/routes/__tests__/t2live.test.ts new file mode 100644 index 0000000..fadc510 --- /dev/null +++ b/src/routes/__tests__/t2live.test.ts @@ -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( + "../../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"); + }); +}); diff --git a/src/routes/corrections.ts b/src/routes/corrections.ts index 38bf75c..145d25d 100644 --- a/src/routes/corrections.ts +++ b/src/routes/corrections.ts @@ -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, diff --git a/src/routes/t2live.ts b/src/routes/t2live.ts index 3226d8c..474b7a1 100644 --- a/src/routes/t2live.ts +++ b/src/routes/t2live.ts @@ -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 { + 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 { + 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 { + 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=` * - 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=` — 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' - } 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' - } + profile = auth.profile; + if (!sujetId) { + denyCode = 4004; + denyReason = "SUJET_NOT_FOUND"; + } else { + 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[0]) + ws.send(data as Parameters[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; }