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 { 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 = <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 exercices = Array.isArray(data.exercices) ? (data.exercices as ExerciceItem[]) : null
const rapport = parseJsonb<CorrectionRapport>(data.rapport);
const exercicesParsed = parseJsonb<ExerciceItem[]>(data.exercices);
const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null;
const modeleParsed = parseJsonb<ProductionModele>(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 } };
}

View file

@ -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");
});
});

View file

@ -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.`;
}
/**