fix(t1-live): remove questionnaire dependency from T1 Live session
Some checks are pending
CI / quality (push) Waiting to run

- buildT1SystemPrompt() now static (no reponses param); examiner
  formulates questions from what it hears in real-time audio stream
- Remove context guard + close 4004 CONTEXT_MISSING; Gemini session
  opens immediately after auth (aligns with T2 flow)
- Remove parseT1Context, validateReponses import from route
- Unknown WS message types silently ignored (debug log + return)
- Update Prompt_t1live.md and CHANGELOG-backend
- Tests: 309/309 green
This commit is contained in:
Hermann_Kitio 2026-06-30 02:57:17 +03:00
parent 01707c0b74
commit 74770b6402
6 changed files with 105 additions and 209 deletions

View file

@ -6,6 +6,28 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
--- ---
## [Unreleased] — 2026-06-30 — Sprint 7a-patch — T1 Live : suppression de la dépendance au questionnaire
### Changed
- `buildT1SystemPrompt` (`geminiLiveT1.ts`) — prompt système T1 désormais **statique** (signature sans argument). La section « CONTEXTE DU CANDIDAT » (5 variables `${reponses.*}`) est retirée et remplacée par une consigne d'écoute : « Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire. » Les 8 règles sont conservées (silence par défaut, relance sur signal, ton bienveillant, jamais d'évaluation/hors-rôle, règle 5 « DOIS poser des questions »). Règle 4 : retrait de « ou à son contexte ci-dessus ».
- `openGeminiLiveT1Session` (`geminiLiveT1.ts`) — option `reponses` retirée de `OpenGeminiLiveT1SessionOptions` et de la signature. Le handler de message client ignore désormais explicitement (log debug + return) tout message non reconnu (ni `audio` ni `end`) — un éventuel `{type:'context'}` d'un ancien front ne provoque ni crash ni close.
- `WS /t1/live` (`t1live.ts`) — la session Gemini s'ouvre **immédiatement après l'auth** (calque T2), dans `onOpen`. Le flux client devient `{type:'audio', data}` puis `{type:'end'}`.
- `docs/Prompt_t1live.md` — §1 (table « Subject-based »), §2 (règle 3), §3 (prompt statique, retrait des variables et de la section « Variables à substituer »), §4.1 (retrait du message de contexte), §4.3 (retrait du close 4004).
- Tests `geminiLiveT1.test.ts` / `t1live.test.ts` — appels `buildT1SystemPrompt()` sans argument, retrait de `reponses` des appels de session ; suppression du test d'intégration des réponses (remplacé par un test « écoute / plus de CONTEXTE DU CANDIDAT ») et du bloc `parseT1Context` ; tests interruption, flush terminal, timeout et correction inchangés.
### Removed
- Message client `{type:'context', reponses}` (1er message obligatoire) et close **4004 `CONTEXT_MISSING`** — la route ne lit plus de contexte.
- Fonction `parseT1Context` (`t1live.ts`) et ses imports `validateReponses` / `PresentationReponses`.
### Notes
- Tests backend : 309/309 verts. `tsc --noEmit` OK.
- **Suivi frontend (hors scope)** : le frontend Sprint 7b (non commité) envoie encore `{type:'context'}` et possède un `QuestionnaireT1Page` Live ; ce message est désormais inoffensif côté backend (ignoré). Le questionnaire Live devient sémantiquement obsolète — à retirer dans une session frontend dédiée.
---
## [Unreleased] — 2026-06-28 — Sprint 6d — T2 Live : durcissement prompt + VAD + cleanup SDK ## [Unreleased] — 2026-06-28 — Sprint 6d — T2 Live : durcissement prompt + VAD + cleanup SDK
### Changed ### Changed

View file

