fix(t1-live): remove questionnaire dependency from T1 Live session
Some checks are pending
CI / quality (push) Waiting to run
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:
parent
01707c0b74
commit
74770b6402
6 changed files with 105 additions and 209 deletions
|
|
@ -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
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ d'immigration au Canada) sous forme de **monologue**, et l'examinateur le
|
|||
|
||||
**Différence structurelle avec le T2 :**
|
||||
|
||||
| Axe | T1 (entretien dirigé) | T2 (interaction de service) |
|
||||
| ----------------- | -------------------------------- | ---------------------------- |
|
||||
| Qui mène | L'examinateur relance | Le candidat mène |
|
||||
| Questions de l'IA | **Obligatoires** (relances) | **Interdites** (rôle passif) |
|
||||
| Forme candidat | Monologue + relances | Dialogue |
|
||||
| Subject-based | **Non** (questionnaire candidat) | Oui (table `sujets`) |
|
||||
| Axe | T1 (entretien dirigé) | T2 (interaction de service) |
|
||||
| ----------------- | ------------------------------ | ---------------------------- |
|
||||
| Qui mène | L'examinateur relance | Le candidat mène |
|
||||
| Questions de l'IA | **Obligatoires** (relances) | **Interdites** (rôle passif) |
|
||||
| Forme candidat | Monologue + relances | Dialogue |
|
||||
| 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
|
||||
(Modèle 1 acté — cf. ROADMAP / `geminiLiveT1.ts`).
|
||||
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.
|
||||
5. **N'évalue jamais** le candidat, ne corrige pas ses erreurs, ne commente pas
|
||||
sa langue.
|
||||
|
|
@ -63,25 +63,21 @@ L'IA joue un **examinateur bienveillant** du TCF Canada. Son comportement :
|
|||
|
||||
## 3. Prompt système (source : `buildT1SystemPrompt`)
|
||||
|
||||
Les variables `${...}` sont substituées dynamiquement depuis les réponses du
|
||||
**questionnaire candidat** (`PresentationReponses`) — il n'existe pas de sujet T1
|
||||
en base (T1 EO n'est PAS subject-based).
|
||||
Le prompt est **statique** : aucune variable substituée. L'examinateur formule
|
||||
ses relances à partir de ce qu'il **entend en temps réel** (son contexte audio
|
||||
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.
|
||||
|
||||
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) :
|
||||
- Identité : ${reponses.prenom_age_ville}
|
||||
- Formation / métier : ${reponses.formation_metier}
|
||||
- Situation familiale : ${reponses.situation_familiale}
|
||||
- Loisirs : ${reponses.loisirs}
|
||||
- Projet Canada : ${reponses.motivation_canada}
|
||||
Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
|
||||
|
||||
RÈGLES :
|
||||
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
|
||||
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
|
||||
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
|
||||
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire 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.
|
||||
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.
|
||||
|
|
@ -91,13 +87,6 @@ RÈGLES :
|
|||
> **⚠ 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).
|
||||
|
||||
**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)
|
||||
|
|
@ -106,13 +95,17 @@ Route : **`WS /t1/live?token=<jwt>`**
|
|||
Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise
|
||||
`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
|
||||
|
||||
| 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. |
|
||||
| Fin de session | `{type:'end'}` | Déclenche `endSession()` (flush terminal + correction). |
|
||||
| Message | Forme | Effet |
|
||||
| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| 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). |
|
||||
|
||||
### 4.2 Backend → Client
|
||||
|
||||
|
|
@ -127,15 +120,14 @@ Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise
|
|||
|
||||
### 4.3 Codes de fermeture WebSocket
|
||||
|
||||
| Close code | Cause | Origine |
|
||||
| ---------- | -------------------------------------------------------- | ------------------------- |
|
||||
| 1000 | Fin normale + rapport prêt (ou transcript vide) | `runT1LiveCorrection` |
|
||||
| 1011 | `PERSISTENCE_FAILED` / `CORRECTION_FAILED` | `runT1LiveCorrection` |
|
||||
| 4001 | `AUTH_REQUIRED` (JWT absent/invalide) | `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` |
|
||||
| 4006 | `GEMINI_DISCONNECTED` (WS Gemini fermé/erreur) | `openGeminiLiveT1Session` |
|
||||
| Close code | Cause | Origine |
|
||||
| ---------- | ------------------------------------------------------- | ------------------------- |
|
||||
| 1000 | Fin normale + rapport prêt (ou transcript vide) | `runT1LiveCorrection` |
|
||||
| 1011 | `PERSISTENCE_FAILED` / `CORRECTION_FAILED` | `runT1LiveCorrection` |
|
||||
| 4001 | `AUTH_REQUIRED` (JWT absent/invalide) | `authenticate` |
|
||||
| 4003 | `PLAN_INSUFFICIENT` (pas Premium) | `authenticate` |
|
||||
| 4005 | `GEMINI_CONFIG` (clé API Gemini manquante côté serveur) | `openGeminiLiveT1Session` |
|
||||
| 4006 | `GEMINI_DISCONNECTED` (WS Gemini fermé/erreur) | `openGeminiLiveT1Session` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
T1_INTERRUPTION_MIN_SPACING_MS,
|
||||
} from "../geminiLiveT1";
|
||||
import type { WebSocketLike } from "../geminiLive";
|
||||
import type { PresentationReponses } from "../../controllers/presentationController";
|
||||
|
||||
class FakeWs extends EventEmitter implements WebSocketLike {
|
||||
public sent: unknown[] = [];
|
||||
|
|
@ -50,33 +49,23 @@ function clientSignals(client: FakeWs): { 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", () => {
|
||||
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.toLowerCase()).toContain("relanc");
|
||||
expect(prompt.toLowerCase()).toContain("question");
|
||||
});
|
||||
|
||||
it("intègre les réponses du questionnaire candidat", () => {
|
||||
const prompt = buildT1SystemPrompt({ reponses: REPONSES });
|
||||
expect(prompt).toContain("Hermann, 35 ans, Lyon");
|
||||
expect(prompt).toContain("ingénieur en informatique");
|
||||
expect(prompt).toContain("marié, deux enfants");
|
||||
expect(prompt).toContain("la randonnée et la photographie");
|
||||
expect(prompt).toContain("de meilleures opportunités professionnelles");
|
||||
it("instruit l'examinateur d'écouter le candidat (plus de questionnaire pré-rempli)", () => {
|
||||
const prompt = buildT1SystemPrompt();
|
||||
expect(prompt).toContain("Écoute attentivement");
|
||||
// Plus aucune section « CONTEXTE DU CANDIDAT » ni variable substituée.
|
||||
expect(prompt).not.toContain("CONTEXTE DU CANDIDAT");
|
||||
});
|
||||
|
||||
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();
|
||||
// La règle 7 T2 interdit les questions et bannit le point d'interrogation.
|
||||
expect(upper).not.toContain("INTERDICTION DE POSER DES QUESTIONS");
|
||||
|
|
@ -151,7 +140,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
|
|||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
openGeminiLiveT1Session(client, {
|
||||
reponses: REPONSES,
|
||||
clientFactory: () => gemini,
|
||||
random: seqRandom([0.1]),
|
||||
});
|
||||
|
|
@ -171,7 +159,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
|
|||
const gemini = new FakeWs();
|
||||
// drawCount(0.5)=1 ; planInstants(1, 0)=START_MS.
|
||||
openGeminiLiveT1Session(client, {
|
||||
reponses: REPONSES,
|
||||
clientFactory: () => gemini,
|
||||
random: seqRandom([0.5, 0]),
|
||||
});
|
||||
|
|
@ -223,7 +210,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
|
|||
const onSessionEnd = vi.fn();
|
||||
// count=0 : aucune interruption programmée, on teste juste le flush terminal.
|
||||
openGeminiLiveT1Session(client, {
|
||||
reponses: REPONSES,
|
||||
clientFactory: () => gemini,
|
||||
random: seqRandom([0.1]),
|
||||
onSessionEnd,
|
||||
|
|
@ -283,7 +269,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
|
|||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveT1Session(client, {
|
||||
reponses: REPONSES,
|
||||
clientFactory: () => gemini,
|
||||
random: seqRandom([0.1]),
|
||||
onSessionEnd,
|
||||
|
|
@ -304,7 +289,6 @@ describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
|
|||
const factory = vi.fn(() => new FakeWs());
|
||||
|
||||
openGeminiLiveT1Session(client, {
|
||||
reponses: REPONSES,
|
||||
clientFactory: factory,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
*/
|
||||
|
||||
import { WebSocket as NodeWebSocket } from "ws";
|
||||
import type { PresentationReponses } from "../controllers/presentationController.js";
|
||||
import {
|
||||
GEMINI_LIVE_URL,
|
||||
buildSetupFrame,
|
||||
|
|
@ -37,33 +36,28 @@ import {
|
|||
} from "./geminiLive.js";
|
||||
|
||||
/**
|
||||
* Construit le prompt système T1 Live à partir des réponses du questionnaire
|
||||
* candidat (transmises dynamiquement — il n'existe pas de sujet T1 en base).
|
||||
* Construit le prompt système T1 Live.
|
||||
*
|
||||
* L'examinateur formule ses relances à partir de ce qu'il ENTEND en temps réel
|
||||
* (son contexte audio interne) — il n'existe pas de sujet T1 en base et le flux
|
||||
* ne dépend plus d'un questionnaire pré-rempli.
|
||||
*
|
||||
* Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut
|
||||
* et ne prend la parole QUE lorsque le backend le lui signale (injection
|
||||
* `clientContent` au moment choisi par l'horloge probabiliste). C'est le
|
||||
* BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une
|
||||
* relance courte à partir de son contexte audio interne.
|
||||
* relance courte à partir de ce que le candidat vient de dire.
|
||||
*/
|
||||
export function buildT1SystemPrompt(input: {
|
||||
reponses: PresentationReponses;
|
||||
}): string {
|
||||
const { reponses } = input;
|
||||
export function buildT1SystemPrompt(): string {
|
||||
return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
|
||||
|
||||
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) :
|
||||
- Identité : ${reponses.prenom_age_ville}
|
||||
- Formation / métier : ${reponses.formation_metier}
|
||||
- Situation familiale : ${reponses.situation_familiale}
|
||||
- Loisirs : ${reponses.loisirs}
|
||||
- Projet Canada : ${reponses.motivation_canada}
|
||||
Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
|
||||
|
||||
RÈGLES :
|
||||
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
|
||||
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
|
||||
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
|
||||
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire 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.
|
||||
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.
|
||||
|
|
@ -160,8 +154,6 @@ export function planT1InterruptionInstants(
|
|||
// ── Options de session ───────────────────────────────────────────────────────
|
||||
|
||||
export interface OpenGeminiLiveT1SessionOptions {
|
||||
/** Réponses du questionnaire candidat (contexte du prompt T1). */
|
||||
reponses: PresentationReponses;
|
||||
/** Callback de fin de session avec le transcript reconstruit. */
|
||||
onSessionEnd?: (transcript: string) => void | Promise<void>;
|
||||
/** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */
|
||||
|
|
@ -208,7 +200,7 @@ export function openGeminiLiveT1Session(
|
|||
const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS;
|
||||
const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS;
|
||||
const random = opts.random ?? Math.random;
|
||||
const systemPrompt = buildT1SystemPrompt({ reponses: opts.reponses });
|
||||
const systemPrompt = buildT1SystemPrompt();
|
||||
|
||||
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
|
||||
const factory =
|
||||
|
|
@ -440,12 +432,14 @@ export function openGeminiLiveT1Session(
|
|||
return;
|
||||
}
|
||||
const audioBase64 = parseAudioChunk(data);
|
||||
if (
|
||||
audioBase64 !== null &&
|
||||
!sessionEnded &&
|
||||
candidateTurnOpen &&
|
||||
!injecting
|
||||
) {
|
||||
if (audioBase64 === null) {
|
||||
// Message non reconnu (ni audio ni end). Notamment un éventuel
|
||||
// {type:'context'} envoyé par un ancien front : ignoré silencieusement —
|
||||
// jamais de crash ni de close. Cf. point de vigilance Patch 7a.
|
||||
console.debug("[T1] ignored non-audio client message");
|
||||
return;
|
||||
}
|
||||
if (!sessionEnded && candidateTurnOpen && !injecting) {
|
||||
geminiSend(
|
||||
JSON.stringify({
|
||||
realtimeInput: {
|
||||
|
|
@ -454,7 +448,6 @@ export function openGeminiLiveT1Session(
|
|||
}),
|
||||
);
|
||||
}
|
||||
// Tout autre message client est ignoré.
|
||||
});
|
||||
|
||||
clientWs.on("close", () => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ vi.mock("../../lib/geminiPhonology", () => ({
|
|||
|
||||
import { supabase } from "../../lib/supabase";
|
||||
import { correctEO as deepseekCorrectEO } from "../../lib/deepseek";
|
||||
import { parseT1Context, runT1LiveCorrection } from "../t1live";
|
||||
import { runT1LiveCorrection } from "../t1live";
|
||||
import type { WebSocketLike } from "../../lib/geminiLive";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -87,14 +87,6 @@ function mockProductionUpdate(errorMsg: string | null = null) {
|
|||
} 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 = {
|
||||
score: 14,
|
||||
nclc: 8,
|
||||
|
|
@ -108,34 +100,6 @@ const FAKE_RAPPORT = {
|
|||
|
||||
// ─── 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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,6 @@ import { supabase } from "../lib/supabase.js";
|
|||
import type { Plan } from "../lib/access.js";
|
||||
import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
|
||||
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
|
||||
import {
|
||||
validateReponses,
|
||||
type PresentationReponses,
|
||||
} from "../controllers/presentationController.js";
|
||||
import {
|
||||
openGeminiLiveT1Session,
|
||||
type OpenGeminiLiveT1SessionOptions,
|
||||
|
|
@ -23,37 +19,6 @@ interface Profile {
|
|||
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
|
||||
* 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`.
|
||||
* - Auth : JWT Supabase en query param `?token=<jwt>` (RÉUTILISE authenticate de
|
||||
* t2live — même permission Premium `oral_t2_live`).
|
||||
* - Contexte : pas de sujet en base. On attend le 1er message
|
||||
* `{type:'context', reponses}` (validé par validateReponses). Absent/invalide
|
||||
* → close 4004 CONTEXT_MISSING.
|
||||
* - Pas de sujet ni de questionnaire : la session Gemini s'ouvre IMMÉDIATEMENT
|
||||
* après l'auth (calque T2 qui ouvre après auth + fetch sujet). L'examinateur
|
||||
* 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.
|
||||
*/
|
||||
export default function createT1LiveRoutes(
|
||||
|
|
@ -243,9 +209,6 @@ export default function createT1LiveRoutes(
|
|||
adapter.send = () => {};
|
||||
adapter.close = () => {};
|
||||
|
||||
// La session Gemini ne démarre qu'à réception d'un contexte valide.
|
||||
let started = false;
|
||||
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
adapter.send = (data: unknown) =>
|
||||
|
|
@ -260,59 +223,37 @@ export default function createT1LiveRoutes(
|
|||
/* ignore */
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
},
|
||||
onClose() {
|
||||
if (started) adapter.emit("close");
|
||||
adapter.emit("close");
|
||||
},
|
||||
onError() {
|
||||
if (started) adapter.emit("error", new Error("CLIENT_ERROR"));
|
||||
adapter.emit("error", new Error("CLIENT_ERROR"));
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue