/** * 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 NclcCible, type TacheEE, type TacheEO, type TacheCorrection, } 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. // ⚠️ RACE CONDITION — ne PAS inclure `modele_status` ni `exercices_status` // ici : runModeleJob (lancé en parallèle option b) peut avoir déjà terminé // et écrit 'ready' avant que cet update ne s'exécute. Écraser avec 'pending' // perdrait le résultat. // Les colonnes *_status sont initialisées à 'pending' par la migration // (DEFAULT) et gérées exclusivement par runModeleJob / runExercicesJob. 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), }) .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: TacheCorrection; sujet: string | null; texte: string; nclcObtenu: number; } async function runModeleJob(input: ModeleJobInput): Promise { const { simulationId, tache, sujet, texte, nclcObtenu } = input; console.log("[runModeleJob] START", { simulationId, tache, nclcObtenu }); try { const modele = await generateProductionModele({ tache, sujet, texte, nclcObtenu, }); console.log("[runModeleJob] DeepSeek OK, updating productions", { simulationId, modeleWordCount: modele.tcf_word_count, }); const { error: updateErr, data: updateData } = await supabase .from("productions") .update({ modele, modele_status: "ready" }) .eq("id", simulationId) .select("id, modele_status"); console.log("[runModeleJob] update result", { simulationId, updateErr, updateData, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; console.error("[runModeleJob] CAUGHT ERROR", { simulationId, message, stack, }); try { const { error: fallbackErr } = await supabase .from("productions") .update({ modele_status: "error" }) .eq("id", simulationId); console.log("[runModeleJob] fallback update result", { simulationId, fallbackErr, }); } catch (fallbackExc) { console.error("[runModeleJob] FALLBACK UPDATE THREW", { simulationId, message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), }); } } } interface ExercicesJobInput { simulationId: string; tache: TacheCorrection; rapport: CorrectionRapport; } async function runExercicesJob(input: ExercicesJobInput): Promise { const { simulationId, tache, rapport } = input; console.log("[runExercicesJob] START", { simulationId, tache, erreursCodesCount: rapport.erreurs_codes.length, }); try { const exercices = await generateExercices({ tache, erreursCodes: rapport.erreurs_codes, criteres: rapport.criteres, }); console.log("[runExercicesJob] DeepSeek OK, updating productions", { simulationId, exercicesCount: exercices.length, }); const { error: updateErr, data: updateData } = await supabase .from("productions") .update({ exercices, exercices_status: "ready" }) .eq("id", simulationId) .select("id, exercices_status"); console.log("[runExercicesJob] update result", { simulationId, updateErr, updateData, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; console.error("[runExercicesJob] CAUGHT ERROR", { simulationId, message, stack, }); try { const { error: fallbackErr } = await supabase .from("productions") .update({ exercices_status: "error" }) .eq("id", simulationId); console.log("[runExercicesJob] fallback update result", { simulationId, fallbackErr, }); } catch (fallbackExc) { console.error("[runExercicesJob] FALLBACK UPDATE THREW", { simulationId, message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), }); } } } // ── EO — Sprint 4b : transcript-only (audio géré côté frontend) ───────── // // Décision Sprint 4b : Deepgram en connexion directe navigateur ↔ Deepgram via // token éphémère (cf. /transcriptions/token). Le backend reçoit uniquement le // transcript final ; aucun audio n'est stocké côté serveur. // // Flux POST /corrections/eo : // 1. Vérifier que la production existe, appartient à l'utilisateur. // 2. Charger la consigne (utile au prompt EO). // 3. Lancer correction EO + modèle EO en parallèle (mêmes patterns que EE). // 4. Persister le rapport (revelation, diagnostic, conseil_nclc, erreurs_codes, // contenu = transcript). // 5. Lancer les exercices fire-and-forget. // 6. Incrémenter le quota. // // Le risque race-condition décrit dans correctEE s'applique aussi ici : on ne // touche PAS aux colonnes *_status dans l'update final. export interface CorrectEOInput { simulationId: string; tache: TacheEO; nclcCible: NclcCible; transcript: string; } export async function correctEO( input: CorrectEOInput, profile: AuthProfile, ): Promise< { data: CorrectionRapport & { simulation_id: string } } | CorrectionError > { const { simulationId, tache, nclcCible, transcript } = input; // 1. Vérifier la production + ownership. const { data: production, error: fetchError } = await supabase .from("productions") .select("id, user_id, tache, sujet_id") .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 la consigne (utile au prompt EO). let sujetConsigne: string | null = null; if (production.sujet_id) { const { data: sujetRow } = await supabase .from("sujets") .select("consigne") .eq("id", production.sujet_id) .single(); if (sujetRow) { sujetConsigne = (sujetRow.consigne as string | null) ?? null; } } // 3. Lancer correction EO + modèle EO en parallèle. const correctionPromise = deepseekCorrectEO( transcript, tache, nclcCible, sujetConsigne, ); const nclcObtenuEstime = nclcCible - 1; void runModeleJob({ simulationId, tache, sujet: sujetConsigne, texte: transcript, nclcObtenu: nclcObtenuEstime, }); let rapport: CorrectionRapport; try { rapport = await correctionPromise; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error("[correctionController.correctEO] correction failed", { simulationId, tache, nclcCible, message, }); return { error: true, code: "INTERNAL_ERROR", message: "Erreur lors de la correction. Veuillez réessayer dans quelques instants.", status: 500, }; } // 5. Persister le rapport. Pas de *_status (race condition — cf. correctEE). const { error: updateError } = await supabase .from("productions") .update({ contenu: transcript, 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), }) .eq("id", simulationId); if (updateError) { return { error: true, code: "INTERNAL_ERROR", message: "Erreur lors de la sauvegarde du rapport. Veuillez réessayer.", status: 500, }; } // 6. Exercices fire-and-forget. void runExercicesJob({ simulationId, tache, rapport }); // 7. Quota. 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 } }; }