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

@ -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", () => {