From 8863520a2e7f0aa8bad655677bd9ca88e0b59cc9 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Mon, 27 Apr 2026 04:11:02 +0300 Subject: [PATCH] fix: T2 prompt calibration (25 words max) + JSONB parse guard (500 on getById) --- src/controllers/simulationController.ts | 402 +++++++++++++----------- src/lib/__tests__/geminiLive.test.ts | 5 +- src/lib/geminiLive.ts | 28 +- 3 files changed, 238 insertions(+), 197 deletions(-) diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index cd5e1cc..4c15f03 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -1,106 +1,117 @@ -import { supabase } from '../lib/supabase.js' -import { canUserSimulate } from '../lib/access.js' +import { supabase } from "../lib/supabase.js"; +import { canUserSimulate } from "../lib/access.js"; import type { CorrectionRapport, ProductionModele, ExerciceItem, -} from '../lib/deepseek.js' -import type { AuthProfile } from '../middleware/auth.js' +} from "../lib/deepseek.js"; +import type { AuthProfile } from "../middleware/auth.js"; -export type JobStatus = 'pending' | 'ready' | 'error' +export type JobStatus = "pending" | "ready" | "error"; -export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE' -export type Mode = 'entrainement' | 'examen' +export type Tache = + | "EE_T1" + | "EE_T2" + | "EE_T3" + | "EO_T1" + | "EO_T3" + | "EO_T2_LIVE"; +export type Mode = "entrainement" | "examen"; export interface CreateBody { - tache: Tache - mode: Mode - contenu?: string + tache: Tache; + mode: Mode; + contenu?: string; } export interface SujetData { - id: string - consigne: string - role: string | null - contexte: string | null - doc1_titre: string | null - doc1_texte: string | null - doc2_titre: string | null - doc2_texte: string | null + id: string; + consigne: string; + role: string | null; + contexte: string | null; + doc1_titre: string | null; + doc1_texte: string | null; + doc2_titre: string | null; + doc2_texte: string | null; } export interface CreateResult { - id: string - tache: Tache - mode: Mode - created_at: string - sujet: SujetData | null + id: string; + tache: Tache; + mode: Mode; + created_at: string; + sujet: SujetData | null; } type CreateError = { - error: true - code: string - message: string - status: number -} + error: true; + code: string; + message: string; + status: number; +}; // Mappe une Tache frontend vers les filtres de la table sujets. // Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini). function mapTacheToSujetParams( - tache: Tache -): { mode: 'EE' | 'EO'; tacheNumber: number } | null { + tache: Tache, +): { mode: "EE" | "EO"; tacheNumber: number } | null { switch (tache) { - case 'EE_T1': - return { mode: 'EE', tacheNumber: 1 } - case 'EE_T2': - return { mode: 'EE', tacheNumber: 2 } - case 'EE_T3': - return { mode: 'EE', tacheNumber: 3 } - case 'EO_T1': - return { mode: 'EO', tacheNumber: 1 } - case 'EO_T3': - return { mode: 'EO', tacheNumber: 3 } - case 'EO_T2_LIVE': - return null + case "EE_T1": + return { mode: "EE", tacheNumber: 1 }; + case "EE_T2": + return { mode: "EE", tacheNumber: 2 }; + case "EE_T3": + return { mode: "EE", tacheNumber: 3 }; + case "EO_T1": + return { mode: "EO", tacheNumber: 1 }; + case "EO_T3": + return { mode: "EO", tacheNumber: 3 }; + case "EO_T2_LIVE": + return null; } } export async function create( body: CreateBody, - profile: AuthProfile + profile: AuthProfile, ): Promise<{ data: CreateResult } | CreateError> { // 1. Vérifier le quota via canUserSimulate (lib/access.ts) - const check = canUserSimulate({ plan: profile.plan, simulations_used: profile.simulations_used }) + const check = canUserSimulate({ + plan: profile.plan, + simulations_used: profile.simulations_used, + }); if (!check.allowed) { return { error: true, - code: 'QUOTA_REACHED', + code: "QUOTA_REACHED", message: - 'Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.', + "Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.", status: 403, - } + }; } // 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête. // (non bloquant — sujet: null si introuvable). - const sujetParams = mapTacheToSujetParams(body.tache) - let sujet: SujetData | null = null + const sujetParams = mapTacheToSujetParams(body.tache); + let sujet: SujetData | null = null; if (sujetParams) { const { data: sujets, error: sujetError } = await supabase - .from('sujets') - .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') - .eq('mode', sujetParams.mode) - .eq('tache', sujetParams.tacheNumber) - .eq('actif', true) + .from("sujets") + .select( + "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", + ) + .eq("mode", sujetParams.mode) + .eq("tache", sujetParams.tacheNumber) + .eq("actif", true); if (!sujetError && sujets && sujets.length > 0) { - sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData + sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData; } } // 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume). const { data, error } = await supabase - .from('productions') + .from("productions") .insert({ user_id: profile.id, tache: body.tache, @@ -108,16 +119,17 @@ export async function create( contenu: body.contenu ?? null, sujet_id: sujet?.id ?? null, }) - .select('id, tache, mode, created_at') - .single() + .select("id, tache, mode, created_at") + .single(); if (error || !data) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.', + code: "INTERNAL_ERROR", + message: + "Une erreur est survenue. Veuillez réessayer dans quelques instants.", status: 500, - } + }; } return { @@ -128,7 +140,7 @@ export async function create( created_at: data.created_at, sujet, }, - } + }; } // Sprint 3.7 — liste paginée des productions de l'utilisateur connecté. @@ -136,52 +148,54 @@ export async function create( // rapport, exercices, modele — trop lourds). export interface ListOptions { - page: number - limit: number + page: number; + limit: number; } export interface ListItem { - id: string - tache: Tache - mode: Mode - score: number | null - nclc: number | null - nclc_cible: 9 | 10 | null - created_at: string + id: string; + tache: Tache; + mode: Mode; + score: number | null; + nclc: number | null; + nclc_cible: 9 | 10 | null; + created_at: string; } export interface ListResult { - data: ListItem[] + data: ListItem[]; pagination: { - page: number - limit: number - total: number - } + page: number; + limit: number; + total: number; + }; } -type ListError = ControllerError +type ListError = ControllerError; export async function list( options: ListOptions, profile: AuthProfile, ): Promise<{ data: ListResult } | ListError> { - const { page, limit } = options - const offset = (page - 1) * limit + const { page, limit } = options; + const offset = (page - 1) * limit; const { data, error, count } = await supabase - .from('productions') - .select('id, tache, mode, score, nclc, nclc_cible, created_at', { count: 'exact' }) - .eq('user_id', profile.id) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) + .from("productions") + .select("id, tache, mode, score, nclc, nclc_cible, created_at", { + count: "exact", + }) + .eq("user_id", profile.id) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); if (error) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Impossible de charger les simulations.', + code: "INTERNAL_ERROR", + message: "Impossible de charger les simulations.", status: 500, - } + }; } const items: ListItem[] = (data ?? []).map((row) => ({ @@ -191,16 +205,18 @@ export async function list( score: (row.score as number | null) ?? null, nclc: (row.nclc as number | null) ?? null, nclc_cible: - row.nclc_cible === 9 || row.nclc_cible === 10 ? (row.nclc_cible as 9 | 10) : null, + row.nclc_cible === 9 || row.nclc_cible === 10 + ? (row.nclc_cible as 9 | 10) + : null, created_at: row.created_at as string, - })) + })); return { data: { data: items, pagination: { page, limit, total: count ?? 0 }, }, - } + }; } // Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc, @@ -211,80 +227,98 @@ export async function list( // - rapport !== null → RapportPage affiche la correction // - rapport === null → SimulationFlowProvider restaure la session (resume) export interface GetByIdResult { - simulation_id: string - tache: Tache - mode: Mode - created_at: string - contenu: string | null - sujet: SujetData | null - rapport: CorrectionRapport | null - nclc_cible: 9 | 10 | null - exercices: ExerciceItem[] | null - exercices_status: JobStatus - modele: ProductionModele | null - modele_status: JobStatus + simulation_id: string; + tache: Tache; + mode: Mode; + created_at: string; + contenu: string | null; + sujet: SujetData | null; + rapport: CorrectionRapport | null; + nclc_cible: 9 | 10 | null; + exercices: ExerciceItem[] | null; + exercices_status: JobStatus; + modele: ProductionModele | null; + modele_status: JobStatus; } type ControllerError = { - error: true - code: string - message: string - status: number -} + error: true; + code: string; + message: string; + status: number; +}; export async function getById( id: string, - profile: AuthProfile + profile: AuthProfile, ): Promise<{ data: GetByIdResult } | ControllerError> { const { data, error } = await supabase - .from('productions') + .from("productions") .select( - 'id, user_id, tache, mode, contenu, sujet_id, rapport, created_at, nclc_cible, exercices, exercices_status, modele, modele_status', + "id, user_id, tache, mode, contenu, sujet_id, rapport, created_at, nclc_cible, exercices, exercices_status, modele, modele_status", ) - .eq('id', id) - .single() + .eq("id", id) + .single(); if (error || !data) { return { error: true, - code: 'SIMULATION_NOT_FOUND', - message: 'Simulation introuvable.', + code: "SIMULATION_NOT_FOUND", + message: "Simulation introuvable.", status: 404, - } + }; } if (data.user_id !== profile.id) { return { error: true, - code: 'AUTH_REQUIRED', - message: 'Cette simulation ne vous appartient pas.', + code: "AUTH_REQUIRED", + message: "Cette simulation ne vous appartient pas.", status: 401, - } + }; } // Charger le sujet si présent (FTD-21 — restore complet de la session). - let sujet: SujetData | null = null + let sujet: SujetData | null = null; if (data.sujet_id) { const { data: sujetRow } = await supabase - .from('sujets') - .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') - .eq('id', data.sujet_id) - .single() - if (sujetRow) sujet = sujetRow as SujetData + .from("sujets") + .select( + "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", + ) + .eq("id", data.sujet_id) + .single(); + if (sujetRow) sujet = sujetRow as SujetData; } - const rapport = data.rapport ? (JSON.parse(data.rapport) as CorrectionRapport) : null + // Garde JSONB : supabase-js retourne les colonnes JSONB déjà parsées (objet) + // mais on tolère le cas string au cas où le payload aurait été ré-encodé + // (ex. cache, réponse stockée en TEXT par migration manuelle). + const parseJsonb = (field: unknown): T | null => { + if (field === null || field === undefined) return null; + if (typeof field === "string") { + try { + return JSON.parse(field) as T; + } catch { + return null; + } + } + return field as T; + }; - // JSONB columns reviennent déjà parsées par supabase-js. - const exercices = Array.isArray(data.exercices) ? (data.exercices as ExerciceItem[]) : null + const rapport = parseJsonb(data.rapport); + const exercicesParsed = parseJsonb(data.exercices); + const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null; + const modeleParsed = parseJsonb(data.modele); const modele = - data.modele && typeof data.modele === 'object' ? (data.modele as ProductionModele) : null + modeleParsed && typeof modeleParsed === "object" ? modeleParsed : null; - const exercicesStatus = (data.exercices_status as JobStatus | null) ?? 'pending' - const modeleStatus = (data.modele_status as JobStatus | null) ?? 'pending' - const nclcCibleRaw = data.nclc_cible + const exercicesStatus = + (data.exercices_status as JobStatus | null) ?? "pending"; + const modeleStatus = (data.modele_status as JobStatus | null) ?? "pending"; + const nclcCibleRaw = data.nclc_cible; const nclcCible: 9 | 10 | null = - nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null + nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null; return { data: { @@ -301,7 +335,7 @@ export async function getById( modele, modele_status: modeleStatus, }, - } + }; } /** @@ -311,65 +345,65 @@ export async function getById( export async function autosaveContenu( id: string, userId: string, - contenu: string + contenu: string, ): Promise<{ data: { ok: true } } | ControllerError> { if (contenu.length > 5000) { return { error: true, - code: 'VALIDATION_ERROR', - message: 'Le texte ne doit pas dépasser 5 000 caractères.', + code: "VALIDATION_ERROR", + message: "Le texte ne doit pas dépasser 5 000 caractères.", status: 400, - } + }; } const { data: prod, error } = await supabase - .from('productions') - .select('user_id, rapport') - .eq('id', id) - .single() + .from("productions") + .select("user_id, rapport") + .eq("id", id) + .single(); if (error || !prod) { return { error: true, - code: 'SIMULATION_NOT_FOUND', - message: 'Simulation introuvable.', + code: "SIMULATION_NOT_FOUND", + message: "Simulation introuvable.", status: 404, - } + }; } if (prod.user_id !== userId) { return { error: true, - code: 'AUTH_REQUIRED', - message: 'Cette simulation ne vous appartient pas.', + code: "AUTH_REQUIRED", + message: "Cette simulation ne vous appartient pas.", status: 401, - } + }; } if (prod.rapport !== null) { return { error: true, - code: 'VALIDATION_ERROR', - message: 'Cette simulation a déjà été corrigée.', + code: "VALIDATION_ERROR", + message: "Cette simulation a déjà été corrigée.", status: 400, - } + }; } const { error: updateError } = await supabase - .from('productions') + .from("productions") .update({ contenu }) - .eq('id', id) + .eq("id", id); if (updateError) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Sauvegarde impossible. Réessayez dans quelques instants.', + code: "INTERNAL_ERROR", + message: "Sauvegarde impossible. Réessayez dans quelques instants.", status: 500, - } + }; } - return { data: { ok: true } } + return { data: { ok: true } }; } /** @@ -379,69 +413,71 @@ export async function autosaveContenu( export async function updateSujet( id: string, userId: string, - sujetId: string + sujetId: string, ): Promise<{ data: { sujet: SujetData } } | ControllerError> { const { data: sujetRow, error: sujetError } = await supabase - .from('sujets') - .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') - .eq('id', sujetId) - .single() + .from("sujets") + .select( + "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", + ) + .eq("id", sujetId) + .single(); if (sujetError || !sujetRow) { return { error: true, - code: 'SUJET_NOT_FOUND', - message: 'Sujet introuvable.', + code: "SUJET_NOT_FOUND", + message: "Sujet introuvable.", status: 404, - } + }; } const { data: prod, error } = await supabase - .from('productions') - .select('user_id, rapport') - .eq('id', id) - .single() + .from("productions") + .select("user_id, rapport") + .eq("id", id) + .single(); if (error || !prod) { return { error: true, - code: 'SIMULATION_NOT_FOUND', - message: 'Simulation introuvable.', + code: "SIMULATION_NOT_FOUND", + message: "Simulation introuvable.", status: 404, - } + }; } if (prod.user_id !== userId) { return { error: true, - code: 'AUTH_REQUIRED', - message: 'Cette simulation ne vous appartient pas.', + code: "AUTH_REQUIRED", + message: "Cette simulation ne vous appartient pas.", status: 401, - } + }; } if (prod.rapport !== null) { return { error: true, - code: 'VALIDATION_ERROR', - message: 'Cette simulation a déjà été corrigée.', + code: "VALIDATION_ERROR", + message: "Cette simulation a déjà été corrigée.", status: 400, - } + }; } const { error: updateError } = await supabase - .from('productions') + .from("productions") .update({ sujet_id: sujetId }) - .eq('id', id) + .eq("id", id); if (updateError) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Mise à jour impossible. Réessayez dans quelques instants.', + code: "INTERNAL_ERROR", + message: "Mise à jour impossible. Réessayez dans quelques instants.", status: 500, - } + }; } - return { data: { sujet: sujetRow as SujetData } } + return { data: { sujet: sujetRow as SujetData } }; } diff --git a/src/lib/__tests__/geminiLive.test.ts b/src/lib/__tests__/geminiLive.test.ts index 0e1b610..d8285c1 100644 --- a/src/lib/__tests__/geminiLive.test.ts +++ b/src/lib/__tests__/geminiLive.test.ts @@ -35,11 +35,12 @@ 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", + "Tu incarnes un bailleur qui propose un appartement à louer", ); expect(prompt).toContain("Vous cherchez un appartement"); - expect(prompt).toContain("uniquement en français"); + expect(prompt).toContain("français naturel et courant"); expect(prompt).toContain("Tu ne prends PAS la parole en premier"); + expect(prompt).toContain("15 à 25 mots maximum"); }); }); diff --git a/src/lib/geminiLive.ts b/src/lib/geminiLive.ts index 352b415..1269e09 100644 --- a/src/lib/geminiLive.ts +++ b/src/lib/geminiLive.ts @@ -41,18 +41,22 @@ export function buildT2SystemPrompt(input: { 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.`; + return `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif). +RÔLE : Tu incarnes ${role}. +CONTEXTE : ${contexte} +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). Pas de monologue. +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 brièvement sans chercher à compléter. +7. Ne pose JAMAIS de question de relance. Tu réponds, point. +8. Ne prends jamais d'initiative pour orienter la conversation. +9. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste. +10. JAMAIS de listes ni de structure numérotée dans tes réponses. +11. Ne mentionne jamais que tu es une IA ou un modèle. +12. Tu ne prends PAS la parole en premier. Tu attends que le candidat s'adresse à toi.`; } /**