@ -31,12 +31,12 @@ d'immigration au Canada) sous forme de **monologue**, et l'examinateur le
**Différence structurelle avec le T2 :** **Différence structurelle avec le T2 :**
| Axe | T1 (entretien dirigé) | T2 (interaction de service) | | Axe | T1 (entretien dirigé) | T2 (interaction de service) |
| ----------------- | -------------------------------- | ---------------------------- | | ----------------- | ------------------------------ | ---------------------------- |
| Qui mène | L'examinateur relance | Le candidat mène | | Qui mène | L'examinateur relance | Le candidat mène |
| Questions de l'IA | **Obligatoires** (relances) | **Interdites** (rôle passif) | | Questions de l'IA | **Obligatoires** (relances) | **Interdites** (rôle passif) |
| Forme candidat | Monologue + relances | Dialogue | | Forme candidat | Monologue + relances | Dialogue |
| Subject-based | **Non** (questionnaire candidat) | Oui (table `sujets`) | | Subject-based | **Non** (écoute en temps réel) | Oui (table `sujets`) |
--- ---
@ -53,7 +53,7 @@ L'IA joue un **examinateur bienveillant** du TCF Canada. Son comportement :
interne. Le backend ne lit PAS la transcription partielle pour décider interne. Le backend ne lit PAS la transcription partielle pour décider
(Modèle 1 acté — cf. ROADMAP / `geminiLiveT1.ts`). (Modèle 1 acté — cf. ROADMAP / `geminiLiveT1.ts`).
3. **Relance courte et unique.** Une seule question de 10 à 20 mots, liée à ce 3. **Relance courte et unique.** Une seule question de 10 à 20 mots, liée à ce
que le candidat vient de dire ou à son contexte. Jamais d'enchaînement. que le candidat vient de dire. Jamais d'enchaînement.
4. **Ton bienveillant et professionnel**, français B2-C1. 4. **Ton bienveillant et professionnel**, français B2-C1.
5. **N'évalue jamais** le candidat, ne corrige pas ses erreurs, ne commente pas 5. **N'évalue jamais** le candidat, ne corrige pas ses erreurs, ne commente pas
sa langue. sa langue.
@ -63,25 +63,21 @@ L'IA joue un **examinateur bienveillant** du TCF Canada. Son comportement :
## 3. Prompt système (source : `buildT1SystemPrompt`) ## 3. Prompt système (source : `buildT1SystemPrompt`)
Les variables `${...}` sont substituées dynamiquement depuis les réponses du Le prompt est **statique** : aucune variable substituée. L'examinateur formule
**questionnaire candidat** (`PresentationReponses`) — il n'existe pas de sujet T1 ses relances à partir de ce qu'il **entend en temps réel** (son contexte audio
en base (T1 EO n'est PAS subject-based). interne) — il n'existe ni sujet T1 en base, ni questionnaire pré-rempli (T1 EO
n'est PAS subject-based).
``` ```
RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada. RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) : Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
- Identité : ${reponses.prenom_age_ville}
- Formation / métier : ${reponses.formation_metier}
- Situation familiale : ${reponses.situation_familiale}
- Loisirs : ${reponses.loisirs}
- Projet Canada : ${reponses.motivation_canada}
RÈGLES : RÈGLES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel. 1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative. 2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question. 3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire ou à son contexte ci-dessus. 4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire.
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement. 5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole. 6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance. 7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
@ -91,13 +87,6 @@ RÈGLES :
> **⚠ Spécificité T1 — règle 5 :** elle est l'**exact inverse** de la règle 7 du > **⚠ Spécificité T1 — règle 5 :** elle est l'**exact inverse** de la règle 7 du
> T2. Toute fusion des deux prompts est interdite (TD-22 / TD-23). > T2. Toute fusion des deux prompts est interdite (TD-22 / TD-23).
**Variables à substituer dynamiquement** (depuis le questionnaire candidat, pas
d'un sujet en base) :
- `prenom_age_ville`, `formation_metier`, `situation_familiale`, `loisirs`,
`motivation_canada` — validés par `validateReponses`
(`presentationController.ts`).
--- ---
## 4. Contrat WebSocket T1 (figé — la suite Sprint 7b en dépend) ## 4. Contrat WebSocket T1 (figé — la suite Sprint 7b en dépend)
@ -106,13 +95,17 @@ Route : **`WS /t1/live?token=<jwt>`**
Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise
`authenticate` de `t2live.ts` — cf. dette de nommage TD-24). `authenticate` de `t2live.ts` — cf. dette de nommage TD-24).
La session Gemini s'ouvre **immédiatement après l'auth** (pas de message de
contexte ni de questionnaire). Le client envoie directement son audio. Tout
message non reconnu (ni `audio` ni `end`) est **ignoré silencieusement** (log
debug + return) — jamais de close.
### 4.1 Client → Backend ### 4.1 Client → Backend
| Message | Forme | Effet | | Message | Forme | Effet |
| ---------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Contexte (1er message obligatoire) | `{type:'context', reponses}` | Validé par `validateReponses` ; démarre la session Gemini. Absent/invalide → close `4004`. | | Audio candidat | `{type:'audio', data}` (PCM 16 kHz base64) | Relayé à Gemini tant qu'un tour candidat est ouvert et qu'aucune interruption n'est en cours. |
| Audio candidat | `{type:'audio', data}` (PCM 16 kHz base64) | Relayé à Gemini tant qu'un tour candidat est ouvert et qu'aucune interruption n'est en cours. | | Fin de session | `{type:'end'}` | Déclenche `endSession()` (flush terminal + correction). |
| Fin de session | `{type:'end'}` | Déclenche `endSession()` (flush terminal + correction). |
### 4.2 Backend → Client ### 4.2 Backend → Client
@ -127,15 +120,14 @@ Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise
### 4.3 Codes de fermeture WebSocket ### 4.3 Codes de fermeture WebSocket
| Close code | Cause | Origine | | Close code | Cause | Origine |
| ---------- | -------------------------------------------------------- | ------------------------- | | ---------- | ------------------------------------------------------- | ------------------------- |
| 1000 | Fin normale + rapport prêt (ou transcript vide) | `runT1LiveCorrection` | | 1000 | Fin normale + rapport prêt (ou transcript vide) | `runT1LiveCorrection` |
| 1011 | `PERSISTENCE_FAILED` / `CORRECTION_FAILED` | `runT1LiveCorrection` | | 1011 | `PERSISTENCE_FAILED` / `CORRECTION_FAILED` | `runT1LiveCorrection` |
| 4001 | `AUTH_REQUIRED` (JWT absent/invalide) | `authenticate` | | 4001 | `AUTH_REQUIRED` (JWT absent/invalide) | `authenticate` |
| 4003 | `PLAN_INSUFFICIENT` (pas Premium) | `authenticate` | | 4003 | `PLAN_INSUFFICIENT` (pas Premium) | `authenticate` |
| 4004 | `CONTEXT_MISSING` (1er message contexte absent/invalide) | route `t1live.ts` | | 4005 | `GEMINI_CONFIG` (clé API Gemini manquante côté serveur) | `openGeminiLiveT1Session` |
| 4005 | `GEMINI_CONFIG` (clé API Gemini manquante côté serveur) | `openGeminiLiveT1Session` | | 4006 | `GEMINI_DISCONNECTED` (WS Gemini fermé/erreur) | `openGeminiLiveT1Session` |
| 4006 | `GEMINI_DISCONNECTED` (WS Gemini fermé/erreur) | `openGeminiLiveT1Session` |
--- ---

View file

@ -10,7 +10,6 @@ import {
T1_INTERRUPTION_MIN_SPACING_MS, T1_INTERRUPTION_MIN_SPACING_MS,
} from "../geminiLiveT1"; } from "../geminiLiveT1";
import type { WebSocketLike } from "../geminiLive"; import type { WebSocketLike } from "../geminiLive";
import type { PresentationReponses } from "../../controllers/presentationController";
class FakeWs extends EventEmitter implements WebSocketLike { class FakeWs extends EventEmitter implements WebSocketLike {
public sent: unknown[] = []; public sent: unknown[] = [];
@ -50,33 +49,23 @@ function clientSignals(client: FakeWs): { type: string }[] {
.filter((o): o is { type: string } => typeof o.type === "string"); .filter((o): o is { type: string } => typeof o.type === "string");
} }
const REPONSES: PresentationReponses = {
prenom_age_ville: "Hermann, 35 ans, Lyon",
formation_metier: "ingénieur en informatique",
situation_familiale: "marié, deux enfants",
loisirs: "la randonnée et la photographie",
motivation_canada: "de meilleures opportunités professionnelles",
};
describe("buildT1SystemPrompt", () => { describe("buildT1SystemPrompt", () => {
it("définit un examinateur qui relance le candidat par une question", () => { it("définit un examinateur qui relance le candidat par une question", () => {
const prompt = buildT1SystemPrompt({ reponses: REPONSES }); const prompt = buildT1SystemPrompt();
expect(prompt).toContain("examinateur"); expect(prompt).toContain("examinateur");
expect(prompt.toLowerCase()).toContain("relanc"); expect(prompt.toLowerCase()).toContain("relanc");
expect(prompt.toLowerCase()).toContain("question"); expect(prompt.toLowerCase()).toContain("question");
}); });
it("intègre les réponses du questionnaire candidat", () => { it("instruit l'examinateur d'écouter le candidat (plus de questionnaire pré-rempli)", () => {
const prompt = buildT1SystemPrompt({ reponses: REPONSES }); const prompt = buildT1SystemPrompt();
expect(prompt).toContain("Hermann, 35 ans, Lyon"); expect(prompt).toContain("Écoute attentivement");
expect(prompt).toContain("ingénieur en informatique"); // Plus aucune section « CONTEXTE DU CANDIDAT » ni variable substituée.
expect(prompt).toContain("marié, deux enfants"); expect(prompt).not.toContain("CONTEXTE DU CANDIDAT");
expect(prompt).toContain("la randonnée et la photographie");
expect(prompt).toContain("de meilleures opportunités professionnelles");
}); });
it("AUTORISE les questions — ne propage PAS la règle 7 du T2", () => { it("AUTORISE les questions — ne propage PAS la règle 7 du T2", () => {
const prompt = buildT1SystemPrompt({ reponses: REPONSES }); const prompt = buildT1SystemPrompt();
const upper = prompt.toUpperCase(); const upper = prompt.toUpperCase();
// La règle 7 T2 interdit les questions et bannit le point d'interrogation. // La règle 7 T2 interdit les questions et bannit le point d'interrogation.
expect(upper).not.toContain("INTERDICTION DE POSER DES QUESTIONS"); expect(upper).not.toContain("INTERDICTION DE POSER DES QUESTIONS");
@ -151,7 +140,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
const client = new FakeWs(); const client = new FakeWs();
const gemini = new FakeWs(); const gemini = new FakeWs();
openGeminiLiveT1Session(client, { openGeminiLiveT1Session(client, {
reponses: REPONSES,
clientFactory: () => gemini, clientFactory: () => gemini,
random: seqRandom([0.1]), random: seqRandom([0.1]),
}); });
@ -171,7 +159,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
const gemini = new FakeWs(); const gemini = new FakeWs();
// drawCount(0.5)=1 ; planInstants(1, 0)=START_MS. // drawCount(0.5)=1 ; planInstants(1, 0)=START_MS.
openGeminiLiveT1Session(client, { openGeminiLiveT1Session(client, {
reponses: REPONSES,
clientFactory: () => gemini, clientFactory: () => gemini,
random: seqRandom([0.5, 0]), random: seqRandom([0.5, 0]),
}); });
@ -223,7 +210,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
// count=0 : aucune interruption programmée, on teste juste le flush terminal. // count=0 : aucune interruption programmée, on teste juste le flush terminal.
openGeminiLiveT1Session(client, { openGeminiLiveT1Session(client, {
reponses: REPONSES,
clientFactory: () => gemini, clientFactory: () => gemini,
random: seqRandom([0.1]), random: seqRandom([0.1]),
onSessionEnd, onSessionEnd,
@ -283,7 +269,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
const gemini = new FakeWs(); const gemini = new FakeWs();
const onSessionEnd = vi.fn(); const onSessionEnd = vi.fn();
openGeminiLiveT1Session(client, { openGeminiLiveT1Session(client, {
reponses: REPONSES,
clientFactory: () => gemini, clientFactory: () => gemini,
random: seqRandom([0.1]), random: seqRandom([0.1]),
onSessionEnd, onSessionEnd,
@ -304,7 +289,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
const factory = vi.fn(() => new FakeWs()); const factory = vi.fn(() => new FakeWs());
openGeminiLiveT1Session(client, { openGeminiLiveT1Session(client, {
reponses: REPONSES,
clientFactory: factory, clientFactory: factory,
}); });

View file

@ -24,7 +24,6 @@
*/ */
import { WebSocket as NodeWebSocket } from "ws"; import { WebSocket as NodeWebSocket } from "ws";
import type { PresentationReponses } from "../controllers/presentationController.js";
import { import {
GEMINI_LIVE_URL, GEMINI_LIVE_URL,
buildSetupFrame, buildSetupFrame,
@ -37,33 +36,28 @@ import {
} from "./geminiLive.js"; } from "./geminiLive.js";
/** /**
* Construit le prompt système T1 Live à partir des réponses du questionnaire * Construit le prompt système T1 Live.
* candidat (transmises dynamiquement il n'existe pas de sujet T1 en base). *
* L'examinateur formule ses relances à partir de ce qu'il ENTEND en temps réel
* (son contexte audio interne) il n'existe pas de sujet T1 en base et le flux
* ne dépend plus d'un questionnaire pré-rempli.
* *
* Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut * Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut
* et ne prend la parole QUE lorsque le backend le lui signale (injection * et ne prend la parole QUE lorsque le backend le lui signale (injection
* `clientContent` au moment choisi par l'horloge probabiliste). C'est le * `clientContent` au moment choisi par l'horloge probabiliste). C'est le
* BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une * BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une
* relance courte à partir de son contexte audio interne. * relance courte à partir de ce que le candidat vient de dire.
*/ */
export function buildT1SystemPrompt(input: { export function buildT1SystemPrompt(): string {
reponses: PresentationReponses;
}): string {
const { reponses } = input;
return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada. return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) : Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
- Identité : ${reponses.prenom_age_ville}
- Formation / métier : ${reponses.formation_metier}
- Situation familiale : ${reponses.situation_familiale}
- Loisirs : ${reponses.loisirs}
- Projet Canada : ${reponses.motivation_canada}
RÈGLES : RÈGLES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel. 1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative. 2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question. 3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire ou à son contexte ci-dessus. 4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire.
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement. 5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole. 6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance. 7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
@ -160,8 +154,6 @@ export function planT1InterruptionInstants(
// ── Options de session ─────────────────────────────────────────────────────── // ── Options de session ───────────────────────────────────────────────────────
export interface OpenGeminiLiveT1SessionOptions { export interface OpenGeminiLiveT1SessionOptions {
/** Réponses du questionnaire candidat (contexte du prompt T1). */
reponses: PresentationReponses;
/** Callback de fin de session avec le transcript reconstruit. */ /** Callback de fin de session avec le transcript reconstruit. */
onSessionEnd?: (transcript: string) => void | Promise<void>; onSessionEnd?: (transcript: string) => void | Promise<void>;
/** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */ /** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */
@ -208,7 +200,7 @@ export function openGeminiLiveT1Session(
const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS; const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS;
const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS; const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS;
const random = opts.random ?? Math.random; const random = opts.random ?? Math.random;
const systemPrompt = buildT1SystemPrompt({ reponses: opts.reponses }); const systemPrompt = buildT1SystemPrompt();
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const factory = const factory =
@ -440,12 +432,14 @@ export function openGeminiLiveT1Session(
return; return;
} }
const audioBase64 = parseAudioChunk(data); const audioBase64 = parseAudioChunk(data);
if ( if (audioBase64 === null) {
audioBase64 !== null && // Message non reconnu (ni audio ni end). Notamment un éventuel
!sessionEnded && // {type:'context'} envoyé par un ancien front : ignoré silencieusement —
candidateTurnOpen && // jamais de crash ni de close. Cf. point de vigilance Patch 7a.
!injecting console.debug("[T1] ignored non-audio client message");
) { return;
}
if (!sessionEnded && candidateTurnOpen && !injecting) {
geminiSend( geminiSend(
JSON.stringify({ JSON.stringify({
realtimeInput: { realtimeInput: {
@ -454,7 +448,6 @@ export function openGeminiLiveT1Session(
}), }),
); );
} }
// Tout autre message client est ignoré.
}); });
clientWs.on("close", () => { clientWs.on("close", () => {

View file

@ -33,7 +33,7 @@ vi.mock("../../lib/geminiPhonology", () => ({
import { supabase } from "../../lib/supabase"; import { supabase } from "../../lib/supabase";
import { correctEO as deepseekCorrectEO } from "../../lib/deepseek"; import { correctEO as deepseekCorrectEO } from "../../lib/deepseek";
import { parseT1Context, runT1LiveCorrection } from "../t1live"; import { runT1LiveCorrection } from "../t1live";
import type { WebSocketLike } from "../../lib/geminiLive"; import type { WebSocketLike } from "../../lib/geminiLive";
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -87,14 +87,6 @@ function mockProductionUpdate(errorMsg: string | null = null) {
} as any); } as any);
} }
const REPONSES = {
prenom_age_ville: "Hermann, 35 ans, Lyon",
formation_metier: "ingénieur en informatique",
situation_familiale: "marié, deux enfants",
loisirs: "la randonnée et la photographie",
motivation_canada: "de meilleures opportunités professionnelles",
};
const FAKE_RAPPORT = { const FAKE_RAPPORT = {
score: 14, score: 14,
nclc: 8, nclc: 8,
@ -108,34 +100,6 @@ const FAKE_RAPPORT = {
// ─── Tests ─────────────────────────────────────────────────────────────────── // ─── Tests ───────────────────────────────────────────────────────────────────
describe("parseT1Context", () => {
it("accepte un message {type:'context', reponses} valide", () => {
const result = parseT1Context(
JSON.stringify({ type: "context", reponses: REPONSES }),
);
expect(result).toEqual({ ok: true, reponses: REPONSES });
});
it("refuse un message sans type 'context'", () => {
const result = parseT1Context(
JSON.stringify({ type: "audio", data: "AAAA" }),
);
expect(result).toEqual({ ok: false });
});
it("refuse un contexte aux réponses invalides (champ manquant)", () => {
const { motivation_canada: _omit, ...partiel } = REPONSES;
const result = parseT1Context(
JSON.stringify({ type: "context", reponses: partiel }),
);
expect(result).toEqual({ ok: false });
});
it("refuse un payload non-JSON", () => {
expect(parseT1Context("pas du json {")).toEqual({ ok: false });
});
});
describe("runT1LiveCorrection", () => { describe("runT1LiveCorrection", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View file

@ -5,10 +5,6 @@ import { supabase } from "../lib/supabase.js";
import type { Plan } from "../lib/access.js"; import type { Plan } from "../lib/access.js";
import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js"; import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js"; import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
import {
validateReponses,
type PresentationReponses,
} from "../controllers/presentationController.js";
import { import {
openGeminiLiveT1Session, openGeminiLiveT1Session,
type OpenGeminiLiveT1SessionOptions, type OpenGeminiLiveT1SessionOptions,
@ -23,37 +19,6 @@ interface Profile {
plan: Plan; plan: Plan;
} }
/**
* Parse et valide le 1er message attendu sur la socket T1 : `{type:'context',
* reponses}`. Les réponses sont validées via `validateReponses` (réutilisée du
* contrôleur de présentation T1 EO n'est PAS subject-based, le contexte vient
* du questionnaire candidat, pas d'un sujet en base).
*/
export function parseT1Context(
data: unknown,
): { ok: true; reponses: PresentationReponses } | { ok: false } {
let parsed: unknown;
if (typeof data === "string") {
try {
parsed = JSON.parse(data);
} catch {
return { ok: false };
}
} else if (data !== null && typeof data === "object") {
parsed = data;
} else {
return { ok: false };
}
if (parsed === null || typeof parsed !== "object") return { ok: false };
const msg = parsed as Record<string, unknown>;
if (msg.type !== "context") return { ok: false };
const validation = validateReponses(msg.reponses);
if ("error" in validation) return { ok: false };
return { ok: true, reponses: validation.reponses };
}
/** /**
* Pipeline post-session T1 : crée la production, lance la correction EO sur le * Pipeline post-session T1 : crée la production, lance la correction EO sur le
* transcript reconstruit, persiste le rapport, envoie au client puis ferme. * transcript reconstruit, persiste le rapport, envoie au client puis ferme.
@ -210,9 +175,10 @@ export interface CreateT1LiveRoutesOptions {
* Crée le router pour `WS /t1/live`. * Crée le router pour `WS /t1/live`.
* - Auth : JWT Supabase en query param `?token=<jwt>` (RÉUTILISE authenticate de * - Auth : JWT Supabase en query param `?token=<jwt>` (RÉUTILISE authenticate de
* t2live même permission Premium `oral_t2_live`). * t2live même permission Premium `oral_t2_live`).
* - Contexte : pas de sujet en base. On attend le 1er message * - Pas de sujet ni de questionnaire : la session Gemini s'ouvre IMMÉDIATEMENT
* `{type:'context', reponses}` (validé par validateReponses). Absent/invalide * après l'auth (calque T2 qui ouvre après auth + fetch sujet). L'examinateur
* close 4004 CONTEXT_MISSING. * formule ses relances à partir de ce qu'il ENTEND. Le client envoie
* directement son audio (`{type:'audio'}`) puis `{type:'end'}`.
* - OK openGeminiLiveT1Session onSessionEnd : correction EO_T1 + persistance. * - OK openGeminiLiveT1Session onSessionEnd : correction EO_T1 + persistance.
*/ */
export default function createT1LiveRoutes( export default function createT1LiveRoutes(
@ -243,9 +209,6 @@ export default function createT1LiveRoutes(
adapter.send = () => {}; adapter.send = () => {};
adapter.close = () => {}; adapter.close = () => {};
// La session Gemini ne démarre qu'à réception d'un contexte valide.
let started = false;
return { return {
onOpen(_evt, ws) { onOpen(_evt, ws) {
adapter.send = (data: unknown) => adapter.send = (data: unknown) =>
@ -260,59 +223,37 @@ export default function createT1LiveRoutes(
/* ignore */ /* ignore */
} }
setTimeout(() => ws.close(denyCode!, denyReason), 100); setTimeout(() => ws.close(denyCode!, denyReason), 100);
}
},
onMessage(evt, ws) {
if (denyCode !== null) return;
// Tant que la session n'est pas démarrée, on attend le contexte.
if (!started) {
const raw =
typeof evt.data === "string"
? evt.data
: Buffer.isBuffer(evt.data)
? evt.data.toString("utf8")
: String(evt.data);
const ctx = parseT1Context(raw);
if (!ctx.ok) {
try {
ws.send(
JSON.stringify({ error: true, code: "CONTEXT_MISSING" }),
);
} catch {
/* ignore */
}
setTimeout(() => ws.close(4004, "CONTEXT_MISSING"), 100);
return;
}
started = true;
const profileNonNull = profile!;
openGeminiLiveT1Session(adapter, {
reponses: ctx.reponses,
clientFactory: opts.clientFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
random: opts.random,
onSessionEnd: async (transcript) => {
await runT1LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
transcript,
});
},
});
return; return;
} }
// Session démarrée : on relaie les messages (audio / end) à la session. // Auth OK → on ouvre la session Gemini immédiatement (pas de
// questionnaire ni de sujet). Calque T2.
const profileNonNull = profile!;
openGeminiLiveT1Session(adapter, {
clientFactory: opts.clientFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
random: opts.random,
onSessionEnd: async (transcript) => {
await runT1LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
transcript,
});
},
});
},
onMessage(evt) {
// Relaie les messages (audio / end) à la session. Tout message non
// reconnu (ex. {type:'context'} d'un ancien front) est ignoré
// silencieusement par openGeminiLiveT1Session.
adapter.emit("message", evt.data); adapter.emit("message", evt.data);
}, },
onClose() { onClose() {
if (started) adapter.emit("close"); adapter.emit("close");
}, },
onError() { onError() {
if (started) adapter.emit("error", new Error("CLIENT_ERROR")); adapter.emit("error", new Error("CLIENT_ERROR"));
}, },
}; };
}), }),