expria-backend/src/controllers/correctionController.ts
2026-04-22 17:27:29 +03:00

304 lines
8.9 KiB
TypeScript

/**
* Contrôleur corrections — Sprint 3.6a.
*
* Flux POST /corrections/ee :
* 1. Vérifier que la simulation existe, appartient à l'utilisateur, n'est pas déjà corrigée.
* 2. Charger le sujet (consigne + documents T3) pour alimenter le prompt maître.
* 3. Lancer les appels DeepSeek :
* (a) correction — await, bloque la réponse HTTP
* (b) modèle — démarré EN MÊME TEMPS que (a) avec `nclcObtenu = nclcCible - 1`
* comme estimation provisoire ; fire-and-forget, mise à jour async.
* (c) exercices — démarré APRÈS (a) car dépend de `rapport.erreurs_codes` et
* `rapport.criteres` ; fire-and-forget également.
* 4. À réception de (a) : persister le rapport + champs associés, retourner 200.
* 5. Les promesses (b) et (c) continuent en arrière-plan et mettent à jour Supabase.
*
* ⚠️ Risque connu (cf. TECH_DEBT TD-15) : si le process redémarre pendant (b)/(c),
* les colonnes `*_status` restent en 'pending' indéfiniment.
*/
import { supabase } from '../lib/supabase.js'
import {
correctEE as deepseekCorrectEE,
correctEO as deepseekCorrectEO,
generateProductionModele,
generateExercices,
type CorrectionRapport,
type EORapport,
type NclcCible,
type TacheEE,
} from '../lib/deepseek.js'
import { PLANS, type Plan } from '../lib/access.js'
import type { AuthProfile } from '../middleware/auth.js'
type CorrectionError = {
error: true
code: string
message: string
status: number
}
export interface CorrectEEInput {
simulationId: string
contenu: string
tache: TacheEE
nclcCible: NclcCible
}
export async function correctEE(
input: CorrectEEInput,
profile: AuthProfile,
): Promise<{ data: CorrectionRapport & { simulation_id: string } } | CorrectionError> {
const { simulationId, contenu, tache, nclcCible } = input
// 1. Vérifier que la production existe et appartient à l'utilisateur
const { data: production, error: fetchError } = await supabase
.from('productions')
.select('id, user_id, tache, sujet_id, rapport')
.eq('id', simulationId)
.single()
if (fetchError || !production) {
return {
error: true,
code: 'SIMULATION_NOT_FOUND',
message: 'Simulation introuvable.',
status: 404,
}
}
if (production.user_id !== profile.id) {
return {
error: true,
code: 'AUTH_REQUIRED',
message: 'Cette simulation ne vous appartient pas.',
status: 401,
}
}
// 2. Charger le sujet pour alimenter le prompt maître (consigne + docs T3)
let sujetConsigne: string | null = null
let sourceDoc1: string | null = null
let sourceDoc2: string | null = null
if (production.sujet_id) {
const { data: sujetRow } = await supabase
.from('sujets')
.select('consigne, doc1_texte, doc2_texte')
.eq('id', production.sujet_id)
.single()
if (sujetRow) {
sujetConsigne = (sujetRow.consigne as string | null) ?? null
sourceDoc1 = (sujetRow.doc1_texte as string | null) ?? null
sourceDoc2 = (sujetRow.doc2_texte as string | null) ?? null
}
}
// 3. Lancer correction + modèle EN MÊME TEMPS.
// Le modèle démarre sans attendre la correction : on estime `nclcObtenu`
// à `nclcCible - 1` (ordre de grandeur plausible pour un candidat visant
// NCLC nclcCible). Cette valeur n'alimente que la phrase pédagogique du
// prompt modèle — pas la cible, qui reste fixée à NCLC 9.
const correctionPromise = deepseekCorrectEE({
tache,
contenu,
sujet: sujetConsigne,
sourceDoc1,
sourceDoc2,
nclcCible,
})
const nclcObtenuEstime = nclcCible - 1
void runModeleJob({
simulationId,
tache,
sujet: sujetConsigne,
texte: contenu,
nclcObtenu: nclcObtenuEstime,
})
let rapport: CorrectionRapport
try {
rapport = await correctionPromise
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const stack = err instanceof Error ? err.stack : undefined
console.error('[correctionController.correctEE] correction failed', {
simulationId,
tache,
nclcCible,
message,
stack,
})
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Erreur lors de la correction. Veuillez réessayer dans quelques instants.',
status: 500,
}
}
// 4. Persister la correction (statuts modèle/exercices : pending — les jobs sont lancés juste après)
const { error: updateError } = await supabase
.from('productions')
.update({
score: rapport.score,
nclc: rapport.nclc,
nclc_cible: rapport.nclc_cible,
revelation: rapport.revelation,
diagnostic: rapport.diagnostic,
conseil_nclc: rapport.conseil_nclc,
erreurs_codes: rapport.erreurs_codes,
rapport: JSON.stringify(rapport),
exercices_status: 'pending',
modele_status: 'pending',
})
.eq('id', simulationId)
if (updateError) {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Erreur lors de la sauvegarde du rapport. Veuillez réessayer.',
status: 500,
}
}
// 5. Lancer les exercices maintenant qu'on a rapport.erreurs_codes + criteres.
// Ne JAMAIS await — cette promesse vit après la réponse HTTP.
void runExercicesJob({ simulationId, tache, rapport })
// 6. Incrémenter simulations_used si le plan a une limite (non bloquant).
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
await supabase
.from('profiles')
.update({ simulations_used: profile.simulations_used + 1 })
.eq('id', profile.id)
}
return { data: { ...rapport, simulation_id: simulationId } }
}
// ── Jobs asynchrones — modèle + exercices ───────────────────────────────
interface ModeleJobInput {
simulationId: string
tache: TacheEE
sujet: string | null
texte: string
nclcObtenu: number
}
async function runModeleJob(input: ModeleJobInput): Promise<void> {
const { simulationId, tache, sujet, texte, nclcObtenu } = input
try {
const modele = await generateProductionModele({ tache, sujet, texte, nclcObtenu })
await supabase
.from('productions')
.update({ modele, modele_status: 'ready' })
.eq('id', simulationId)
} catch {
await supabase
.from('productions')
.update({ modele_status: 'error' })
.eq('id', simulationId)
}
}
interface ExercicesJobInput {
simulationId: string
tache: TacheEE
rapport: CorrectionRapport
}
async function runExercicesJob(input: ExercicesJobInput): Promise<void> {
const { simulationId, tache, rapport } = input
try {
const exercices = await generateExercices({
tache,
erreursCodes: rapport.erreurs_codes,
criteres: rapport.criteres,
})
await supabase
.from('productions')
.update({ exercices, exercices_status: 'ready' })
.eq('id', simulationId)
} catch {
await supabase
.from('productions')
.update({ exercices_status: 'error' })
.eq('id', simulationId)
}
}
// ── EO — inchangé par Sprint 3.6a ───────────────────────────────────────
export async function correctEO(
simulationId: string,
transcript: string,
tache: string,
profile: AuthProfile,
): Promise<{ data: EORapport & { simulation_id: string } } | CorrectionError> {
const { data: production, error: fetchError } = await supabase
.from('productions')
.select('id, user_id, tache')
.eq('id', simulationId)
.single()
if (fetchError || !production) {
return {
error: true,
code: 'SIMULATION_NOT_FOUND',
message: 'Simulation introuvable.',
status: 404,
}
}
if (production.user_id !== profile.id) {
return {
error: true,
code: 'AUTH_REQUIRED',
message: 'Cette simulation ne vous appartient pas.',
status: 401,
}
}
let rapport: EORapport
try {
rapport = await deepseekCorrectEO(transcript, tache)
} catch {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Erreur lors de la correction. Veuillez réessayer dans quelques instants.',
status: 500,
}
}
const { error: updateError } = await supabase
.from('productions')
.update({
contenu: transcript,
score: rapport.score,
nclc: rapport.nclc,
rapport: JSON.stringify(rapport),
})
.eq('id', simulationId)
if (updateError) {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Erreur lors de la sauvegarde du rapport. Veuillez réessayer.',
status: 500,
}
}
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
await supabase
.from('profiles')
.update({ simulations_used: profile.simulations_used + 1 })
.eq('id', profile.id)
}
return { data: { ...rapport, simulation_id: simulationId } }
}