/** * Client DeepSeek — Sprint 3.6a. * * Expose trois appels dédiés à la correction EE (entraînement / examen) : * 1. `correctEE` → prompt maître (rapport avec revelation, diagnostic, * critères détaillés, conseil_nclc, erreurs_codes) * 2. `generateProductionModele` → production modèle réécrite à NCLC 9 (fixe) * 3. `generateExercices` → 3 exercices ciblés sur les erreurs détectées * * Contrat JSON défini par docs/Prompt_maître.md et docs/Prompt_production_modèle.md. * Codes d'erreurs issus de src/lib/taxonomieErreurs.ts (validation runtime incluse). * * EO (Expression Orale) conserve le pipeline V1 monolithique (hors scope Sprint 3.6a). */ import { CRITERES, CRITERE_LABELS, NCLC_MIN_SCORE, buildTaxonomyPromptSection, isValidCode, isValidCritere, type Critere, } from './taxonomieErreurs.js' const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? '' const DEEPSEEK_BASE_URL = 'https://api.deepseek.com' // ── Types — Sprint 3.6a ────────────────────────────────────────────────── export type TacheEE = 'EE_T1' | 'EE_T2' | 'EE_T3' export type NclcCible = 9 | 10 export interface CorrectionInput { tache: TacheEE contenu: string sujet: string | null sourceDoc1?: string | null sourceDoc2?: string | null nclcCible: NclcCible } export interface CorrectionCritereDetail { nom: string score: number commentaire: string exemple: string suggestion: string astuce: string } export interface ErreurCode { code: string critere: Critere description: string | null } export interface CorrectionRapport { score: number nclc: number nclc_cible: NclcCible revelation: { croyance: string realite: string consequence: string } diagnostic: string criteres: CorrectionCritereDetail[] conseil_nclc: { nclc_cible: string ecart: string action_prioritaire: string } erreurs_codes: ErreurCode[] } export interface ProductionModeleInput { tache: TacheEE sujet: string | null texte: string nclcObtenu: number } export interface TransformationItem { original: string ameliore: string explication: string } export interface NotePedagogique { passage: string explication: string } export interface ProductionModele { production_modele_propre: string notes_pedagogiques: NotePedagogique[] transformations: TransformationItem[] message: string // Métadonnées ajoutées par le post-traitement serveur nclc_modele: 9 nclc_obtenu: number score_cible: number tcf_word_count: number tcf_word_min: number tcf_word_max: number tcf_truncated: boolean } export interface ExercicesInput { tache: TacheEE erreursCodes: ErreurCode[] criteres: CorrectionCritereDetail[] } export interface ExerciceItem { difficulte: 'facile' | 'intermediaire' | 'difficile' theme: string diagnostic: string consigne: string extrait: string indice: string correction: string explication: string } // Longueurs TCF Canada par tâche (docs/Prompt_production_modèle.md §LONGUEUR) const WORD_LIMITS: Record = { EE_T1: { min: 60, max: 120 }, EE_T2: { min: 120, max: 150 }, EE_T3: { min: 120, max: 180 }, } const TASK_DESCRIPTIONS: Record = { EE_T1: 'Tâche 1 — Message / mail / annonce (60-120 mots) : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne.', EE_T2: 'Tâche 2 — Article de blog / forum (120-150 mots) : compte rendu d\'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif.', EE_T3: 'Tâche 3 — Texte comparatif (120-180 mots) : Partie 1 (40-60 mots) synthèse des deux points de vue des documents sources ; Partie 2 (80-120 mots) prise de position personnelle argumentée.', } // ── Prompts builders ───────────────────────────────────────────────────── /** * Prompt maître — correction EE. * Retourne le couple (system, user) à envoyer à DeepSeek. */ export function buildCorrectionPrompt(input: CorrectionInput): { system: string user: string } { const { tache, contenu, sujet, sourceDoc1, sourceDoc2, nclcCible } = input const minScore = NCLC_MIN_SCORE[nclcCible] const taxonomySection = buildTaxonomyPromptSection() const system = `Tu es un correcteur TCF Canada certifié par France Éducation International. Tu corriges avec précision et bienveillance. RÈGLES ABSOLUES : - "exemple" = citation textuelle EXACTE, mot pour mot, extraite de la production du candidat. Jamais inventée. - "commentaire" = 2 phrases maximum, directes, sans formule introductive. - Interdit : "Voici", "Bien sûr", "Il convient de", toute formule introductive, tout markdown, tout backtick. - "score" global = somme exacte des 4 scores critères (0 à 20). - JSON strict sans aucun texte avant ni après. CRITÈRES OFFICIELS TCF (chacun noté de 0 à 5) : 1. ${CRITERE_LABELS.adequation_tache} — respect des consignes, longueur, registre, pertinence du contenu. 2. ${CRITERE_LABELS.coherence_cohesion} — structure logique, connecteurs, progression thématique. 3. ${CRITERE_LABELS.competence_lexicale} — étendue du vocabulaire, précision, variété, absence de répétitions excessives. 4. ${CRITERE_LABELS.competence_grammaticale} — correction des structures, morphologie verbale, syntaxe, ponctuation. ${taxonomySection} FORMAT DE RÉPONSE (JSON strict, aucun autre texte) : { "score": , "nclc": , "revelation": { "croyance": "", "realite": "", "consequence": "" }, "diagnostic": "", "criteres": [ { "nom": "${CRITERE_LABELS.adequation_tache}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "", "suggestion": "", "astuce": "" }, { "nom": "${CRITERE_LABELS.coherence_cohesion}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "", "suggestion": "", "astuce": "" }, { "nom": "${CRITERE_LABELS.competence_lexicale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "", "suggestion": "", "astuce": "" }, { "nom": "${CRITERE_LABELS.competence_grammaticale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "", "suggestion": "", "astuce": "" } ], "conseil_nclc": { "nclc_cible": "NCLC ${nclcCible}", "ecart": "", "action_prioritaire": "" }, "erreurs_codes": [ { "code": "", "critere": "", "description": } ] }` const docsBlock = tache === 'EE_T3' && (sourceDoc1 || sourceDoc2) ? `\n\nDOCUMENTS SOURCES : Document 1 (point de vue POUR) : ${sourceDoc1 ?? 'Non précisé'} Document 2 (point de vue CONTRE) : ${sourceDoc2 ?? 'Non précisé'}` : '' const user = `OBJECTIF DU CANDIDAT : NCLC ${nclcCible} — score minimum requis : ${minScore}/20. TÂCHE : ${TASK_DESCRIPTIONS[tache]}${docsBlock} CONSIGNE / SUJET : ${sujet ?? 'Non précisé'} PRODUCTION DU CANDIDAT : """ ${contenu} """` return { system, user } } /** * Prompt production modèle — cible fixe NCLC 9 (cf. consigne Sprint 3.6a). */ export function buildModelPrompt(input: ProductionModeleInput): { system: string user: string } { const { tache, sujet, texte, nclcObtenu } = input const nclcModele: 9 = 9 const scoreCible = NCLC_MIN_SCORE[nclcModele] const { min, max } = WORD_LIMITS[tache] const system = `Tu es un correcteur expert TCF Canada. Ta mission : réécrire la production du candidat EN CONSERVANT le fond, les idées, le positionnement et les arguments — mais en appliquant parfaitement les 4 critères officiels TCF Canada : 1. Réalisation de la tâche — respecter le format, les limites de mots, la consigne, le registre 2. Cohérence / Structure — paragraphes clairs, connecteurs logiques variés, progression cohérente 3. Étendue du lexique — vocabulaire riche et précis, zéro répétition, registre adapté 4. Maîtrise grammaticale — structures complexes, subjonctif, passif, subordination RÈGLES ABSOLUES : - Conserver les idées et arguments du candidat — ne pas inventer - Respecter STRICTEMENT les limites de mots pour production_modele_propre (maximum : ${max} mots) - Viser exactement le niveau NCLC ${nclcModele} (score minimum ${scoreCible}/20) - Aucune annotation dans production_modele_propre (pas de [NOTE:], pas de commentaire entre parenthèses) - Exactement 3 entrées dans notes_pedagogiques - Répondre en JSON valide, sans markdown, sans texte avant ni après COMPTAGE DES MOTS (TCF Canada) : - Un mot = segment séparé par un espace. L'apostrophe (' ou ') et le tiret (-) ne créent pas un mot supplémentaire. - Exemples : « c'est », « aujourd'hui », « c'est-à-dire », « vas-y » = 1 mot chacun. LONGUEUR pour cette tâche : ${min} à ${max} mots — ne pas dépasser ${max}. FORMAT JSON (strict) : { "production_modele_propre": "", "notes_pedagogiques": [ { "passage": "", "explication": "" } ], "transformations": [ { "original": "", "ameliore": "", "explication": "" } ], "message": "" }` const user = `SUJET : ${sujet ?? 'Non précisé'} TÂCHE : ${TASK_DESCRIPTIONS[tache]} PRODUCTION DU CANDIDAT : ${texte} Le candidat a obtenu NCLC ${nclcObtenu}. Montre-lui comment atteindre NCLC ${nclcModele}.` return { system, user } } /** * Prompt exercices — 3 exercices ciblés sur les erreurs_codes les plus saillantes. * Format aligné sur les captures d'écran (cf. plan session). */ export function buildExercicesPrompt(input: ExercicesInput): { system: string user: string } { const { tache, erreursCodes, criteres } = input const system = `Tu es un coach TCF Canada. Tu produis des micro-exercices ciblés pour faire travailler un candidat sur ses erreurs réelles. RÈGLES ABSOLUES : - Produire EXACTEMENT 3 exercices, ciblés sur les 3 codes d'erreurs les plus impactants fournis en entrée. - "extrait" = citation textuelle exacte du candidat (tirée des champs "exemple" des critères quand pertinent). Jamais inventée. - "correction" = la version corrigée de "extrait". - Aucune formule introductive, aucun markdown, aucun backtick. - JSON strict sans aucun texte avant ni après. FORMAT JSON : { "exercices": [ { "difficulte": "facile" | "intermediaire" | "difficile", "theme": "", "diagnostic": "<1 phrase : quelle erreur cet exercice cible>", "consigne": "", "extrait": "", "indice": "", "correction": "", "explication": "" } ] }` const erreursBlock = erreursCodes .map((e) => `- ${e.code} (${e.critere})${e.description ? ` : ${e.description}` : ''}`) .join('\n') const criteresBlock = criteres .map((c) => `- ${c.nom} (score ${c.score}/5) — exemple : « ${c.exemple} »`) .join('\n') const user = `TÂCHE : ${TASK_DESCRIPTIONS[tache]} ERREURS DÉTECTÉES DANS LA PRODUCTION : ${erreursBlock || '(aucune erreur listée)'} EXTRAITS PAR CRITÈRE (pour alimenter "extrait") : ${criteresBlock} Produis 3 exercices ciblés. Privilégie les codes d'erreurs qui apparaissent le plus souvent, puis les plus impactants pour l'objectif NCLC.` return { system, user } } // ── Post-traitement production modèle ─────────────────────────────────── /** * Compte des mots TCF Canada : un mot = segment séparé par un espace. * Apostrophes et tirets ne créent pas de mot supplémentaire. */ export function wordCountTCF(text: string): number { const trimmed = text.trim() if (trimmed.length === 0) return 0 return trimmed.split(/\s+/).length } /** * Supprime les annotations [NOTE: ...] et les commentaires entre parenthèses * ajoutés par erreur par DeepSeek malgré la consigne. */ export function stripModelAnnotations(text: string): string { return text .replace(/\[NOTE:[^\]]*\]/gi, '') .replace(/\s{2,}/g, ' ') .trim() } /** * Tronque à `maxWords` mots TCF. Retourne {text, truncated}. */ export function truncateToMaxWords(text: string, maxWords: number): { text: string; truncated: boolean } { const words = text.trim().split(/\s+/) if (words.length <= maxWords) return { text, truncated: false } return { text: words.slice(0, maxWords).join(' '), truncated: true } } // ── Appels DeepSeek ────────────────────────────────────────────────────── async function callDeepSeek(system: string, user: string, temperature: number): Promise { try { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ model: 'deepseek-chat', messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], temperature, response_format: { type: 'json_object' }, }), // Le prompt maître + taxonomie produit une réponse JSON longue : DeepSeek // peut prendre 20-40 s. Le frontend abort à 60 s (CORRECTION_TIMEOUT_MS) // → on abort ici à 55 s pour laisser une marge côté client. signal: AbortSignal.timeout(55_000), }) if (!response.ok) { throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) } const data = (await response.json()) as { choices?: { message?: { content?: string } }[] } const content = data.choices?.[0]?.message?.content if (!content) { throw new Error('DeepSeek API: réponse vide') } return content } catch (err) { const kind = err instanceof Error && err.name === 'TimeoutError' ? 'TIMEOUT' : err instanceof Error && err.name === 'AbortError' ? 'ABORT' : err instanceof SyntaxError ? 'JSON_PARSE' : err instanceof TypeError ? 'NETWORK' : 'OTHER' const message = err instanceof Error ? err.message : String(err) console.error(`[deepseek.callDeepSeek] ${kind} — ${message}`) throw err } } // ── Validation runtime ─────────────────────────────────────────────────── function validateErreursCodes(raw: unknown): ErreurCode[] { if (!Array.isArray(raw)) return [] const valid: ErreurCode[] = [] for (const item of raw) { if (typeof item !== 'object' || item === null) continue const o = item as { code?: unknown; critere?: unknown; description?: unknown } if (typeof o.code !== 'string' || typeof o.critere !== 'string') continue if (!isValidCritere(o.critere)) continue if (!isValidCode(o.critere, o.code)) continue const description = typeof o.description === 'string' && o.description.trim().length > 0 ? o.description : null if (o.code === 'autre' && description === null) continue // autre exige une description valid.push({ code: o.code, critere: o.critere, description }) } return valid } function validateCorrectionRapport(raw: unknown, nclcCible: NclcCible): CorrectionRapport { if (typeof raw !== 'object' || raw === null) { throw new Error('Réponse DeepSeek invalide : racine non-objet') } const r = raw as Record const score = typeof r.score === 'number' ? r.score : Number(r.score) if (!Number.isFinite(score) || score < 0 || score > 20) { throw new Error(`Score invalide: ${String(r.score)} (attendu 0-20)`) } const nclc = typeof r.nclc === 'number' ? r.nclc : Number(r.nclc) if (!Number.isFinite(nclc) || nclc < 4 || nclc > 12) { throw new Error(`NCLC invalide: ${String(r.nclc)} (attendu 4-12)`) } const revelation = r.revelation as Record | undefined if ( !revelation || typeof revelation.croyance !== 'string' || typeof revelation.realite !== 'string' || typeof revelation.consequence !== 'string' ) { throw new Error('revelation invalide : attendu { croyance, realite, consequence } en chaînes') } if (typeof r.diagnostic !== 'string' || r.diagnostic.trim().length === 0) { throw new Error('diagnostic invalide : chaîne non vide attendue') } if (!Array.isArray(r.criteres) || r.criteres.length !== 4) { throw new Error('criteres invalide : 4 entrées attendues') } const criteres: CorrectionCritereDetail[] = r.criteres.map((c: unknown, i: number) => { const o = c as Record if (typeof o?.nom !== 'string') throw new Error(`criteres[${i}].nom invalide`) const cScore = typeof o.score === 'number' ? o.score : Number(o.score) if (!Number.isFinite(cScore) || cScore < 0 || cScore > 5) { throw new Error(`criteres[${i}].score invalide`) } return { nom: o.nom, score: cScore, commentaire: typeof o.commentaire === 'string' ? o.commentaire : '', exemple: typeof o.exemple === 'string' ? o.exemple : '', suggestion: typeof o.suggestion === 'string' ? o.suggestion : '', astuce: typeof o.astuce === 'string' ? o.astuce : '', } }) const conseil = r.conseil_nclc as Record | undefined if ( !conseil || typeof conseil.nclc_cible !== 'string' || typeof conseil.ecart !== 'string' || typeof conseil.action_prioritaire !== 'string' ) { throw new Error('conseil_nclc invalide') } const erreursCodes = validateErreursCodes(r.erreurs_codes) return { score, nclc, nclc_cible: nclcCible, revelation: { croyance: revelation.croyance, realite: revelation.realite, consequence: revelation.consequence, }, diagnostic: r.diagnostic, criteres, conseil_nclc: { nclc_cible: conseil.nclc_cible, ecart: conseil.ecart, action_prioritaire: conseil.action_prioritaire, }, erreurs_codes: erreursCodes, } } // ── Fonctions exportées — correction + modèle + exercices ─────────────── export async function correctEE(input: CorrectionInput): Promise { const { system, user } = buildCorrectionPrompt(input) const content = await callDeepSeek(system, user, 0.2) const parsed: unknown = JSON.parse(content) return validateCorrectionRapport(parsed, input.nclcCible) } export async function generateProductionModele(input: ProductionModeleInput): Promise { const { system, user } = buildModelPrompt(input) const content = await callDeepSeek(system, user, 0.3) const parsed = JSON.parse(content) as Record if (typeof parsed.production_modele_propre !== 'string') { throw new Error('production_modele_propre invalide : chaîne attendue') } const cleaned = stripModelAnnotations(parsed.production_modele_propre) const { min, max } = WORD_LIMITS[input.tache] const { text: final, truncated } = truncateToMaxWords(cleaned, max) const count = wordCountTCF(final) const notes = Array.isArray(parsed.notes_pedagogiques) ? (parsed.notes_pedagogiques as unknown[]) .map((n) => n as Record) .filter((n) => typeof n.passage === 'string' && typeof n.explication === 'string') .map((n) => ({ passage: n.passage as string, explication: n.explication as string })) : [] const transformations = Array.isArray(parsed.transformations) ? (parsed.transformations as unknown[]) .map((t) => t as Record) .filter( (t) => typeof t.original === 'string' && typeof t.ameliore === 'string' && typeof t.explication === 'string' ) .map((t) => ({ original: t.original as string, ameliore: t.ameliore as string, explication: t.explication as string, })) : [] return { production_modele_propre: final, notes_pedagogiques: notes, transformations, message: typeof parsed.message === 'string' ? parsed.message : '', nclc_modele: 9, nclc_obtenu: input.nclcObtenu, score_cible: NCLC_MIN_SCORE[9], tcf_word_count: count, tcf_word_min: min, tcf_word_max: max, tcf_truncated: truncated, } } export async function generateExercices(input: ExercicesInput): Promise { const { system, user } = buildExercicesPrompt(input) const content = await callDeepSeek(system, user, 0.4) const parsed = JSON.parse(content) as { exercices?: unknown } if (!Array.isArray(parsed.exercices)) { throw new Error('exercices invalide : tableau attendu') } const DIFFICULTES: ExerciceItem['difficulte'][] = ['facile', 'intermediaire', 'difficile'] return (parsed.exercices as unknown[]) .map((e) => e as Record) .filter((e) => typeof e.consigne === 'string' && typeof e.correction === 'string') .map((e) => ({ difficulte: DIFFICULTES.includes(e.difficulte as ExerciceItem['difficulte']) ? (e.difficulte as ExerciceItem['difficulte']) : 'intermediaire', theme: typeof e.theme === 'string' ? e.theme : '', diagnostic: typeof e.diagnostic === 'string' ? e.diagnostic : '', consigne: e.consigne as string, extrait: typeof e.extrait === 'string' ? e.extrait : '', indice: typeof e.indice === 'string' ? e.indice : '', correction: e.correction as string, explication: typeof e.explication === 'string' ? e.explication : '', })) } // ── EO (Expression Orale) — inchangé par Sprint 3.6a ──────────────────── export interface EOCritere { nom: string score: number commentaire: string } export interface EORapport { score: number nclc: number feedback_court: string criteres: EOCritere[] erreurs: string[] modele: string idees: string[] exercices: string[] } const SYSTEM_PROMPT_EO = `Tu es un examinateur officiel du TCF Canada (Test de connaissance du français). Tu évalues une production orale à partir de sa transcription selon les 4 critères officiels de l'Expression Orale : 1. Cohérence et cohésion 2. Lexique (étendue et maîtrise du vocabulaire) 3. Morphosyntaxe (grammaire et structures) 4. Phonologie — NOTE IMPORTANTE : ce critère est fixé à 0 car l'évaluation se fait sur une transcription textuelle, pas sur l'audio original. Mets toujours 0 pour ce critère. Tu dois retourner un JSON strict avec cette structure exacte : { "score": , "nclc": , "feedback_court": "<2 à 3 lignes de feedback global, orientées action>", "criteres": [ { "nom": "Cohérence et cohésion", "score": , "commentaire": "" }, { "nom": "Lexique", "score": , "commentaire": "" }, { "nom": "Morphosyntaxe", "score": , "commentaire": "" }, { "nom": "Phonologie", "score": 0, "commentaire": "Non évalué sur transcription textuelle." } ], "erreurs": ["", "", ...], "modele": "", "idees": ["", "", ...], "exercices": ["", "", ...] } Règles : - score est la note globale sur 20 (basée uniquement sur les 3 critères évalués) - nclc est le niveau NCLC estimé (entre 4 et 12) - feedback_court est un résumé de 2 à 3 lignes, toujours renseigné (visible pour tous les plans) - Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle." - Retourne UNIQUEMENT le JSON, sans texte avant ni après` const SYSTEM_PROMPT_IDEES = `Tu es un coach TCF Canada. Tu aides un étudiant à continuer sa rédaction en cours. Tu dois retourner UNIQUEMENT un JSON strict : { "idees": ["", "", ...] } Règles : - Exactement 5 idées courtes et concrètes (1 phrase max chacune) - Les idées doivent prolonger ce que l'étudiant a déjà écrit, sans répéter - Rester en français, ton encourageant, orienté action - Aucun texte avant ni après le JSON` export async function generateIdees(consigne: string, contenu: string): Promise { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ model: 'deepseek-chat', messages: [ { role: 'system', content: SYSTEM_PROMPT_IDEES }, { role: 'user', content: `Sujet : ${consigne}\n\nCe que l'étudiant a écrit jusqu'ici :\n${contenu}`, }, ], temperature: 0.5, response_format: { type: 'json_object' }, }), signal: AbortSignal.timeout(15_000), }) if (!response.ok) { throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) } const data = (await response.json()) as { choices?: { message?: { content?: string } }[] } const content = data.choices?.[0]?.message?.content if (!content) { throw new Error('DeepSeek API: réponse vide') } const parsed = JSON.parse(content) as { idees?: unknown } if (!Array.isArray(parsed.idees) || parsed.idees.length === 0) { throw new Error('Réponse DeepSeek invalide : idees doit être un tableau non vide') } const idees = parsed.idees.filter( (i): i is string => typeof i === 'string' && i.trim().length > 0, ) if (idees.length === 0) { throw new Error('Réponse DeepSeek invalide : aucune idée exploitable') } return idees } export async function correctEO(transcript: string, tache: string): Promise { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ model: 'deepseek-chat', messages: [ { role: 'system', content: SYSTEM_PROMPT_EO }, { role: 'user', content: `Tâche : ${tache}\n\nTranscription de la production orale :\n${transcript}`, }, ], temperature: 0.3, response_format: { type: 'json_object' }, }), }) if (!response.ok) { throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) } const data = (await response.json()) as { choices?: { message?: { content?: string } }[] } const content = data.choices?.[0]?.message?.content if (!content) { throw new Error('DeepSeek API: réponse vide') } const rapport: EORapport = JSON.parse(content) if (rapport.score < 0 || rapport.score > 20) { throw new Error(`Score invalide: ${rapport.score} (attendu 0-20)`) } if (rapport.nclc < 4 || rapport.nclc > 12) { throw new Error(`NCLC invalide: ${rapport.nclc} (attendu 4-12)`) } if (typeof rapport.feedback_court !== 'string' || rapport.feedback_court.trim().length === 0) { throw new Error('feedback_court invalide: attendu une chaîne non vide') } return rapport } // Alias legacy — temporairement conservé le temps que correctionController.correctEE // soit migré vers la nouvelle signature (étape E5). export type EERapport = CorrectionRapport