fix: T2 prompt calibration (25 words max) + JSONB parse guard (500 on getById)

This commit is contained in:
Hermann_Kitio 2026-04-27 04:11:02 +03:00
parent 452255d77f
commit 8863520a2e
3 changed files with 238 additions and 197 deletions

View file

@ -1,106 +1,117 @@
import { supabase } from '../lib/supabase.js' import { supabase } from "../lib/supabase.js";
import { canUserSimulate } from '../lib/access.js' import { canUserSimulate } from "../lib/access.js";
import type { import type {
CorrectionRapport, CorrectionRapport,
ProductionModele, ProductionModele,
ExerciceItem, ExerciceItem,
} from '../lib/deepseek.js' } from "../lib/deepseek.js";
import type { AuthProfile } from '../middleware/auth.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 Tache =
export type Mode = 'entrainement' | 'examen' | "EE_T1"
| "EE_T2"
| "EE_T3"
| "EO_T1"
| "EO_T3"
| "EO_T2_LIVE";
export type Mode = "entrainement" | "examen";
export interface CreateBody { export interface CreateBody {
tache: Tache tache: Tache;
mode: Mode mode: Mode;
contenu?: string contenu?: string;
} }
export interface SujetData { export interface SujetData {
id: string id: string;
consigne: string consigne: string;
role: string | null role: string | null;
contexte: string | null contexte: string | null;
doc1_titre: string | null doc1_titre: string | null;
doc1_texte: string | null doc1_texte: string | null;
doc2_titre: string | null doc2_titre: string | null;
doc2_texte: string | null doc2_texte: string | null;
} }
export interface CreateResult { export interface CreateResult {
id: string id: string;
tache: Tache tache: Tache;
mode: Mode mode: Mode;
created_at: string created_at: string;
sujet: SujetData | null sujet: SujetData | null;
} }
type CreateError = { type CreateError = {
error: true error: true;
code: string code: string;
message: string message: string;
status: number status: number;
} };
// Mappe une Tache frontend vers les filtres de la table sujets. // 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). // Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini).
function mapTacheToSujetParams( function mapTacheToSujetParams(
tache: Tache tache: Tache,
): { mode: 'EE' | 'EO'; tacheNumber: number } | null { ): { mode: "EE" | "EO"; tacheNumber: number } | null {
switch (tache) { switch (tache) {
case 'EE_T1': case "EE_T1":
return { mode: 'EE', tacheNumber: 1 } return { mode: "EE", tacheNumber: 1 };
case 'EE_T2': case "EE_T2":
return { mode: 'EE', tacheNumber: 2 } return { mode: "EE", tacheNumber: 2 };
case 'EE_T3': case "EE_T3":
return { mode: 'EE', tacheNumber: 3 } return { mode: "EE", tacheNumber: 3 };
case 'EO_T1': case "EO_T1":
return { mode: 'EO', tacheNumber: 1 } return { mode: "EO", tacheNumber: 1 };
case 'EO_T3': case "EO_T3":
return { mode: 'EO', tacheNumber: 3 } return { mode: "EO", tacheNumber: 3 };
case 'EO_T2_LIVE': case "EO_T2_LIVE":
return null return null;
} }
} }
export async function create( export async function create(
body: CreateBody, body: CreateBody,
profile: AuthProfile profile: AuthProfile,
): Promise<{ data: CreateResult } | CreateError> { ): Promise<{ data: CreateResult } | CreateError> {
// 1. Vérifier le quota via canUserSimulate (lib/access.ts) // 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) { if (!check.allowed) {
return { return {
error: true, error: true,
code: 'QUOTA_REACHED', code: "QUOTA_REACHED",
message: 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, status: 403,
} };
} }
// 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête. // 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête.
// (non bloquant — sujet: null si introuvable). // (non bloquant — sujet: null si introuvable).
const sujetParams = mapTacheToSujetParams(body.tache) const sujetParams = mapTacheToSujetParams(body.tache);
let sujet: SujetData | null = null let sujet: SujetData | null = null;
if (sujetParams) { if (sujetParams) {
const { data: sujets, error: sujetError } = await supabase const { data: sujets, error: sujetError } = await supabase
.from('sujets') .from("sujets")
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') .select(
.eq('mode', sujetParams.mode) "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
.eq('tache', sujetParams.tacheNumber) )
.eq('actif', true) .eq("mode", sujetParams.mode)
.eq("tache", sujetParams.tacheNumber)
.eq("actif", true);
if (!sujetError && sujets && sujets.length > 0) { 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). // 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume).
const { data, error } = await supabase const { data, error } = await supabase
.from('productions') .from("productions")
.insert({ .insert({
user_id: profile.id, user_id: profile.id,
tache: body.tache, tache: body.tache,
@ -108,16 +119,17 @@ export async function create(
contenu: body.contenu ?? null, contenu: body.contenu ?? null,
sujet_id: sujet?.id ?? null, sujet_id: sujet?.id ?? null,
}) })
.select('id, tache, mode, created_at') .select("id, tache, mode, created_at")
.single() .single();
if (error || !data) { if (error || !data) {
return { return {
error: true, error: true,
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.', message:
"Une erreur est survenue. Veuillez réessayer dans quelques instants.",
status: 500, status: 500,
} };
} }
return { return {
@ -128,7 +140,7 @@ export async function create(
created_at: data.created_at, created_at: data.created_at,
sujet, sujet,
}, },
} };
} }
// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté. // 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). // rapport, exercices, modele — trop lourds).
export interface ListOptions { export interface ListOptions {
page: number page: number;
limit: number limit: number;
} }
export interface ListItem { export interface ListItem {
id: string id: string;
tache: Tache tache: Tache;
mode: Mode mode: Mode;
score: number | null score: number | null;
nclc: number | null nclc: number | null;
nclc_cible: 9 | 10 | null nclc_cible: 9 | 10 | null;
created_at: string created_at: string;
} }
export interface ListResult { export interface ListResult {
data: ListItem[] data: ListItem[];
pagination: { pagination: {
page: number page: number;
limit: number limit: number;
total: number total: number;
} };
} }
type ListError = ControllerError type ListError = ControllerError;
export async function list( export async function list(
options: ListOptions, options: ListOptions,
profile: AuthProfile, profile: AuthProfile,
): Promise<{ data: ListResult } | ListError> { ): Promise<{ data: ListResult } | ListError> {
const { page, limit } = options const { page, limit } = options;
const offset = (page - 1) * limit const offset = (page - 1) * limit;
const { data, error, count } = await supabase const { data, error, count } = await supabase
.from('productions') .from("productions")
.select('id, tache, mode, score, nclc, nclc_cible, created_at', { count: 'exact' }) .select("id, tache, mode, score, nclc, nclc_cible, created_at", {
.eq('user_id', profile.id) count: "exact",
.order('created_at', { ascending: false }) })
.range(offset, offset + limit - 1) .eq("user_id", profile.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (error) { if (error) {
return { return {
error: true, error: true,
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'Impossible de charger les simulations.', message: "Impossible de charger les simulations.",
status: 500, status: 500,
} };
} }
const items: ListItem[] = (data ?? []).map((row) => ({ const items: ListItem[] = (data ?? []).map((row) => ({
@ -191,16 +205,18 @@ export async function list(
score: (row.score as number | null) ?? null, score: (row.score as number | null) ?? null,
nclc: (row.nclc as number | null) ?? null, nclc: (row.nclc as number | null) ?? null,
nclc_cible: 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, created_at: row.created_at as string,
})) }));
return { return {
data: { data: {
data: items, data: items,
pagination: { page, limit, total: count ?? 0 }, pagination: { page, limit, total: count ?? 0 },
}, },
} };
} }
// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc, // 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 → RapportPage affiche la correction
// - rapport === null → SimulationFlowProvider restaure la session (resume) // - rapport === null → SimulationFlowProvider restaure la session (resume)
export interface GetByIdResult { export interface GetByIdResult {
simulation_id: string simulation_id: string;
tache: Tache tache: Tache;
mode: Mode mode: Mode;
created_at: string created_at: string;
contenu: string | null contenu: string | null;
sujet: SujetData | null sujet: SujetData | null;
rapport: CorrectionRapport | null rapport: CorrectionRapport | null;
nclc_cible: 9 | 10 | null nclc_cible: 9 | 10 | null;
exercices: ExerciceItem[] | null exercices: ExerciceItem[] | null;
exercices_status: JobStatus exercices_status: JobStatus;
modele: ProductionModele | null modele: ProductionModele | null;
modele_status: JobStatus modele_status: JobStatus;
} }
type ControllerError = { type ControllerError = {
error: true error: true;
code: string code: string;
message: string message: string;
status: number status: number;
} };
export async function getById( export async function getById(
id: string, id: string,
profile: AuthProfile profile: AuthProfile,
): Promise<{ data: GetByIdResult } | ControllerError> { ): Promise<{ data: GetByIdResult } | ControllerError> {
const { data, error } = await supabase const { data, error } = await supabase
.from('productions') .from("productions")
.select( .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) .eq("id", id)
.single() .single();
if (error || !data) { if (error || !data) {
return { return {
error: true, error: true,
code: 'SIMULATION_NOT_FOUND', code: "SIMULATION_NOT_FOUND",
message: 'Simulation introuvable.', message: "Simulation introuvable.",
status: 404, status: 404,
} };
} }
if (data.user_id !== profile.id) { if (data.user_id !== profile.id) {
return { return {
error: true, error: true,
code: 'AUTH_REQUIRED', code: "AUTH_REQUIRED",
message: 'Cette simulation ne vous appartient pas.', message: "Cette simulation ne vous appartient pas.",
status: 401, status: 401,
} };
} }
// Charger le sujet si présent (FTD-21 — restore complet de la session). // 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) { if (data.sujet_id) {
const { data: sujetRow } = await supabase const { data: sujetRow } = await supabase
.from('sujets') .from("sujets")
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') .select(
.eq('id', data.sujet_id) "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
.single() )
if (sujetRow) sujet = sujetRow as SujetData .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 = <T>(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 rapport = parseJsonb<CorrectionRapport>(data.rapport);
const exercices = Array.isArray(data.exercices) ? (data.exercices as ExerciceItem[]) : null const exercicesParsed = parseJsonb<ExerciceItem[]>(data.exercices);
const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null;
const modeleParsed = parseJsonb<ProductionModele>(data.modele);
const 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 exercicesStatus =
const modeleStatus = (data.modele_status as JobStatus | null) ?? 'pending' (data.exercices_status as JobStatus | null) ?? "pending";
const nclcCibleRaw = data.nclc_cible const modeleStatus = (data.modele_status as JobStatus | null) ?? "pending";
const nclcCibleRaw = data.nclc_cible;
const nclcCible: 9 | 10 | null = const nclcCible: 9 | 10 | null =
nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null;
return { return {
data: { data: {
@ -301,7 +335,7 @@ export async function getById(
modele, modele,
modele_status: modeleStatus, modele_status: modeleStatus,
}, },
} };
} }
/** /**
@ -311,65 +345,65 @@ export async function getById(
export async function autosaveContenu( export async function autosaveContenu(
id: string, id: string,
userId: string, userId: string,
contenu: string contenu: string,
): Promise<{ data: { ok: true } } | ControllerError> { ): Promise<{ data: { ok: true } } | ControllerError> {
if (contenu.length > 5000) { if (contenu.length > 5000) {
return { return {
error: true, error: true,
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Le texte ne doit pas dépasser 5 000 caractères.', message: "Le texte ne doit pas dépasser 5 000 caractères.",
status: 400, status: 400,
} };
} }
const { data: prod, error } = await supabase const { data: prod, error } = await supabase
.from('productions') .from("productions")
.select('user_id, rapport') .select("user_id, rapport")
.eq('id', id) .eq("id", id)
.single() .single();
if (error || !prod) { if (error || !prod) {
return { return {
error: true, error: true,
code: 'SIMULATION_NOT_FOUND', code: "SIMULATION_NOT_FOUND",
message: 'Simulation introuvable.', message: "Simulation introuvable.",
status: 404, status: 404,
} };
} }
if (prod.user_id !== userId) { if (prod.user_id !== userId) {
return { return {
error: true, error: true,
code: 'AUTH_REQUIRED', code: "AUTH_REQUIRED",
message: 'Cette simulation ne vous appartient pas.', message: "Cette simulation ne vous appartient pas.",
status: 401, status: 401,
} };
} }
if (prod.rapport !== null) { if (prod.rapport !== null) {
return { return {
error: true, error: true,
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Cette simulation a déjà été corrigée.', message: "Cette simulation a déjà été corrigée.",
status: 400, status: 400,
} };
} }
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('productions') .from("productions")
.update({ contenu }) .update({ contenu })
.eq('id', id) .eq("id", id);
if (updateError) { if (updateError) {
return { return {
error: true, error: true,
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'Sauvegarde impossible. Réessayez dans quelques instants.', message: "Sauvegarde impossible. Réessayez dans quelques instants.",
status: 500, status: 500,
} };
} }
return { data: { ok: true } } return { data: { ok: true } };
} }
/** /**
@ -379,69 +413,71 @@ export async function autosaveContenu(
export async function updateSujet( export async function updateSujet(
id: string, id: string,
userId: string, userId: string,
sujetId: string sujetId: string,
): Promise<{ data: { sujet: SujetData } } | ControllerError> { ): Promise<{ data: { sujet: SujetData } } | ControllerError> {
const { data: sujetRow, error: sujetError } = await supabase const { data: sujetRow, error: sujetError } = await supabase
.from('sujets') .from("sujets")
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') .select(
.eq('id', sujetId) "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
.single() )
.eq("id", sujetId)
.single();
if (sujetError || !sujetRow) { if (sujetError || !sujetRow) {
return { return {
error: true, error: true,
code: 'SUJET_NOT_FOUND', code: "SUJET_NOT_FOUND",
message: 'Sujet introuvable.', message: "Sujet introuvable.",
status: 404, status: 404,
} };
} }
const { data: prod, error } = await supabase const { data: prod, error } = await supabase
.from('productions') .from("productions")
.select('user_id, rapport') .select("user_id, rapport")
.eq('id', id) .eq("id", id)
.single() .single();
if (error || !prod) { if (error || !prod) {
return { return {
error: true, error: true,
code: 'SIMULATION_NOT_FOUND', code: "SIMULATION_NOT_FOUND",
message: 'Simulation introuvable.', message: "Simulation introuvable.",
status: 404, status: 404,
} };
} }
if (prod.user_id !== userId) { if (prod.user_id !== userId) {
return { return {
error: true, error: true,
code: 'AUTH_REQUIRED', code: "AUTH_REQUIRED",
message: 'Cette simulation ne vous appartient pas.', message: "Cette simulation ne vous appartient pas.",
status: 401, status: 401,
} };
} }
if (prod.rapport !== null) { if (prod.rapport !== null) {
return { return {
error: true, error: true,
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Cette simulation a déjà été corrigée.', message: "Cette simulation a déjà été corrigée.",
status: 400, status: 400,
} };
} }
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('productions') .from("productions")
.update({ sujet_id: sujetId }) .update({ sujet_id: sujetId })
.eq('id', id) .eq("id", id);
if (updateError) { if (updateError) {
return { return {
error: true, error: true,
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'Mise à jour impossible. Réessayez dans quelques instants.', message: "Mise à jour impossible. Réessayez dans quelques instants.",
status: 500, status: 500,
} };
} }
return { data: { sujet: sujetRow as SujetData } } return { data: { sujet: sujetRow as SujetData } };
} }

View file

@ -35,11 +35,12 @@ describe("buildT2SystemPrompt", () => {
it("substitue role et contexte dans le template", () => { it("substitue role et contexte dans le template", () => {
const prompt = buildT2SystemPrompt(SUJET_OPTS); const prompt = buildT2SystemPrompt(SUJET_OPTS);
expect(prompt).toContain( 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("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("Tu ne prends PAS la parole en premier");
expect(prompt).toContain("15 à 25 mots maximum");
}); });
}); });

View file

@ -41,18 +41,22 @@ export function buildT2SystemPrompt(input: {
contexte: string; contexte: string;
}): string { }): string {
const { role, contexte } = input; const { role, contexte } = input;
return `Tu joues le rôle de ${role} dans la situation suivante : ${contexte} return `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif).
RÔLE : Tu incarnes ${role}.
Règles à respecter impérativement : CONTEXTE : ${contexte}
- Tu réponds uniquement en français, quelle que soit la langue de ton interlocuteur. RÈGLES ABSOLUES :
- Tu joues ton rôle de façon naturelle et réaliste. Tu n'es pas un examinateur tu es ${role}. 1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1.
- 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. 2. Tu NE corriges JAMAIS les erreurs du candidat.
- 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. 3. Tu attends que le candidat finisse sa question avant de répondre.
- 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. 4. Tes réponses sont courtes (15 à 25 mots maximum). Pas de monologue.
- Tu ne fais aucun commentaire sur la langue, les erreurs ou le niveau de français de ton interlocuteur. 5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises.
- Tu ne sors jamais de ton rôle. 6. Si le candidat est vague, réponds brièvement sans chercher à compléter.
- 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. 7. Ne pose JAMAIS de question de relance. Tu réponds, point.
- Tes réponses sont concises et naturelles : ni monosyllabiques, ni des monologues.`; 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.`;
} }
/** /**