/** * 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 { 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 { 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 } } }