From 63bc43ddcf6be41b5b1b2e4350265e5d05f40927 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Wed, 22 Apr 2026 17:27:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(corrections):=20Sprint=203.6a=20=E2=80=94?= =?UTF-8?q?=20nouveaux=20prompts=20+=20taxonomie=20erreurs=20+=20g=C3=A9n?= =?UTF-8?q?=C3=A9ration=20parall=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG.md | 38 + docs/Prompt_maître.md | 153 ++++ docs/Prompt_production_modèle.md | 164 +++++ docs/TECH_DEBT.md | 14 + .../__tests__/correctionController.test.ts | 421 +++++++++++ .../__tests__/simulationController.test.ts | 89 +++ src/controllers/correctionController.ts | 183 ++++- src/controllers/simulationController.ts | 41 +- src/lib/__tests__/deepseek.test.ts | 549 ++++++++++---- src/lib/deepseek.ts | 693 +++++++++++++++--- src/lib/taxonomieErreurs.ts | 151 ++++ src/routes/corrections.ts | 34 +- src/routes/simulations.ts | 32 +- .../004_sprint_3_6a_qualite_correction.sql | 39 + 14 files changed, 2319 insertions(+), 282 deletions(-) create mode 100644 docs/CHANGELOG.md create mode 100644 docs/Prompt_maître.md create mode 100644 docs/Prompt_production_modèle.md create mode 100644 src/controllers/__tests__/correctionController.test.ts create mode 100644 src/lib/taxonomieErreurs.ts create mode 100644 supabase/migrations/004_sprint_3_6a_qualite_correction.sql diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..1a942a6 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog — Expria Backend + +Toutes les modifications notables du backend sont documentées dans ce fichier. + +Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). + +--- + +## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction Backend + +### Added +- Nouveaux prompts DeepSeek spécifiés dans `docs/Prompt_maître.md` et `docs/Prompt_production_modèle.md` — builders dynamiques `buildCorrectionPrompt`, `buildModelPrompt`, `buildExercicesPrompt` dans `src/lib/deepseek.ts`. +- `expria-frontend/docs/TAXONOMIE_ERREURS.md` — 63 codes d'erreurs TCF Canada sur 4 critères + 4 codes « autre ». Validation runtime via `src/lib/taxonomieErreurs.ts` (`isValidCode`, `isValidCritere`, `buildTaxonomyPromptSection`). Codes invalides retournés par DeepSeek sont filtrés ; le code `autre` sans description est rejeté. +- Génération parallèle correction + modèle — option (b) : `generateProductionModele` démarre en même temps que `correctEE` avec `nclcObtenu = nclcCible - 1` comme estimation provisoire, `await` uniquement sur la correction pour répondre à la requête HTTP. +- Exercices personnalisés fire-and-forget déclenchés après la résolution de la correction (dépendent de `rapport.erreurs_codes` et `rapport.criteres`). Format aligné sur les captures d'écran : `{difficulte, theme, diagnostic, consigne, extrait, indice, correction, explication}`. +- Nouveaux champs dans `productions` : `revelation` (JSONB), `diagnostic` (TEXT), `conseil_nclc` (JSONB), `erreurs_codes` (JSONB), `exercices` (JSONB), `modele` (JSONB), `nclc_cible` (INTEGER), `exercices_status` / `modele_status` (TEXT, 'pending'/'ready'/'error'). +- Migration SQL `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` — première migration versionnée du projet (cf. backend TD-06) ; idempotente grâce à `IF NOT EXISTS`. +- Paramètre `nclc_cible` optionnel sur `POST /corrections/ee` (défaut 9, valeurs acceptées : 9 ou 10 ; sinon 400 VALIDATION_ERROR). +- Index GIN sur `erreurs_codes` pour préparer l'agrégation du Sprint 3.6c (analyse patterns). +- Nouveau fichier de tests `src/controllers/__tests__/correctionController.test.ts` — 8 tests (parallélisme option b, statuts ready/error, nclc_cible propagé, simulation introuvable, autre utilisateur). +- 2 tests ajoutés à `simulationController.test.ts` — `getById` renvoie `nclc_cible`, `exercices`, `modele` + statuts. +- Logs d'erreur détaillés : `callDeepSeek` classifie TIMEOUT / ABORT / JSON_PARSE / NETWORK / OTHER ; `correctionController.correctEE` logue `{simulationId, tache, nclcCible, message, stack}` avant de retourner 500. +- FTD-23 🟡 ajoutée dans `expria-frontend/docs/TECH_DEBT.md` — `useAutosave` peut fire un PATCH `/simulations/:id/contenu` après correction, ce qui retourne 400 VALIDATION_ERROR. À corriger dans une session dédiée (préexistant au Sprint 3.6a, détecté lors des tests manuels). + +### Changed +- `correctEE` dans `src/lib/deepseek.ts` — nouvelle signature `correctEE(CorrectionInput)` (contenu, tache, sujet, sourceDoc1/2, nclcCible) et nouvelle forme de retour `CorrectionRapport` (revelation, diagnostic, criteres avec exemple/suggestion/astuce, conseil_nclc, erreurs_codes). `EERapport` devient alias de `CorrectionRapport`. EO inchangé. +- `correctionController.correctEE` — charge le sujet + documents T3 depuis Supabase pour alimenter le prompt maître ; persiste les nouveaux champs (revelation, diagnostic, conseil_nclc, erreurs_codes, nclc_cible) + statuts pending initiaux ; lance `runModeleJob` en parallèle (option b) et `runExercicesJob` après correction. +- `simulationController.getById` — retourne désormais `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` en plus du `rapport` enrichi ; fallback `'pending'` si les colonnes sont absentes (compat avec productions pré-migration). +- Timeout DeepSeek côté backend : `callDeepSeek` abort à **55 s** via `AbortSignal.timeout(55_000)` (avant : aucun timeout) ; timeout frontend corrections monte de **30 s à 60 s** — marge de 5 s entre abort backend et abort client. +- Routes `/simulations/*` : réorganisation défensive — les `PATCH /:id/contenu` et `PATCH /:id/sujet` sont déclarées avant `GET /:id` pour éviter tout risque de masquage. +- `deepseek.test.ts` réécrit (25 tests) — couvre correctEE nouvelle signature, generateProductionModele, generateExercices, helpers post-traitement, EO inchangé. + +### Notes +- **Option A retenue** pour la compatibilité frontend : backend renvoie uniquement la nouvelle forme. Le Sprint 3.6b (frontend) est immédiatement suivant et corrige l'écran blanc sur `RapportPage`. +- **Option (b) retenue** pour le parallélisme : modèle en parallèle avec correction (nclcObtenu estimé), exercices strictement après correction. +- Migration SQL à exécuter manuellement via `supabase db push` ou SQL Editor du dashboard (cf. Règle F) — aucune exécution automatique. +- Tests : **174 tests verts** (+19 vs baseline 155), 18 fichiers de tests. +- TD-15 🟡 ouvert : si le process redémarre pendant un job fire-and-forget (modèle/exercices), le statut reste `pending` indéfiniment. À traiter après observation en production. diff --git a/docs/Prompt_maître.md b/docs/Prompt_maître.md new file mode 100644 index 0000000..11ed957 --- /dev/null +++ b/docs/Prompt_maître.md @@ -0,0 +1,153 @@ +# Prompt Maître — Correction Expression Écrite TCF Canada + +> **Source :** `app/api/corriger/route.ts` → fonction `buildPrompt()` +> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.2` · `response_format: json_object` + +--- + +## Contexte & Variables dynamiques + +| Variable | Description | Valeur par défaut | +|---|---|---| +| `nclc` | Niveau NCLC cible du candidat (7 à 10) | `9` | +| `minScore` | Score minimum requis sur 20 | `14` (NCLC 9) | +| `taskDesc` | Description de la tâche (voir ci-dessous) | — | +| `sujet` | Consigne ou sujet donné au candidat | `"Non précisé"` | +| `texte` | Production écrite du candidat | — | +| `sourceDoc1` | Document POUR (Tâche 3 uniquement) | — | +| `sourceDoc2` | Document CONTRE (Tâche 3 uniquement) | — | + +### Barème NCLC → score minimum + +| NCLC | Score minimum /20 | +|---|---| +| 7 | 10 | +| 8 | 12 | +| 9 | 14 | +| 10 | 16 | + +--- + +## Descriptions des tâches + +### Tâche 1 — Expression Écrite +> Message / mail / annonce **(60-120 mots)** : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne. + +### Tâche 2 — Expression Écrite +> Article de blog / forum **(120-150 mots)** : compte rendu d'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif. + +### Tâche 3 — Expression Écrite +> 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 + +--- + +## Prompt envoyé au modèle + +``` +Tu es un correcteur TCF Canada certifié par France Éducation International. Tu corriges avec précision et bienveillance. + +OBJECTIF DU CANDIDAT : NCLC {nclc} — score minimum requis : {minScore}/20. + +TÂCHE : {taskDesc} + +[SI TÂCHE 3] +DOCUMENTS SOURCES : +Document 1 (point de vue POUR) : {sourceDoc1} +Document 2 (point de vue CONTRE) : {sourceDoc2} +[FIN SI TÂCHE 3] + +CONSIGNE / SUJET : {sujet} + +PRODUCTION DU CANDIDAT : +""" +{texte} +""" + +CRITÈRES OFFICIELS TCF (chacun noté de 0 à 5) : +1. Adéquation à la tâche et au registre — respect des consignes, longueur, registre (formel/informel), pertinence du contenu. +2. Cohérence et cohésion du discours — structure logique, connecteurs, progression thématique, lisibilité globale. +3. Compétence lexicale — étendue du vocabulaire, précision, variété, absence de répétitions excessives. +4. Compétence grammaticale — correction des structures, morphologie verbale, syntaxe, ponctuation. + +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. +``` + +--- + +## Structure de la réponse JSON attendue + +```json +{ + "score": "", + "revelation": { + "croyance": "", + "realite": "", + "consequence": "" + }, + "diagnostic": "", + "criteres": [ + { + "nom": "Adéquation à la tâche et au registre", + "score": "<0-5>", + "commentaire": "<2 phrases max>", + "exemple": "", + "suggestion": "", + "astuce": "" + }, + { + "nom": "Cohérence et cohésion du discours", + "score": "<0-5>", + "commentaire": "<2 phrases max>", + "exemple": "", + "suggestion": "", + "astuce": "" + }, + { + "nom": "Compétence lexicale", + "score": "<0-5>", + "commentaire": "<2 phrases max>", + "exemple": "", + "suggestion": "", + "astuce": "" + }, + { + "nom": "Compétence grammaticale", + "score": "<0-5>", + "commentaire": "<2 phrases max>", + "exemple": "", + "suggestion": "", + "astuce": "" + } + ], + "conseil_nclc": { + "nclc_cible": "NCLC {nclc}", + "ecart": "", + "action_prioritaire": "" + } +} +``` + +--- + +## Champs expliqués + +| Champ | Rôle | +|---|---| +| `score` | Note globale /20 = somme stricte des 4 critères | +| `revelation.croyance` | Perception erronée du candidat sur sa production | +| `revelation.realite` | Constat objectif du correcteur | +| `revelation.consequence` | Impact de cet écart sur la note finale | +| `diagnostic` | Diagnostic court : le frein principal identifié | +| `criteres[].commentaire` | Observation directe, 2 phrases max, sans introduction | +| `criteres[].exemple` | Citation **mot pour mot** tirée du texte du candidat | +| `criteres[].suggestion` | Reformulation ou correction concrète de l'exemple | +| `criteres[].astuce` | Conseil mémorisable pour progresser sur ce critère | +| `conseil_nclc.ecart` | Distance entre le score obtenu et l'objectif NCLC | +| `conseil_nclc.action_prioritaire` | Plan d'action personnalisé et prioritaire | diff --git a/docs/Prompt_production_modèle.md b/docs/Prompt_production_modèle.md new file mode 100644 index 0000000..cbaefe3 --- /dev/null +++ b/docs/Prompt_production_modèle.md @@ -0,0 +1,164 @@ +# Prompt Maître — Génération de la Production Modèle TCF Canada + +> **Source :** `app/api/modele/route.ts` → handler `POST` (ligne 115) +> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.3` · `max_tokens: 2200` + +--- + +## Principe de fonctionnement + +Le prompt **réécrit la production du candidat** un niveau NCLC au-dessus de son score obtenu, en conservant intégralement ses idées et arguments. Il ne génère pas un texte de zéro. + +``` +nclcModele = min(nclcObtenu + 1, 10) +``` + +--- + +## Variables dynamiques + +| Variable | Description | Exemple | +|---|---|---| +| `sujet` | Consigne ou sujet donné au candidat | `"Écrivez un mail à votre voisin..."` | +| `taskDescription` | Description officielle de la tâche (voir ci-dessous) | — | +| `texte` | Production originale du candidat | — | +| `nclcObtenu` | Niveau NCLC réellement atteint par le candidat (7–10) | `8` | +| `nclcModele` | Niveau NCLC cible de la production modèle (`nclcObtenu + 1`) | `9` | +| `scoreModele` | Score minimum requis pour atteindre `nclcModele` | `14` | + +### Barème NCLC → score minimum + +| NCLC | Score minimum /20 | +|---|---| +| 7 | 10 | +| 8 | 12 | +| 9 | 14 | +| 10 | 16 | + +--- + +## Descriptions des tâches + +### Tâche 1 +> Expression écrite — Tâche 1 : Message / mail / annonce **(60-120 mots)**. Respect du registre (formel ou informel), salutation, corps du message, formule de clôture et signature. + +### Tâche 2 +> Expression écrite — Tâche 2 : Article de blog ou forum **(120-150 mots)**. Accroche, récit personnel à la 1re personne, opinion argumentée, conseil au lecteur. + +### Tâche 3 +> Expression écrite — Tâche 3 : Texte comparatif **(120-180 mots)**. Partie 1 (40-60 mots) : présentation neutre des deux documents. Partie 2 (80-120 mots) : prise de position personnelle argumentée. + +--- + +## Prompt envoyé au modèle + +``` +Tu es un correcteur expert TCF Canada. + +Le candidat a rédigé cette production sur le sujet suivant : + +SUJET : {sujet} + +TÂCHE : {taskDescription} + +PRODUCTION DU CANDIDAT : +{texte} + +Le candidat a obtenu NCLC {nclcObtenu}. Ta mission est de lui montrer comment atteindre NCLC {nclcModele} (score minimum {scoreModele}/20). + +Ta mission : réécrire cette production EN CONSERVANT le fond, les idées, le positionnement et les arguments du candidat — 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 ci-dessous pour le champ production_modele_propre (ne jamais dépasser le maximum) +- Viser exactement le niveau NCLC {nclcModele} +- Le texte d'examen ne contient AUCUNE note : pas de [NOTE:], pas de commentaire entre parenthèses dans production_modele_propre +- Proposer exactement 3 entrées dans notes_pedagogiques (passage court + explication) +- Répondre en JSON valide sans markdown + +COMPTAGE DES MOTS (TCF Canada, Expression écrite) : +- Un mot = segment séparé par des espaces (ou fins de ligne) ; l'apostrophe (' ou ') et le tiret (-) ne créent pas un mot supplémentaire. +- Exemples : « c'est », « l'eau », « aujourd'hui », « c'est-à-dire », « vas-y » comptent chacun pour un seul mot. + +LONGUEUR production_modele_propre pour cette tâche (respecter min conseillé et max STRICT) : +- Tâche 1 : 60 à 120 mots — ne pas dépasser 120 mots +- Tâche 2 : 120 à 150 mots — ne pas dépasser 150 mots +- Tâche 3 : 120 à 180 mots — ne pas dépasser 180 mots + +FORMAT JSON : +{ + "production_modele_propre": "texte final seul, prêt pour l'examen, sans aucune annotation", + "notes_pedagogiques": [ + {"passage": "extrait court du texte modèle", "explication": "pourquoi ce passage est efficace au TCF"} + ], + "transformations": [ + {"original": "extrait original du candidat", "ameliore": "version améliorée", "explication": "pourquoi c'est mieux"} + ], + "message": "phrase courte encourageante sur les idées du candidat" +} +``` + +--- + +## Structure de la réponse JSON attendue + +```json +{ + "production_modele_propre": "", + "notes_pedagogiques": [ + { + "passage": "", + "explication": "" + }, + { + "passage": "", + "explication": "" + }, + { + "passage": "", + "explication": "" + } + ], + "transformations": [ + { + "original": "", + "ameliore": "", + "explication": "" + } + ], + "message": "" +} +``` + +--- + +## Champs expliqués + +| Champ | Rôle | +|---|---| +| `production_modele_propre` | Texte final réécrit au niveau NCLC cible, sans aucune annotation, prêt pour l'examen | +| `notes_pedagogiques` | Exactement **3** passages du texte modèle commentés pédagogiquement | +| `notes_pedagogiques[].passage` | Extrait court tiré du texte modèle | +| `notes_pedagogiques[].explication` | Raison pour laquelle ce passage est efficace au TCF | +| `transformations` | Liste des améliorations appliquées sur des extraits précis | +| `transformations[].original` | Extrait original du candidat | +| `transformations[].ameliore` | Version améliorée de cet extrait | +| `transformations[].explication` | Justification pédagogique de l'amélioration | +| `message` | Message court et encourageant adressé au candidat | + +--- + +## Post-traitement côté serveur + +Après réception de la réponse du modèle, le serveur applique les traitements suivants : + +1. **Nettoyage** — suppression de toutes les annotations entre crochets `[NOTE: ...]` ou parenthèses dans `production_modele_propre` via `stripModelAnnotations()` +2. **Vérification du nombre de mots** — comptage TCF via `wordCount()` (apostrophes et tirets ne créent pas de mots supplémentaires) +3. **Troncature automatique** — si le texte dépasse le maximum de mots autorisé, il est tronqué via `truncateToMaxWords()` et le flag `tcf_truncated: true` est retourné +4. **Enrichissement de la réponse** — ajout des métadonnées : `nclcModele`, `nclcObtenu`, `scoreCible`, `tcf_word_count`, `tcf_word_min`, `tcf_word_max`, `tcf_truncated` +5. **Persistance** — enregistrement dans la table `productions` avec `record_kind: "production_modele"` et lien vers le rapport parent via `parent_production_id` diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 4768adb..ebad5f8 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -98,6 +98,20 @@ --- +### TD-15 — Jobs asynchrones modèle/exercices : status peut rester "pending" indéfiniment +**Priorité :** 🟡 Important +**Statut :** Ouvert — introduit au Sprint 3.6a +**Description :** Le flux POST /corrections/ee lance deux jobs DeepSeek en fire-and-forget (`runModeleJob`, `runExercicesJob` dans `correctionController.ts`). Si le process Node redémarre (deploy Render, crash, OOM) pendant l'exécution d'un de ces jobs, la colonne `exercices_status` ou `modele_status` reste figée à `'pending'` — l'utilisateur voit un loader infini côté frontend. +**Impact actuel :** faible en conditions normales (DeepSeek répond en ~5-15 s, Render redémarre rarement). Perceptible uniquement si un deploy a lieu pendant une correction active. +**À faire :** +- Option 1 (simple) : job de reprise au boot → scanner `productions WHERE (exercices_status='pending' OR modele_status='pending') AND created_at < NOW() - INTERVAL '2 minutes'` → relancer. +- Option 2 (robuste) : file d'attente persistée (pg-boss, BullMQ) au lieu de fire-and-forget. +- Option 3 (minimal) : timeout côté frontend → si `pending` depuis > 2 min, afficher "La génération a échoué, réessayer ?" + endpoint `POST /simulations/:id/retry-jobs`. +**Session concernée :** à planifier après livraison Sprint 3.6a/3.6b en prod stable. +**Condition de résolution :** après 7 jours d'observation en prod avec monitoring des colonnes `*_status='pending'` âgées. + +--- + ### TD-14 — Erreurs TypeScript TS2835 pré-existantes **Priorité :** 🟡 Important **Statut :** Résolu — session correction build TypeScript diff --git a/src/controllers/__tests__/correctionController.test.ts b/src/controllers/__tests__/correctionController.test.ts new file mode 100644 index 0000000..a1e8b95 --- /dev/null +++ b/src/controllers/__tests__/correctionController.test.ts @@ -0,0 +1,421 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { CorrectionRapport } from '../../lib/deepseek' +import type { AuthProfile } from '../../middleware/auth' + +// ── Helpers mocks ──────────────────────────────────────────────────────── + +const PROFILE: AuthProfile = { + id: 'user-1', + email: 'u@test.com', + plan: 'standard', + simulations_used: 3, +} + +const VALID_RAPPORT: CorrectionRapport = { + score: 14, + nclc: 9, + nclc_cible: 9, + revelation: { + croyance: 'c', + realite: 'r', + consequence: 'co', + }, + diagnostic: 'd', + criteres: [ + { nom: 'Adéquation à la tâche et au registre', score: 4, commentaire: '', exemple: '', suggestion: '', astuce: '' }, + { nom: 'Cohérence et cohésion du discours', score: 3, commentaire: '', exemple: '', suggestion: '', astuce: '' }, + { nom: 'Compétence lexicale', score: 3, commentaire: '', exemple: '', suggestion: '', astuce: '' }, + { nom: 'Compétence grammaticale', score: 4, commentaire: '', exemple: '', suggestion: '', astuce: '' }, + ], + conseil_nclc: { nclc_cible: 'NCLC 9', ecart: 'ok', action_prioritaire: 'a' }, + erreurs_codes: [ + { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }, + ], +} + +function createSupabaseMock() { + const updates: { table: string; data: Record; id: string }[] = [] + + const builder = (table: string, productionRow: unknown) => { + const obj: Record = {} + obj.select = () => obj + obj.eq = () => obj + obj.single = async () => ({ data: productionRow, error: null }) + obj.update = (data: Record) => { + return { + eq: async (_col: string, id: string) => { + updates.push({ table, data, id }) + return { error: null } + }, + } + } + return obj + } + + const mock = { + from: vi.fn((table: string) => { + if (table === 'productions') { + return builder(table, { + id: 'sim-1', + user_id: 'user-1', + tache: 'EE_T1', + sujet_id: null, + rapport: null, + }) + } + if (table === 'profiles') { + return builder(table, null) + } + if (table === 'sujets') { + return builder(table, null) + } + return builder(table, null) + }), + updates, + } + + return mock +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('correctionController.correctEE — Sprint 3.6a', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('retourne la correction dès que DeepSeek correction résout (ne bloque pas sur modele/exercices)', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + // correction résout vite, modele + exercices résolvent plus tard + const deepseekMocks = { + correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT), + correctEO: vi.fn(), + generateProductionModele: vi.fn().mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + production_modele_propre: 'modele', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }), + 50, + ), + ), + ), + generateExercices: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 50)), + ), + } + vi.doMock('../../lib/deepseek', () => deepseekMocks) + + const { correctEE } = await import('../correctionController') + + const start = Date.now() + const result = await correctEE( + { simulationId: 'sim-1', contenu: 'texte', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + const elapsed = Date.now() - start + + // La réponse arrive avant les 50 ms de setTimeout des jobs asynchrones + expect(elapsed).toBeLessThan(40) + expect('data' in result).toBe(true) + if ('data' in result) { + expect(result.data.simulation_id).toBe('sim-1') + expect(result.data.score).toBe(14) + } + + // La persistance de la correction inclut les nouveaux champs + statuts pending + const persisted = supabaseMock.updates.find( + (u) => u.table === 'productions' && u.data.score !== undefined, + ) + expect(persisted).toBeDefined() + expect(persisted!.data).toMatchObject({ + score: 14, + nclc: 9, + nclc_cible: 9, + exercices_status: 'pending', + modele_status: 'pending', + }) + expect(persisted!.data.revelation).toBeDefined() + expect(persisted!.data.diagnostic).toBeDefined() + expect(persisted!.data.conseil_nclc).toBeDefined() + expect(persisted!.data.erreurs_codes).toBeDefined() + }) + + it('modele_status passe à "ready" quand le job réussit', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT), + correctEO: vi.fn(), + generateProductionModele: vi.fn().mockResolvedValue({ + production_modele_propre: 'texte', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }), + generateExercices: vi.fn().mockResolvedValue([]), + })) + + const { correctEE } = await import('../correctionController') + + await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + + // Laisser les jobs async se résoudre + await new Promise((r) => setTimeout(r, 10)) + + const modeleReady = supabaseMock.updates.find((u) => u.data.modele_status === 'ready') + const exercicesReady = supabaseMock.updates.find( + (u) => u.data.exercices_status === 'ready', + ) + expect(modeleReady).toBeDefined() + expect(exercicesReady).toBeDefined() + }) + + it('modele_status passe à "error" quand le job DeepSeek échoue', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT), + correctEO: vi.fn(), + generateProductionModele: vi.fn().mockRejectedValue(new Error('DeepSeek down')), + generateExercices: vi.fn().mockRejectedValue(new Error('DeepSeek down')), + })) + + const { correctEE } = await import('../correctionController') + + await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + + await new Promise((r) => setTimeout(r, 10)) + + const modeleError = supabaseMock.updates.find((u) => u.data.modele_status === 'error') + const exercicesError = supabaseMock.updates.find( + (u) => u.data.exercices_status === 'error', + ) + expect(modeleError).toBeDefined() + expect(exercicesError).toBeDefined() + }) + + it('correction DeepSeek échoue → INTERNAL_ERROR 500 ; exercices jamais lancé ; modèle peut avoir démarré (parallélisme option b)', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + const modeleSpy = vi.fn().mockResolvedValue({ + production_modele_propre: 't', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 8, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }) + const exercicesSpy = vi.fn() + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn().mockRejectedValue(new Error('DeepSeek down')), + correctEO: vi.fn(), + generateProductionModele: modeleSpy, + generateExercices: exercicesSpy, + })) + + const { correctEE } = await import('../correctionController') + + const result = await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe('INTERNAL_ERROR') + expect(result.status).toBe(500) + } + + // Exercices dépend du rapport → jamais lancé si correction échoue. + // Modèle a été lancé en parallèle avec la correction (option b) → peut avoir + // été appelé avant que la correction ne rejette. + await new Promise((r) => setTimeout(r, 10)) + expect(exercicesSpy).not.toHaveBeenCalled() + // nclcObtenu passé au modèle = nclcCible - 1 = 8 + if (modeleSpy.mock.calls.length > 0) { + expect(modeleSpy).toHaveBeenCalledWith(expect.objectContaining({ nclcObtenu: 8 })) + } + }) + + it('parallélisme option b : modèle est appelé avec nclcObtenu = nclcCible - 1 (provisoire)', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + const modeleSpy = vi.fn().mockResolvedValue({ + production_modele_propre: 't', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }) + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT), + correctEO: vi.fn(), + generateProductionModele: modeleSpy, + generateExercices: vi.fn().mockResolvedValue([]), + })) + + const { correctEE } = await import('../correctionController') + + await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 10 }, + PROFILE, + ) + await new Promise((r) => setTimeout(r, 10)) + + // nclcCible=10 → nclcObtenu estimé = 9 + expect(modeleSpy).toHaveBeenCalledWith(expect.objectContaining({ nclcObtenu: 9 })) + }) + + it('nclc_cible=10 est propagé jusqu\'à DeepSeek', async () => { + const supabaseMock = createSupabaseMock() + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + + const correctEESpy = vi.fn().mockResolvedValue({ ...VALID_RAPPORT, nclc_cible: 10 }) + vi.doMock('../../lib/deepseek', () => ({ + correctEE: correctEESpy, + correctEO: vi.fn(), + generateProductionModele: vi.fn().mockResolvedValue({ + production_modele_propre: 't', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }), + generateExercices: vi.fn().mockResolvedValue([]), + })) + + const { correctEE } = await import('../correctionController') + + await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 10 }, + PROFILE, + ) + + expect(correctEESpy).toHaveBeenCalledWith( + expect.objectContaining({ nclcCible: 10 }), + ) + }) + + it('simulation introuvable → SIMULATION_NOT_FOUND 404', async () => { + const supabaseMock = { + from: vi.fn(() => ({ + select: () => ({ + eq: () => ({ single: async () => ({ data: null, error: { message: 'not found' } }) }), + }), + })), + updates: [], + } + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn(), + correctEO: vi.fn(), + generateProductionModele: vi.fn(), + generateExercices: vi.fn(), + })) + + const { correctEE } = await import('../correctionController') + + const result = await correctEE( + { simulationId: 'sim-missing', contenu: 't', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe('SIMULATION_NOT_FOUND') + expect(result.status).toBe(404) + } + }) + + it('simulation d\'un autre utilisateur → AUTH_REQUIRED 401', async () => { + const supabaseMock = { + from: vi.fn(() => ({ + select: () => ({ + eq: () => ({ + single: async () => ({ + data: { + id: 'sim-1', + user_id: 'other-user', + tache: 'EE_T1', + sujet_id: null, + rapport: null, + }, + error: null, + }), + }), + }), + })), + updates: [], + } + vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock })) + vi.doMock('../../lib/deepseek', () => ({ + correctEE: vi.fn(), + correctEO: vi.fn(), + generateProductionModele: vi.fn(), + generateExercices: vi.fn(), + })) + + const { correctEE } = await import('../correctionController') + + const result = await correctEE( + { simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 }, + PROFILE, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe('AUTH_REQUIRED') + expect(result.status).toBe(401) + } + }) +}) diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts index c8bdbe5..ebb38d1 100644 --- a/src/controllers/__tests__/simulationController.test.ts +++ b/src/controllers/__tests__/simulationController.test.ts @@ -411,6 +411,95 @@ describe('GET /simulations/:id', () => { expect(body.rapport).toBeNull() }) + it('Sprint 3.6a — retourne nclc_cible, exercices/modele + statuses', async () => { + const profile = buildProfile({ id: 'user-123', plan: 'standard' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EE_T1', + mode: 'entrainement', + contenu: 'texte', + sujet_id: null, + rapport: JSON.stringify(VALID_RAPPORT), + created_at: '2024-01-01T00:00:00Z', + nclc_cible: 10, + exercices: [ + { + difficulte: 'facile', + theme: 'accord_sujet_verbe', + diagnostic: 'd', + consigne: 'c', + extrait: 'e', + indice: 'i', + correction: 'cor', + explication: 'ex', + }, + ], + exercices_status: 'ready', + modele: { + production_modele_propre: 'texte modele', + notes_pedagogiques: [], + transformations: [], + message: '', + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 2, + tcf_word_min: 60, + tcf_word_max: 120, + tcf_truncated: false, + }, + modele_status: 'ready', + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.nclc_cible).toBe(10) + expect(body.exercices).toHaveLength(1) + expect(body.exercices[0]).toMatchObject({ + difficulte: 'facile', + theme: 'accord_sujet_verbe', + }) + expect(body.exercices_status).toBe('ready') + expect(body.modele.production_modele_propre).toBe('texte modele') + expect(body.modele_status).toBe('ready') + }) + + it('Sprint 3.6a — statuts par défaut "pending" si colonnes absentes (compat ancien schéma)', async () => { + const profile = buildProfile({ id: 'user-123', plan: 'standard' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EE_T1', + mode: 'entrainement', + contenu: 't', + sujet_id: null, + rapport: JSON.stringify(VALID_RAPPORT), + created_at: '2024-01-01T00:00:00Z', + // nclc_cible / exercices / modele / statuses absents + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.nclc_cible).toBeNull() + expect(body.exercices).toBeNull() + expect(body.exercices_status).toBe('pending') + expect(body.modele).toBeNull() + expect(body.modele_status).toBe('pending') + }) + it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => { const profile = buildProfile({ id: 'user-123' }) mockAuth(profile) diff --git a/src/controllers/correctionController.ts b/src/controllers/correctionController.ts index 8a26c80..ae0bfc0 100644 --- a/src/controllers/correctionController.ts +++ b/src/controllers/correctionController.ts @@ -1,7 +1,34 @@ +/** + * 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 } from '../lib/deepseek.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 { EERapport, EORapport } from '../lib/deepseek.js' import type { AuthProfile } from '../middleware/auth.js' type CorrectionError = { @@ -11,16 +38,23 @@ type CorrectionError = { status: number } +export interface CorrectEEInput { + simulationId: string + contenu: string + tache: TacheEE + nclcCible: NclcCible +} + export async function correctEE( - simulationId: string, - contenu: string, - tache: string, - profile: AuthProfile -): Promise<{ data: EERapport & { simulation_id: string } } | CorrectionError> { + 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') + .select('id, user_id, tache, sujet_id, rapport') .eq('id', simulationId) .single() @@ -42,11 +76,59 @@ export async function correctEE( } } - // 2. Appeler DeepSeek pour la correction - let rapport: EERapport + // 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 deepseekCorrectEE(contenu, tache) - } catch { + 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', @@ -55,13 +137,20 @@ export async function correctEE( } } - // 3. Mettre à jour la production dans Supabase + // 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) @@ -74,7 +163,11 @@ export async function correctEE( } } - // 4. Incrémenter simulations_used si le plan a une limite (non bloquant). + // 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') @@ -82,17 +175,69 @@ export async function correctEE( .eq('id', profile.id) } - // 5. Retourner le rapport complet enrichi avec simulation_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 + profile: AuthProfile, ): Promise<{ data: EORapport & { simulation_id: string } } | CorrectionError> { - // 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') @@ -117,7 +262,6 @@ export async function correctEO( } } - // 2. Appeler DeepSeek pour la correction EO let rapport: EORapport try { rapport = await deepseekCorrectEO(transcript, tache) @@ -130,7 +274,6 @@ export async function correctEO( } } - // 3. Mettre à jour la production dans Supabase const { error: updateError } = await supabase .from('productions') .update({ @@ -150,7 +293,6 @@ export async function correctEO( } } - // 4. 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') @@ -158,6 +300,5 @@ export async function correctEO( .eq('id', profile.id) } - // 5. Retourner le rapport complet enrichi avec simulation_id return { data: { ...rapport, simulation_id: simulationId } } } diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index 87c2c0b..d1a5a29 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -1,8 +1,14 @@ import { supabase } from '../lib/supabase.js' import { canUserSimulate } from '../lib/access.js' -import type { EERapport } from '../lib/deepseek.js' +import type { + CorrectionRapport, + ProductionModele, + ExerciceItem, +} from '../lib/deepseek.js' import type { AuthProfile } from '../middleware/auth.js' +export type JobStatus = 'pending' | 'ready' | 'error' + export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE' export type Mode = 'entrainement' | 'examen' @@ -125,8 +131,8 @@ export async function create( } } -// EERapport et EORapport ont la même structure depuis l'étape A — -// on utilise EERapport comme représentation canonique du rapport parsé. +// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc, +// erreurs_codes) + statuts des jobs asynchrones (modele, exercices). // // FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée). // Le frontend distingue : @@ -139,7 +145,12 @@ export interface GetByIdResult { created_at: string contenu: string | null sujet: SujetData | null - rapport: EERapport | null + rapport: CorrectionRapport | null + nclc_cible: 9 | 10 | null + exercices: ExerciceItem[] | null + exercices_status: JobStatus + modele: ProductionModele | null + modele_status: JobStatus } type ControllerError = { @@ -155,7 +166,9 @@ export async function getById( ): Promise<{ data: GetByIdResult } | ControllerError> { const { data, error } = await supabase .from('productions') - .select('id, user_id, tache, mode, contenu, sujet_id, rapport, created_at') + .select( + 'id, user_id, tache, mode, contenu, sujet_id, rapport, created_at, nclc_cible, exercices, exercices_status, modele, modele_status', + ) .eq('id', id) .single() @@ -188,7 +201,18 @@ export async function getById( if (sujetRow) sujet = sujetRow as SujetData } - const rapport = data.rapport ? (JSON.parse(data.rapport) as EERapport) : null + const rapport = data.rapport ? (JSON.parse(data.rapport) as CorrectionRapport) : null + + // JSONB columns reviennent déjà parsées par supabase-js. + const exercices = Array.isArray(data.exercices) ? (data.exercices as ExerciceItem[]) : null + const modele = + data.modele && typeof data.modele === 'object' ? (data.modele as ProductionModele) : null + + const exercicesStatus = (data.exercices_status as JobStatus | null) ?? 'pending' + const modeleStatus = (data.modele_status as JobStatus | null) ?? 'pending' + const nclcCibleRaw = data.nclc_cible + const nclcCible: 9 | 10 | null = + nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null return { data: { @@ -199,6 +223,11 @@ export async function getById( contenu: data.contenu ?? null, sujet, rapport, + nclc_cible: nclcCible, + exercices, + exercices_status: exercicesStatus, + modele, + modele_status: modeleStatus, }, } } diff --git a/src/lib/__tests__/deepseek.test.ts b/src/lib/__tests__/deepseek.test.ts index c55c2f1..55a0aab 100644 --- a/src/lib/__tests__/deepseek.test.ts +++ b/src/lib/__tests__/deepseek.test.ts @@ -1,150 +1,425 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { CorrectionRapport } from '../deepseek' + +// ── Fixture correction — Sprint 3.6a, forme nouvelle ────────────────── const VALID_RAPPORT = { - score: 14.5, - nclc: 8, - feedback_court: - 'Bonne production générale. Quelques points à améliorer sur le lexique et la morphosyntaxe.', + score: 14, + nclc: 9, + revelation: { + croyance: 'Le candidat pense avoir bien respecté la consigne.', + realite: 'Certains éléments de la consigne sont ignorés.', + consequence: 'Perte d\'un point en adéquation à la tâche.', + }, + diagnostic: 'Frein principal : pauvreté du lexique et connecteurs répétés.', criteres: [ - { nom: 'Coherence et cohesion', score: 4, commentaire: 'Bonne organisation.' }, - { nom: 'Lexique', score: 3, commentaire: 'Vocabulaire correct mais limite.' }, - { nom: 'Morphosyntaxe', score: 4, commentaire: 'Structures variees.' }, - { nom: 'Pertinence', score: 3.5, commentaire: 'Adequation partielle a la consigne.' }, + { + nom: 'Adéquation à la tâche et au registre', + score: 4, + commentaire: 'Tâche globalement respectée.', + exemple: 'Je vous écris pour demander', + suggestion: 'Je sollicite votre attention afin de demander', + astuce: 'Varier les formules d\'appel.', + }, + { + nom: 'Cohérence et cohésion du discours', + score: 3, + commentaire: 'Connecteurs peu variés.', + exemple: 'Et aussi, et puis', + suggestion: 'De plus, par ailleurs', + astuce: 'Bannir "et" comme connecteur unique.', + }, + { + nom: 'Compétence lexicale', + score: 3, + commentaire: 'Vocabulaire basique.', + exemple: 'faire un travail', + suggestion: 'effectuer une mission', + astuce: 'Substituer "faire" par un verbe précis.', + }, + { + nom: 'Compétence grammaticale', + score: 4, + commentaire: 'Accords globalement corrects.', + exemple: 'les enfants joue', + suggestion: 'les enfants jouent', + astuce: 'Vérifier la terminaison verbale au pluriel.', + }, ], - erreurs: ['Connecteurs logiques insuffisants', 'Quelques fautes accord'], - modele: 'Texte modele corrige ici.', - idees: ['Developper argumentation', 'Ajouter des exemples concrets'], - exercices: ['Exercice connecteurs logiques', 'Exercice accords sujet-verbe'], -} + conseil_nclc: { + nclc_cible: 'NCLC 9', + ecart: 'objectif atteint', + action_prioritaire: 'Enrichir le lexique par thématique.', + }, + erreurs_codes: [ + { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }, + { code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null }, + { code: 'vocabulaire_basique', critere: 'competence_lexicale', description: null }, + ], +} satisfies Omit & { erreurs_codes: unknown[] } -function mockFetchSuccess(rapport: unknown) { +function mockFetchSuccess(payload: unknown) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ - choices: [{ message: { content: JSON.stringify(rapport) } }], - }), - }) + json: async () => ({ choices: [{ message: { content: JSON.stringify(payload) } }] }), + }), ) } -describe('deepseek.correctEE', () => { +// ── correctEE (nouvelle signature) ────────────────────────────────────── + +describe('deepseek.correctEE — Sprint 3.6a', () => { beforeEach(() => { vi.resetModules() vi.restoreAllMocks() }) - it('retourne un rapport avec la bonne structure', async () => { + it('retourne un rapport avec la nouvelle structure (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)', async () => { mockFetchSuccess(VALID_RAPPORT) const { correctEE } = await import('../deepseek') - const rapport = await correctEE('Mon texte de test', 'EE_T1') + const rapport = await correctEE({ + tache: 'EE_T1', + contenu: 'Mon texte de test', + sujet: 'Écrivez un message', + nclcCible: 9, + }) - expect(rapport).toHaveProperty('score') - expect(rapport).toHaveProperty('nclc') - expect(rapport).toHaveProperty('feedback_court') - expect(rapport).toHaveProperty('criteres') - expect(rapport).toHaveProperty('erreurs') - expect(rapport).toHaveProperty('modele') - expect(rapport).toHaveProperty('idees') - expect(rapport).toHaveProperty('exercices') + expect(rapport.score).toBe(14) + expect(rapport.nclc).toBe(9) + expect(rapport.nclc_cible).toBe(9) + expect(rapport.revelation).toMatchObject({ + croyance: expect.any(String), + realite: expect.any(String), + consequence: expect.any(String), + }) + expect(rapport.diagnostic).toBeTypeOf('string') expect(rapport.criteres).toHaveLength(4) - expect(typeof rapport.feedback_court).toBe('string') - expect(rapport.feedback_court.length).toBeGreaterThan(0) - expect(Array.isArray(rapport.erreurs)).toBe(true) - expect(Array.isArray(rapport.idees)).toBe(true) - expect(Array.isArray(rapport.exercices)).toBe(true) + expect(rapport.conseil_nclc.nclc_cible).toBe('NCLC 9') + expect(rapport.erreurs_codes.length).toBeGreaterThan(0) }) - it('score est entre 0 et 20', async () => { - mockFetchSuccess(VALID_RAPPORT) + it('nclc_cible=10 est propagé dans le rapport', async () => { + mockFetchSuccess({ ...VALID_RAPPORT, score: 18 }) const { correctEE } = await import('../deepseek') - const rapport = await correctEE('Mon texte', 'EE_T1') + const rapport = await correctEE({ + tache: 'EE_T1', + contenu: 'Texte', + sujet: null, + nclcCible: 10, + }) - expect(rapport.score).toBeGreaterThanOrEqual(0) - expect(rapport.score).toBeLessThanOrEqual(20) + expect(rapport.nclc_cible).toBe(10) }) - it('nclc est entre 4 et 12', async () => { - mockFetchSuccess(VALID_RAPPORT) - const { correctEE } = await import('../deepseek') - - const rapport = await correctEE('Mon texte', 'EE_T2') - - expect(rapport.nclc).toBeGreaterThanOrEqual(4) - expect(rapport.nclc).toBeLessThanOrEqual(12) - }) - - it('lance une erreur si score hors bornes', async () => { + it('score hors bornes → throw', async () => { mockFetchSuccess({ ...VALID_RAPPORT, score: 25 }) const { correctEE } = await import('../deepseek') - - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('Score invalide') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('Score invalide') }) - it('lance une erreur si nclc hors bornes', async () => { + it('nclc hors bornes → throw', async () => { mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 }) const { correctEE } = await import('../deepseek') - - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('NCLC invalide') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('NCLC invalide') }) - it('lance une erreur si feedback_court est absent ou vide', async () => { - // Cas 1 : champ absent (JSON.stringify drop les undefined) - mockFetchSuccess({ ...VALID_RAPPORT, feedback_court: undefined }) + it('revelation absente → throw', async () => { + const bad = { ...VALID_RAPPORT, revelation: undefined } + mockFetchSuccess(bad) const { correctEE } = await import('../deepseek') - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('feedback_court invalide') - - // Cas 2 : chaîne vide (whitespace uniquement) - mockFetchSuccess({ ...VALID_RAPPORT, feedback_court: ' ' }) - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('feedback_court invalide') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('revelation invalide') }) - it('erreur HTTP depuis DeepSeek API', async () => { + it('diagnostic vide → throw', async () => { + mockFetchSuccess({ ...VALID_RAPPORT, diagnostic: ' ' }) + const { correctEE } = await import('../deepseek') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('diagnostic invalide') + }) + + it('criteres doit avoir exactement 4 entrées', async () => { + mockFetchSuccess({ ...VALID_RAPPORT, criteres: VALID_RAPPORT.criteres.slice(0, 3) }) + const { correctEE } = await import('../deepseek') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('criteres invalide') + }) + + it('erreurs_codes : codes hors taxonomie sont filtrés', async () => { + const bad = { + ...VALID_RAPPORT, + erreurs_codes: [ + { code: 'code_inexistant_xyz', critere: 'competence_grammaticale', description: null }, + { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }, + ], + } + mockFetchSuccess(bad) + const { correctEE } = await import('../deepseek') + const rapport = await correctEE({ + tache: 'EE_T1', + contenu: 'T', + sujet: null, + nclcCible: 9, + }) + expect(rapport.erreurs_codes).toHaveLength(1) + expect(rapport.erreurs_codes[0]?.code).toBe('accord_sujet_verbe') + }) + + it('erreurs_codes : code "autre" sans description est rejeté', async () => { + const bad = { + ...VALID_RAPPORT, + erreurs_codes: [ + { code: 'autre', critere: 'coherence_cohesion', description: null }, + { code: 'autre', critere: 'coherence_cohesion', description: 'erreur spécifique' }, + ], + } + mockFetchSuccess(bad) + const { correctEE } = await import('../deepseek') + const rapport = await correctEE({ + tache: 'EE_T1', + contenu: 'T', + sujet: null, + nclcCible: 9, + }) + expect(rapport.erreurs_codes).toHaveLength(1) + expect(rapport.erreurs_codes[0]).toMatchObject({ + code: 'autre', + description: 'erreur spécifique', + }) + }) + + it('critère inconnu → entrée filtrée', async () => { + const bad = { + ...VALID_RAPPORT, + erreurs_codes: [ + { code: 'accord_sujet_verbe', critere: 'critere_inventé', description: null }, + ], + } + mockFetchSuccess(bad) + const { correctEE } = await import('../deepseek') + const rapport = await correctEE({ + tache: 'EE_T1', + contenu: 'T', + sujet: null, + nclcCible: 9, + }) + expect(rapport.erreurs_codes).toHaveLength(0) + }) + + it('erreur HTTP DeepSeek → throw', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }) + vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal' }), ) const { correctEE } = await import('../deepseek') - - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('DeepSeek API error') + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow('DeepSeek API error') }) - it('erreur si reponse vide', async () => { + it('JSON invalide → throw', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ choices: [{ message: { content: '' } }] }), - }) + json: async () => ({ choices: [{ message: { content: 'pas du json' } }] }), + }), ) const { correctEE } = await import('../deepseek') - - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow() - }) - - it('lance une erreur si DeepSeek retourne du JSON invalide', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'ceci nest pas du json' } }], - }), - }) - ) - const { correctEE } = await import('../deepseek') - - await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow() + await expect( + correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), + ).rejects.toThrow() }) }) +// ── generateProductionModele — cible fixe NCLC 9 ─────────────────────── + +const VALID_MODELE = { + production_modele_propre: 'Texte modèle réécrit. '.repeat(10).trim(), + notes_pedagogiques: [ + { passage: 'extrait 1', explication: 'efficace car…' }, + { passage: 'extrait 2', explication: 'efficace car…' }, + { passage: 'extrait 3', explication: 'efficace car…' }, + ], + transformations: [ + { original: 'je fais', ameliore: 'j\'effectue', explication: 'plus précis' }, + ], + message: 'Vos idées sont solides, continuez.', +} + +describe('deepseek.generateProductionModele', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('renvoie métadonnées avec nclc_modele=9 (fixe)', async () => { + mockFetchSuccess(VALID_MODELE) + const { generateProductionModele } = await import('../deepseek') + + const result = await generateProductionModele({ + tache: 'EE_T2', + sujet: 'Un article de blog', + texte: 'production du candidat', + nclcObtenu: 7, + }) + + expect(result.nclc_modele).toBe(9) + expect(result.nclc_obtenu).toBe(7) + expect(result.score_cible).toBe(14) + expect(result.tcf_word_min).toBe(120) + expect(result.tcf_word_max).toBe(150) + }) + + it('tronque à max mots et renseigne tcf_truncated=true', async () => { + const longText = 'mot '.repeat(200).trim() // 200 mots + mockFetchSuccess({ ...VALID_MODELE, production_modele_propre: longText }) + const { generateProductionModele } = await import('../deepseek') + + const result = await generateProductionModele({ + tache: 'EE_T1', // max 120 + sujet: null, + texte: 'production', + nclcObtenu: 8, + }) + + expect(result.tcf_truncated).toBe(true) + expect(result.tcf_word_count).toBe(120) + }) + + it('supprime les annotations [NOTE: ...] de production_modele_propre', async () => { + mockFetchSuccess({ + ...VALID_MODELE, + production_modele_propre: 'Bonjour [NOTE: salutation formelle] je vous écris.', + }) + const { generateProductionModele } = await import('../deepseek') + + const result = await generateProductionModele({ + tache: 'EE_T1', + sujet: null, + texte: 'p', + nclcObtenu: 8, + }) + + expect(result.production_modele_propre).not.toContain('[NOTE:') + expect(result.production_modele_propre).toContain('Bonjour') + }) +}) + +// ── generateExercices ─────────────────────────────────────────────────── + +describe('deepseek.generateExercices', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('renvoie une liste d\'exercices avec le format attendu', async () => { + mockFetchSuccess({ + exercices: [ + { + difficulte: 'facile', + theme: 'accord_sujet_verbe', + diagnostic: 'Erreurs d\'accord verbe-sujet.', + consigne: 'Corrigez les accords.', + extrait: 'les enfants joue', + indice: 'Pluriel du sujet ?', + correction: 'les enfants jouent', + explication: 'Le verbe s\'accorde en nombre avec le sujet.', + }, + { + difficulte: 'intermediaire', + theme: 'connecteurs_repetes', + diagnostic: 'Même connecteur répété.', + consigne: 'Variez les connecteurs.', + extrait: 'Et puis et aussi', + indice: 'Synonymes de "et" ?', + correction: 'De plus, par ailleurs', + explication: 'Varier lexicalement les connecteurs améliore la cohésion.', + }, + { + difficulte: 'difficile', + theme: 'vocabulaire_basique', + diagnostic: 'Verbe "faire" imprécis.', + consigne: 'Remplacez "faire" par un verbe précis.', + extrait: 'faire un travail', + indice: 'Un verbe de réalisation ?', + correction: 'effectuer une mission', + explication: '"Effectuer" précise l\'action.', + }, + ], + }) + const { generateExercices } = await import('../deepseek') + + const exercices = await generateExercices({ + tache: 'EE_T1', + erreursCodes: VALID_RAPPORT.erreurs_codes as never, + criteres: VALID_RAPPORT.criteres, + }) + + expect(exercices).toHaveLength(3) + expect(exercices[0]).toMatchObject({ + difficulte: 'facile', + theme: 'accord_sujet_verbe', + consigne: expect.any(String), + correction: expect.any(String), + }) + }) + + it('difficulte inconnue → fallback "intermediaire"', async () => { + mockFetchSuccess({ + exercices: [ + { + difficulte: 'epique', + theme: 't', + consigne: 'c', + correction: 'r', + }, + ], + }) + const { generateExercices } = await import('../deepseek') + + const exercices = await generateExercices({ + tache: 'EE_T1', + erreursCodes: [], + criteres: [], + }) + + expect(exercices[0]?.difficulte).toBe('intermediaire') + }) + + it('exercices sans consigne/correction sont filtrés', async () => { + mockFetchSuccess({ + exercices: [ + { difficulte: 'facile', theme: 't' }, // manque consigne + correction + { difficulte: 'facile', theme: 't', consigne: 'c', correction: 'r' }, + ], + }) + const { generateExercices } = await import('../deepseek') + + const exercices = await generateExercices({ + tache: 'EE_T1', + erreursCodes: [], + criteres: [], + }) + + expect(exercices).toHaveLength(1) + }) +}) + +// ── EO — inchangé par Sprint 3.6a ────────────────────────────────────── + const VALID_RAPPORT_EO = { score: 12, nclc: 7, @@ -168,81 +443,59 @@ describe('deepseek.correctEO', () => { vi.restoreAllMocks() }) - it('retourne un rapport EO avec la bonne structure', async () => { + it('retourne un rapport EO avec la structure V1', async () => { mockFetchSuccess(VALID_RAPPORT_EO) const { correctEO } = await import('../deepseek') + const rapport = await correctEO('transcription', 'EO_T1') - const rapport = await correctEO('Ma transcription orale', 'EO_T1') - - expect(rapport).toHaveProperty('score') - expect(rapport).toHaveProperty('nclc') expect(rapport).toHaveProperty('feedback_court') - expect(rapport).toHaveProperty('criteres') expect(rapport.criteres).toHaveLength(4) - expect(rapport).toHaveProperty('erreurs') - expect(rapport).toHaveProperty('modele') - expect(rapport).toHaveProperty('idees') - expect(rapport).toHaveProperty('exercices') - expect(typeof rapport.feedback_court).toBe('string') - expect(rapport.feedback_court.length).toBeGreaterThan(0) + expect(rapport.criteres.find((c) => c.nom === 'Phonologie')?.score).toBe(0) }) - it('phonologie est a 0', async () => { - mockFetchSuccess(VALID_RAPPORT_EO) - const { correctEO } = await import('../deepseek') - - const rapport = await correctEO('Ma transcription', 'EO_T1') - - const phonologie = rapport.criteres.find((c) => c.nom === 'Phonologie') - expect(phonologie).toBeDefined() - expect(phonologie!.score).toBe(0) - }) - - it('score est entre 0 et 20', async () => { - mockFetchSuccess(VALID_RAPPORT_EO) - const { correctEO } = await import('../deepseek') - - const rapport = await correctEO('Ma transcription', 'EO_T3') - - expect(rapport.score).toBeGreaterThanOrEqual(0) - expect(rapport.score).toBeLessThanOrEqual(20) - }) - - it('nclc est entre 4 et 12', async () => { - mockFetchSuccess(VALID_RAPPORT_EO) - const { correctEO } = await import('../deepseek') - - const rapport = await correctEO('Ma transcription', 'EO_T1') - - expect(rapport.nclc).toBeGreaterThanOrEqual(4) - expect(rapport.nclc).toBeLessThanOrEqual(12) - }) - - it('lance une erreur si score hors bornes', async () => { + it('score hors bornes → throw', async () => { mockFetchSuccess({ ...VALID_RAPPORT_EO, score: 25 }) const { correctEO } = await import('../deepseek') - - await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('Score invalide') + await expect(correctEO('t', 'EO_T1')).rejects.toThrow('Score invalide') }) - it('lance une erreur si nclc hors bornes', async () => { + it('nclc hors bornes → throw', async () => { mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 }) const { correctEO } = await import('../deepseek') - - await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('NCLC invalide') + await expect(correctEO('t', 'EO_T1')).rejects.toThrow('NCLC invalide') }) - it('erreur HTTP depuis DeepSeek API', async () => { + it('HTTP error → throw', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }) + vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'I' }), ) const { correctEO } = await import('../deepseek') - - await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('DeepSeek API error') + await expect(correctEO('t', 'EO_T1')).rejects.toThrow('DeepSeek API error') + }) +}) + +// ── Post-traitement unitaire ──────────────────────────────────────────── + +describe('deepseek — helpers de post-traitement', () => { + it('wordCountTCF : apostrophes et tirets ne créent pas de mot', async () => { + const { wordCountTCF } = await import('../deepseek') + expect(wordCountTCF("c'est")).toBe(1) + expect(wordCountTCF("aujourd'hui")).toBe(1) + expect(wordCountTCF("c'est-à-dire")).toBe(1) + expect(wordCountTCF('il va bien')).toBe(3) + expect(wordCountTCF('')).toBe(0) + }) + + it('stripModelAnnotations retire [NOTE:…]', async () => { + const { stripModelAnnotations } = await import('../deepseek') + expect(stripModelAnnotations('Bonjour [NOTE: formel] Madame')).toBe('Bonjour Madame') + }) + + it('truncateToMaxWords tronque au-delà du seuil', async () => { + const { truncateToMaxWords } = await import('../deepseek') + const { text, truncated } = truncateToMaxWords('a b c d e f', 3) + expect(text).toBe('a b c') + expect(truncated).toBe(true) }) }) diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index e413626..bd7c849 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -1,23 +1,616 @@ +/** + * 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' -export interface EECritere { +// ── 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 EERapport { +export interface ErreurCode { + code: string + critere: Critere + description: string | null +} + +export interface CorrectionRapport { score: number nclc: number - feedback_court: string - criteres: EECritere[] - erreurs: string[] - modele: string - idees: string[] - exercices: string[] + 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 @@ -35,86 +628,6 @@ export interface EORapport { exercices: string[] } -const SYSTEM_PROMPT = `Tu es un examinateur officiel du TCF Canada (Test de connaissance du français). -Tu évalues une production écrite selon les 4 critères officiels de l'Expression Écrite : -1. Cohérence et cohésion -2. Lexique (étendue et maîtrise du vocabulaire) -3. Morphosyntaxe (grammaire et structures) -4. Pertinence (adéquation à la consigne) - -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": "Pertinence", "score": , "commentaire": "" } - ], - "erreurs": ["", "", ...], - "modele": "", - "idees": ["", "", ...], - "exercices": ["", "", ...] -} - -Règles : -- score est la note globale sur 20 -- 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) -- Chaque critère a un score de 0 à 5 -- Retourne UNIQUEMENT le JSON, sans texte avant ni après` - -export async function correctEE(contenu: 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 }, - { - role: 'user', - content: `Tâche : ${tache}\n\nProduction de l'étudiant :\n${contenu}`, - }, - ], - 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: EERapport = 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 -} - 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 @@ -255,3 +768,7 @@ export async function correctEO(transcript: string, tache: string): Promise = { + adequation_tache: 'Adéquation à la tâche et au registre', + coherence_cohesion: 'Cohérence et cohésion du discours', + competence_lexicale: 'Compétence lexicale', + competence_grammaticale: 'Compétence grammaticale', +} + +export const CODES_BY_CRITERE: Record = { + adequation_tache: [ + 'hors_sujet_total', + 'hors_sujet_partiel', + 'information_manquante', + 'enonce_copie', + 'longueur_insuffisante', + 'longueur_excessive', + 'format_non_respecte', + 'salutation_absente', + 'cloture_absente', + 'structure_absente', + 'registre_trop_formel', + 'registre_trop_familier', + 'abreviations_sms', + 'tutoiement_inadequat', + 'autre', + ], + coherence_cohesion: [ + 'introduction_absente', + 'conclusion_absente', + 'paragraphes_absents', + 'progression_illogique', + 'connecteurs_absents', + 'connecteurs_repetes', + 'connecteurs_inadequats', + 'connecteurs_insuffisants', + 'idee_non_developpee', + 'repetition_idee', + 'contradiction_interne', + 'hors_propos', + 'pronoms_ambigus', + 'substitution_absente', + 'rupture_temporelle', + 'autre', + ], + competence_lexicale: [ + 'vocabulaire_basique', + 'vocabulaire_insuffisant', + 'registre_lexical_inadequat', + 'mot_imprecis', + 'contresens_lexical', + 'anglicisme', + 'calque_syntaxique', + 'repetition_lexicale', + 'synonymes_absents', + 'expressions_figees_absentes', + 'faute_orthographe_courante', + 'confusion_homophones', + 'majuscules_incorrectes', + 'autre', + ], + competence_grammaticale: [ + 'accord_sujet_verbe', + 'accord_adjectif_nom', + 'accord_participe_passe', + 'accord_determinant_nom', + 'temps_verbal_inadequat', + 'subjonctif_absent', + 'subjonctif_incorrect', + 'conditionnel_absent', + 'concordance_temps', + 'phrase_incomplete', + 'phrase_trop_longue', + 'ordre_mots_incorrect', + 'subordination_absente', + 'subordination_incorrecte', + 'virgule_exces', + 'virgule_absence', + 'point_absent', + 'ponctuation_incorrecte', + 'preposition_absente', + 'preposition_incorrecte', + 'preposition_superflue', + 'genre_incorrect', + 'nombre_incorrect', + 'negation_incomplete', + 'autre', + ], +} + +export function isValidCritere(x: unknown): x is Critere { + return typeof x === 'string' && (CRITERES as readonly string[]).includes(x) +} + +export function isValidCode(critere: Critere, code: string): boolean { + return CODES_BY_CRITERE[critere].includes(code) +} + +/** + * Bloc texte injecté dans le prompt maître — liste des codes autorisés par critère, + * dans le format exact attendu par DeepSeek (`critere: code1, code2, …`). + */ +export function buildTaxonomyPromptSection(): string { + const lines = CRITERES.map((critere) => { + const codes = CODES_BY_CRITERE[critere].join(', ') + return `- ${critere} : ${codes}` + }) + return `CODES D'ERREURS AUTORISÉS (par critère) : +${lines.join('\n')} + +Règles : +- Chaque erreur retournée doit utiliser EXACTEMENT un code de la liste du critère concerné. +- Le code "autre" est autorisé mais exige une "description" textuelle non vide. +- Pour tout code différent de "autre", le champ "description" doit être null.` +} + +/** + * Barème NCLC → score minimum /20 (cf. Prompt_maître.md §Barème). + */ +export const NCLC_MIN_SCORE: Record = { + 7: 10, + 8: 12, + 9: 14, + 10: 16, +} diff --git a/src/routes/corrections.ts b/src/routes/corrections.ts index b01dbe4..f3544e2 100644 --- a/src/routes/corrections.ts +++ b/src/routes/corrections.ts @@ -9,7 +9,12 @@ const VALID_TACHES_EO = ['EO_T1', 'EO_T3'] const corrections = new Hono<{ Variables: AppVariables }>() corrections.post('/ee', authMiddleware, async (c) => { - let body: { simulationId?: unknown; contenu?: unknown; tache?: unknown } + let body: { + simulationId?: unknown + contenu?: unknown + tache?: unknown + nclc_cible?: unknown + } try { body = await c.req.json() } catch { @@ -44,12 +49,31 @@ corrections.post('/ee', authMiddleware, async (c) => { ) } + // Sprint 3.6a — nclc_cible optionnel (défaut 9). Seules les valeurs 9 et 10 sont acceptées. + let nclcCible: 9 | 10 = 9 + if (body.nclc_cible !== undefined) { + if (body.nclc_cible !== 9 && body.nclc_cible !== 10) { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: 'nclc_cible doit être 9 ou 10.', + }, + 400 + ) + } + nclcCible = body.nclc_cible + } + const profile = c.get('profile') const result = await correctionController.correctEE( - body.simulationId as string, - body.contenu as string, - body.tache as string, - profile + { + simulationId: body.simulationId, + contenu: body.contenu, + tache: body.tache as 'EE_T1' | 'EE_T2' | 'EE_T3', + nclcCible, + }, + profile, ) if ('error' in result) { diff --git a/src/routes/simulations.ts b/src/routes/simulations.ts index 2f26b27..a26a547 100644 --- a/src/routes/simulations.ts +++ b/src/routes/simulations.ts @@ -78,21 +78,10 @@ simulations.post('/', authMiddleware, async (c) => { return c.json(result.data, 201) }) -simulations.get('/:id', authMiddleware, async (c) => { - // `:id` est garanti présent par le pattern de route Hono - const id = c.req.param('id')! - const profile = c.get('profile') - - const result = await simulationController.getById(id, profile) - - if ('error' in result) { - return c.json(result, result.status as 401 | 404 | 500) - } - - return c.json(result.data, 200) -}) - // FTD-21 — autosave du contenu d'une simulation en cours. +// Déclaré AVANT `GET /:id` par convention défensive, bien que Hono distingue +// les routes par (méthode, chemin) via un trie : PATCH /:id/contenu ne peut +// pas être capturé par GET /:id. Même raison pour /:id/sujet ci-dessous. simulations.patch('/:id/contenu', authMiddleware, async (c) => { const id = c.req.param('id')! @@ -162,4 +151,19 @@ simulations.patch('/:id/sujet', authMiddleware, async (c) => { return c.json(result.data, 200) }) +// GET /:id en dernier — route catch-all par id, ne doit pas masquer les routes plus spécifiques. +simulations.get('/:id', authMiddleware, async (c) => { + // `:id` est garanti présent par le pattern de route Hono + const id = c.req.param('id')! + const profile = c.get('profile') + + const result = await simulationController.getById(id, profile) + + if ('error' in result) { + return c.json(result, result.status as 401 | 404 | 500) + } + + return c.json(result.data, 200) +}) + export default simulations diff --git a/supabase/migrations/004_sprint_3_6a_qualite_correction.sql b/supabase/migrations/004_sprint_3_6a_qualite_correction.sql new file mode 100644 index 0000000..395ca56 --- /dev/null +++ b/supabase/migrations/004_sprint_3_6a_qualite_correction.sql @@ -0,0 +1,39 @@ +-- Sprint 3.6a — Qualité correction +-- +-- Ajoute les champs nécessaires au nouveau prompt maître (revelation, diagnostic, +-- conseil_nclc, erreurs_codes) + au modèle de génération parallèle asynchrone +-- (exercices, modele, leurs statuses) + le NCLC cible choisi par le candidat. +-- +-- Les colonnes `score`, `nclc`, `rapport` existantes sont **conservées** pour +-- rollback et cohabitation pendant la fenêtre 3.6a → 3.6b (frontend). +-- +-- À exécuter manuellement : `supabase db push` (Hermann — cf. Règle F). + +ALTER TABLE productions + ADD COLUMN IF NOT EXISTS revelation JSONB, + ADD COLUMN IF NOT EXISTS diagnostic TEXT, + ADD COLUMN IF NOT EXISTS conseil_nclc JSONB, + ADD COLUMN IF NOT EXISTS erreurs_codes JSONB, + ADD COLUMN IF NOT EXISTS exercices JSONB, + ADD COLUMN IF NOT EXISTS modele JSONB, + ADD COLUMN IF NOT EXISTS nclc_cible INTEGER, + ADD COLUMN IF NOT EXISTS exercices_status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS modele_status TEXT NOT NULL DEFAULT 'pending'; + +ALTER TABLE productions + DROP CONSTRAINT IF EXISTS productions_nclc_cible_check, + ADD CONSTRAINT productions_nclc_cible_check CHECK (nclc_cible IS NULL OR nclc_cible IN (9, 10)); + +ALTER TABLE productions + DROP CONSTRAINT IF EXISTS productions_exercices_status_check, + ADD CONSTRAINT productions_exercices_status_check + CHECK (exercices_status IN ('pending', 'ready', 'error')); + +ALTER TABLE productions + DROP CONSTRAINT IF EXISTS productions_modele_status_check, + ADD CONSTRAINT productions_modele_status_check + CHECK (modele_status IN ('pending', 'ready', 'error')); + +-- Index pour l'analyse patterns (Sprint 3.6c — agrège erreurs_codes sur les 5 dernières productions). +CREATE INDEX IF NOT EXISTS productions_erreurs_codes_gin_idx + ON productions USING GIN (erreurs_codes);