expria-backend/src/controllers/correctionController.ts
Hermann_Kitio 7cac057062 feat(eo): align correction EO on 3.6a format + Deepgram token + T1 presentation generation
Sprint 4a:
- correctEO aligned on CorrectionRapport format (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)
- nclc_cible parameter (default 9, accepts 9|10)
- Fire-and-forget modele + exercices jobs (same pattern as EE)
- EO-specific DeepSeek prompt (oral transcript tolerance, 4 TCF criteria)
- Gemini transcribeAudio: 30s timeout + 1 retry
- POST /presentations/generate: 5-field questionnaire → DeepSeek generates oral presentation (~220-260 words, NCLC 7-8)
- Migration 006_sprint_4a_eo.sql (documentation only — no audio storage)

Sprint 4b:
- POST /transcriptions/token: Deepgram temporary API key (600s TTL)
- Removed audio storage pipeline (audioStorage.ts, XOR validation, 14MB limit)
- Backend receives transcript text only, no audio files
- TD-10/TD-11 resolved (Sprint 3.6c), TD-16/17/18 resolved (4b cleanup)

Typecheck: OK · Tests: 241/241 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 05:04:26 +03:00

459 lines
14 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 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<void> {
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<void> {
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 } };
}