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:
parent
28f8373f5d
commit
d89b0b1e89
8 changed files with 1218 additions and 254 deletions
|
|
@ -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
|
## [Unreleased] — 2026-04-26 — Sprint 5a — Backend billing cleanup
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,134 +1,287 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from "node:events";
|
||||||
import {
|
import {
|
||||||
openGeminiLiveSession,
|
openGeminiLiveSession,
|
||||||
T2_SYSTEM_PROMPT,
|
buildT2SystemPrompt,
|
||||||
type WebSocketLike,
|
type WebSocketLike,
|
||||||
} from '../geminiLive'
|
} from "../geminiLive";
|
||||||
|
|
||||||
class FakeWs extends EventEmitter implements WebSocketLike {
|
class FakeWs extends EventEmitter implements WebSocketLike {
|
||||||
public sent: unknown[] = []
|
public sent: unknown[] = [];
|
||||||
public closed = false
|
public closed = false;
|
||||||
public closeCode?: number
|
public closeCode?: number;
|
||||||
public closeReason?: string
|
public closeReason?: string;
|
||||||
|
|
||||||
send(data: unknown): void {
|
send(data: unknown): void {
|
||||||
this.sent.push(data)
|
this.sent.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(code?: number, reason?: string): void {
|
close(code?: number, reason?: string): void {
|
||||||
if (this.closed) return
|
if (this.closed) return;
|
||||||
this.closed = true
|
this.closed = true;
|
||||||
this.closeCode = code
|
this.closeCode = code;
|
||||||
this.closeReason = reason
|
this.closeReason = reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('openGeminiLiveSession', () => {
|
const SUJET_OPTS = {
|
||||||
let originalKey: string | undefined
|
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(() => {
|
beforeEach(() => {
|
||||||
originalKey = process.env.GEMINI_API_KEY
|
originalKey = process.env.GEMINI_API_KEY;
|
||||||
process.env.GEMINI_API_KEY = 'test-key'
|
process.env.GEMINI_API_KEY = "test-key";
|
||||||
})
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (originalKey === undefined) {
|
if (originalKey === undefined) {
|
||||||
delete process.env.GEMINI_API_KEY
|
delete process.env.GEMINI_API_KEY;
|
||||||
} else {
|
} 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", () => {
|
it("envoie le setup frame avec prompt dynamique + VAD + transcriptions", () => {
|
||||||
const client = new FakeWs()
|
const client = new FakeWs();
|
||||||
const gemini = new FakeWs()
|
const gemini = new FakeWs();
|
||||||
|
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
openGeminiLiveSession(client, {
|
||||||
gemini.emit('open')
|
...SUJET_OPTS,
|
||||||
|
geminiFactory: () => gemini,
|
||||||
|
});
|
||||||
|
gemini.emit("open");
|
||||||
|
|
||||||
expect(gemini.sent).toHaveLength(1)
|
expect(gemini.sent).toHaveLength(1);
|
||||||
const setup = JSON.parse(gemini.sent[0] as string)
|
const setup = JSON.parse(gemini.sent[0] as string);
|
||||||
expect(setup.setup.model).toMatch(/gemini/)
|
expect(setup.setup.model).toMatch(/gemini/);
|
||||||
expect(setup.setup.systemInstruction.parts[0].text).toBe(T2_SYSTEM_PROMPT)
|
expect(setup.setup.systemInstruction.parts[0].text).toContain(
|
||||||
expect(setup.setup.generationConfig.responseModalities).toContain('AUDIO')
|
"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', () => {
|
it("forwarde un chunk audio client (Buffer) vers Gemini", () => {
|
||||||
const client = new FakeWs()
|
const client = new FakeWs();
|
||||||
const gemini = new FakeWs()
|
const gemini = new FakeWs();
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
openGeminiLiveSession(client, {
|
||||||
gemini.emit('open')
|
...SUJET_OPTS,
|
||||||
|
geminiFactory: () => gemini,
|
||||||
|
});
|
||||||
|
gemini.emit("open");
|
||||||
|
|
||||||
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04])
|
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04]);
|
||||||
client.emit('message', audioChunk)
|
client.emit("message", audioChunk);
|
||||||
|
|
||||||
// [0] = setup frame, [1] = audio forwarde
|
// [0] = setup, [1] = chunk audio
|
||||||
expect(gemini.sent).toHaveLength(2)
|
expect(gemini.sent).toHaveLength(2);
|
||||||
expect(gemini.sent[1]).toBe(audioChunk)
|
expect(gemini.sent[1]).toBe(audioChunk);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('forwarde un message Gemini vers le client', () => {
|
it("forwarde un chunk audio Gemini (Buffer non-JSON) vers le client sans accumuler de transcript", async () => {
|
||||||
const client = new FakeWs()
|
const client = new FakeWs();
|
||||||
const gemini = new FakeWs()
|
const gemini = new FakeWs();
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
const onSessionEnd = vi.fn();
|
||||||
gemini.emit('open')
|
openGeminiLiveSession(client, {
|
||||||
|
...SUJET_OPTS,
|
||||||
|
geminiFactory: () => gemini,
|
||||||
|
onSessionEnd,
|
||||||
|
});
|
||||||
|
gemini.emit("open");
|
||||||
|
|
||||||
const examinerAudio = Buffer.from([0x10, 0x20])
|
const examinerAudio = Buffer.from([0x10, 0x20, 0x30]);
|
||||||
gemini.emit('message', examinerAudio)
|
gemini.emit("message", examinerAudio);
|
||||||
|
expect(client.sent).toHaveLength(1);
|
||||||
|
expect(client.sent[0]).toBe(examinerAudio);
|
||||||
|
|
||||||
expect(client.sent).toHaveLength(1)
|
// Fin de session via signal client → transcript vide
|
||||||
expect(client.sent[0]).toBe(examinerAudio)
|
client.emit("message", JSON.stringify({ type: "end" }));
|
||||||
})
|
await vi.runAllTimersAsync();
|
||||||
|
expect(onSessionEnd).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
it('fermeture client → ferme Gemini avec code 1000', () => {
|
it("accumule inputTranscription et outputTranscription depuis Gemini", async () => {
|
||||||
const client = new FakeWs()
|
const client = new FakeWs();
|
||||||
const gemini = new FakeWs()
|
const gemini = new FakeWs();
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
const onSessionEnd = vi.fn();
|
||||||
gemini.emit('open')
|
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)
|
client.emit("message", JSON.stringify({ type: "end" }));
|
||||||
expect(gemini.closeCode).toBe(1000)
|
await vi.runAllTimersAsync();
|
||||||
})
|
|
||||||
|
|
||||||
it('fermeture Gemini → ferme client avec code 1000', () => {
|
expect(onSessionEnd).toHaveBeenCalledTimes(1);
|
||||||
const client = new FakeWs()
|
const transcript = onSessionEnd.mock.calls[0][0] as string;
|
||||||
const gemini = new FakeWs()
|
expect(transcript).toBe(
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
"Candidat : Bonjour, je voudrais louer.\nExaminateur : Bonjour, c’est pour quel quartier ?\nCandidat : Le centre-ville.",
|
||||||
gemini.emit('open')
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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)
|
client.emit("message", JSON.stringify({ type: "end" }));
|
||||||
expect(client.closeCode).toBe(1000)
|
await vi.runAllTimersAsync();
|
||||||
})
|
|
||||||
|
|
||||||
it('erreur Gemini → ferme client avec code 1011 GEMINI_ERROR', () => {
|
expect(gemini.closed).toBe(true);
|
||||||
const client = new FakeWs()
|
expect(gemini.closeCode).toBe(1000);
|
||||||
const gemini = new FakeWs()
|
expect(client.closed).toBe(false);
|
||||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
});
|
||||||
gemini.emit('open')
|
|
||||||
|
|
||||||
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)
|
// Avancer à 180 s → warning au client
|
||||||
expect(client.closeCode).toBe(1011)
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
expect(client.closeReason).toBe('GEMINI_ERROR')
|
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", () => {
|
// Avancer à 210 s total → timeout déclenche endSession
|
||||||
delete process.env.GEMINI_API_KEY
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
const client = new FakeWs()
|
expect(onSessionEnd).toHaveBeenCalledTimes(1);
|
||||||
const factory = vi.fn(() => new FakeWs())
|
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()
|
client.emit("message", JSON.stringify({ type: "end" }));
|
||||||
expect(client.closed).toBe(true)
|
client.emit("message", JSON.stringify({ type: "end" }));
|
||||||
expect(client.closeCode).toBe(1011)
|
await vi.runAllTimersAsync();
|
||||||
expect(client.closeReason).toBe('CONFIG_ERROR')
|
|
||||||
})
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
||||||
// ── Types — Sprint 3.6a ──────────────────────────────────────────────────
|
// ── Types — Sprint 3.6a ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TacheEE = "EE_T1" | "EE_T2" | "EE_T3";
|
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 TacheCorrection = TacheEE | TacheEO;
|
||||||
export type NclcCible = 9 | 10;
|
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_T2: { min: 120, max: 150 },
|
||||||
EE_T3: { min: 120, max: 180 },
|
EE_T3: { min: 120, max: 180 },
|
||||||
EO_T1: { min: 200, max: 300 },
|
EO_T1: { min: 200, max: 300 },
|
||||||
|
EO_T2: { min: 250, max: 450 },
|
||||||
EO_T3: { min: 450, max: 620 },
|
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.",
|
"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:
|
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é.",
|
"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:
|
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.",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,37 @@
|
||||||
import { WebSocket as NodeWebSocket } from 'ws'
|
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.`
|
|
||||||
|
|
||||||
export const GEMINI_LIVE_URL =
|
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 :
|
* 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
|
* - les fakes basés sur EventEmitter dans les tests
|
||||||
*/
|
*/
|
||||||
export interface WebSocketLike {
|
export interface WebSocketLike {
|
||||||
send(data: unknown): void
|
send(data: unknown): void;
|
||||||
close(code?: number, reason?: string): void
|
close(code?: number, reason?: string): void;
|
||||||
on(event: 'message', listener: (data: unknown) => void): void
|
on(event: "message", listener: (data: unknown) => void): void;
|
||||||
on(event: 'close', listener: (code?: number, reason?: unknown) => void): void
|
on(event: "close", listener: (code?: number, reason?: unknown) => void): void;
|
||||||
on(event: 'error', listener: (err: unknown) => void): void
|
on(event: "error", listener: (err: unknown) => void): void;
|
||||||
on(event: 'open', listener: () => void): void
|
on(event: "open", listener: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenGeminiLiveSessionOptions {
|
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. */
|
/** 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). */
|
/** 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({
|
return JSON.stringify({
|
||||||
setup: {
|
setup: {
|
||||||
model: GEMINI_LIVE_MODEL,
|
model: GEMINI_LIVE_MODEL,
|
||||||
systemInstruction: {
|
systemInstruction: {
|
||||||
parts: [{ text: T2_SYSTEM_PROMPT }],
|
parts: [{ text: systemPrompt }],
|
||||||
},
|
},
|
||||||
generationConfig: {
|
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
|
* Ouvre une session Gemini Live et proxifie les messages
|
||||||
* dans les deux sens entre le client (navigateur) et Gemini.
|
* 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.
|
* - Forward transparent des frames audio dans les deux directions.
|
||||||
* - Fermeture coordonnée : close d'un côté → close de l'autre.
|
* - Accumule les transcripts (input = candidat, output = examinateur IA).
|
||||||
* - Erreur Gemini → close client avec code 1011.
|
* - Détecte signal client `{type:'end'}` → déclenche fin de session.
|
||||||
* - Si GEMINI_API_KEY est absente : close client immédiat avec 1011.
|
* - 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(
|
export function openGeminiLiveSession(
|
||||||
clientWs: WebSocketLike,
|
clientWs: WebSocketLike,
|
||||||
opts: OpenGeminiLiveSessionOptions = {}
|
opts: OpenGeminiLiveSessionOptions,
|
||||||
): void {
|
): void {
|
||||||
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY
|
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
clientWs.close(1011, 'CONFIG_ERROR')
|
clientWs.close(4005, "GEMINI_CONFIG");
|
||||||
return
|
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 =
|
const factory =
|
||||||
opts.geminiFactory ??
|
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 transcriptEntries: TranscriptEntry[] = [];
|
||||||
const closeBoth = (code = 1000, reason = '') => {
|
let sessionEnded = false;
|
||||||
if (closed) return
|
let warningTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
closed = true
|
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 {
|
try {
|
||||||
clientWs.close(code, reason)
|
geminiWs.close(1000);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 {
|
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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
geminiWs.on('open', () => {
|
geminiWs.on("error", (err) => {
|
||||||
console.log('[T2] Gemini WS opened')
|
console.log("[T2] Gemini error:", (err as Error)?.message);
|
||||||
try {
|
if (!sessionEnded) {
|
||||||
geminiWs.send(buildSetupFrame())
|
clearTimers();
|
||||||
console.log('[T2] Setup frame sent')
|
sessionEnded = true;
|
||||||
} catch {
|
try {
|
||||||
closeBoth(1011, 'SETUP_FAILED')
|
clientWs.close(4006, "GEMINI_DISCONNECTED");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
geminiWs.on('message', (data) => {
|
clientWs.on("error", () => {
|
||||||
console.log(
|
clearTimers();
|
||||||
'[T2] Gemini message received, type:',
|
sessionEnded = true;
|
||||||
typeof data,
|
|
||||||
'content:',
|
|
||||||
(data as { toString?: () => string })?.toString?.().slice(0, 500)
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
clientWs.send(data)
|
geminiWs.close(1011);
|
||||||
} catch {
|
} 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'))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,14 @@ describe("POST /corrections/eo — Sprint 4a", () => {
|
||||||
expect(body.code).toBe("VALIDATION_ERROR");
|
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 app = buildApp();
|
||||||
const res = await app.request("/corrections/eo", {
|
const res = await app.request("/corrections/eo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: JSON_HEADERS,
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
simulationId: "s1",
|
simulationId: "s1",
|
||||||
tache: "EO_T2",
|
tache: "EE_T1",
|
||||||
transcript: "t",
|
transcript: "t",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
319
src/routes/__tests__/t2live.test.ts
Normal file
319
src/routes/__tests__/t2live.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,7 +4,7 @@ import type { AppVariables } from "../middleware/auth.js";
|
||||||
import * as correctionController from "../controllers/correctionController.js";
|
import * as correctionController from "../controllers/correctionController.js";
|
||||||
|
|
||||||
const VALID_TACHES_EE = ["EE_T1", "EE_T2", "EE_T3"];
|
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 }>();
|
const corrections = new Hono<{ Variables: AppVariables }>();
|
||||||
|
|
||||||
|
|
@ -200,7 +200,7 @@ corrections.post("/eo", authMiddleware, async (c) => {
|
||||||
const result = await correctionController.correctEO(
|
const result = await correctionController.correctEO(
|
||||||
{
|
{
|
||||||
simulationId: body.simulationId,
|
simulationId: body.simulationId,
|
||||||
tache: body.tache as "EO_T1" | "EO_T3",
|
tache: body.tache as "EO_T1" | "EO_T2" | "EO_T3",
|
||||||
nclcCible,
|
nclcCible,
|
||||||
transcript: hasTranscript ? (body.transcript as string) : undefined,
|
transcript: hasTranscript ? (body.transcript as string) : undefined,
|
||||||
audioBase64: hasAudio ? (body.audioBase64 as string) : undefined,
|
audioBase64: hasAudio ? (body.audioBase64 as string) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,343 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from "hono";
|
||||||
import type { UpgradeWebSocket } from 'hono/ws'
|
import type { UpgradeWebSocket } from "hono/ws";
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from "node:events";
|
||||||
import { supabase } from '../lib/supabase.js'
|
import { supabase } from "../lib/supabase.js";
|
||||||
import { checkFeatureAccess } from '../lib/access.js'
|
import { checkFeatureAccess } from "../lib/access.js";
|
||||||
import type { Plan } 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 {
|
import {
|
||||||
openGeminiLiveSession,
|
openGeminiLiveSession,
|
||||||
type WebSocketLike,
|
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`.
|
* Crée le router pour `WS /t2/live`.
|
||||||
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
|
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
|
||||||
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
|
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
|
||||||
* - Refus auth → close 4001, refus plan → close 4003
|
* - Sujet : id passé en query param `?sujet=<uuid>` — table `sujets` (mode='EO', tache=2)
|
||||||
* - OK → openGeminiLiveSession (proxy vers Gemini Live)
|
* - Refus auth → 4001, refus plan → 4003, sujet introuvable → 4004
|
||||||
|
* - OK → openGeminiLiveSession → onSessionEnd : correction EO + persistance + report
|
||||||
*/
|
*/
|
||||||
export default function createT2LiveRoutes(
|
export default function createT2LiveRoutes(
|
||||||
upgradeWebSocket: UpgradeWebSocket
|
upgradeWebSocket: UpgradeWebSocket,
|
||||||
|
opts: CreateT2LiveRoutesOptions = {},
|
||||||
) {
|
) {
|
||||||
const app = new Hono()
|
const app = new Hono();
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/live',
|
"/live",
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
const token = c.req.query('token')
|
const token = c.req.query("token");
|
||||||
let denyCode: number | null = null
|
const sujetId = c.req.query("sujet");
|
||||||
let denyReason = ''
|
|
||||||
|
|
||||||
if (!token) {
|
let denyCode: number | null = null;
|
||||||
denyCode = 4001
|
let denyReason = "";
|
||||||
denyReason = 'AUTH_REQUIRED'
|
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 {
|
} else {
|
||||||
try {
|
profile = auth.profile;
|
||||||
const {
|
if (!sujetId) {
|
||||||
data: { user },
|
denyCode = 4004;
|
||||||
error: authError,
|
denyReason = "SUJET_NOT_FOUND";
|
||||||
} = await supabase.auth.getUser(token)
|
} else {
|
||||||
|
sujet = await fetchSujetT2(sujetId);
|
||||||
if (authError || !user) {
|
if (!sujet) {
|
||||||
denyCode = 4001
|
denyCode = 4004;
|
||||||
denyReason = 'AUTH_REQUIRED'
|
denyReason = "SUJET_NOT_FOUND";
|
||||||
} else {
|
} else if (!sujet.role || !sujet.contexte) {
|
||||||
const { data: profile, error: profileError } = await supabase
|
// Sécurité : un sujet T2 sans role/contexte ne peut pas alimenter le prompt.
|
||||||
.from('profiles')
|
denyCode = 4004;
|
||||||
.select('plan')
|
denyReason = "SUJET_NOT_FOUND";
|
||||||
.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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
denyCode = 4001
|
|
||||||
denyReason = 'AUTH_REQUIRED'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
|
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
|
||||||
const adapter = new EventEmitter() as EventEmitter & WebSocketLike
|
const adapter = new EventEmitter() as EventEmitter & WebSocketLike;
|
||||||
adapter.send = () => {}
|
adapter.send = () => {};
|
||||||
adapter.close = () => {}
|
adapter.close = () => {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt, ws) {
|
||||||
adapter.send = (data: unknown) =>
|
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) =>
|
adapter.close = (code?: number, reason?: string) =>
|
||||||
ws.close(code, reason)
|
ws.close(code, reason);
|
||||||
|
|
||||||
if (denyCode !== null) {
|
if (denyCode !== null) {
|
||||||
ws.send(JSON.stringify({ error: true, code: denyReason }))
|
try {
|
||||||
setTimeout(() => ws.close(denyCode!, denyReason), 100)
|
ws.send(JSON.stringify({ error: true, code: denyReason }));
|
||||||
return
|
} 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) {
|
onMessage(evt) {
|
||||||
adapter.emit('message', evt.data)
|
adapter.emit("message", evt.data);
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
adapter.emit('close')
|
adapter.emit("close");
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
adapter.emit('error', new Error('CLIENT_ERROR'))
|
adapter.emit("error", new Error("CLIENT_ERROR"));
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
return app
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue