fix: T2 prompt calibration (25 words max) + JSONB parse guard (500 on getById)
This commit is contained in:
parent
452255d77f
commit
8863520a2e
3 changed files with 238 additions and 197 deletions
|
|
@ -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 } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue