feat(corrections): Sprint 3.6a — nouveaux prompts + taxonomie erreurs + génération parallèle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df7ef2cc31
commit
63bc43ddcf
14 changed files with 2319 additions and 282 deletions
38
docs/CHANGELOG.md
Normal file
38
docs/CHANGELOG.md
Normal file
|
|
@ -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.
|
||||||
153
docs/Prompt_maître.md
Normal file
153
docs/Prompt_maître.md
Normal file
|
|
@ -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": "<entier 0-20, somme des 4 critères>",
|
||||||
|
"revelation": {
|
||||||
|
"croyance": "<ce que le candidat croit faire bien>",
|
||||||
|
"realite": "<ce que le correcteur observe réellement>",
|
||||||
|
"consequence": "<impact concret sur la note>"
|
||||||
|
},
|
||||||
|
"diagnostic": "<phrase courte et directe identifiant le principal frein>",
|
||||||
|
"criteres": [
|
||||||
|
{
|
||||||
|
"nom": "Adéquation à la tâche et au registre",
|
||||||
|
"score": "<0-5>",
|
||||||
|
"commentaire": "<2 phrases max>",
|
||||||
|
"exemple": "<citation textuelle exacte extraite de la production>",
|
||||||
|
"suggestion": "<reformulation ou correction concrète>",
|
||||||
|
"astuce": "<conseil court et mémorisable>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Cohérence et cohésion du discours",
|
||||||
|
"score": "<0-5>",
|
||||||
|
"commentaire": "<2 phrases max>",
|
||||||
|
"exemple": "<citation textuelle exacte extraite de la production>",
|
||||||
|
"suggestion": "<reformulation ou correction concrète>",
|
||||||
|
"astuce": "<conseil court et mémorisable>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Compétence lexicale",
|
||||||
|
"score": "<0-5>",
|
||||||
|
"commentaire": "<2 phrases max>",
|
||||||
|
"exemple": "<citation textuelle exacte extraite de la production>",
|
||||||
|
"suggestion": "<reformulation ou correction concrète>",
|
||||||
|
"astuce": "<conseil court et mémorisable>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Compétence grammaticale",
|
||||||
|
"score": "<0-5>",
|
||||||
|
"commentaire": "<2 phrases max>",
|
||||||
|
"exemple": "<citation textuelle exacte extraite de la production>",
|
||||||
|
"suggestion": "<reformulation ou correction concrète>",
|
||||||
|
"astuce": "<conseil court et mémorisable>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conseil_nclc": {
|
||||||
|
"nclc_cible": "NCLC {nclc}",
|
||||||
|
"ecart": "<manque X points / objectif atteint / X points au-dessus de l'objectif>",
|
||||||
|
"action_prioritaire": "<conseil direct, concret et personnalisé sur quoi travailler en priorité>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
164
docs/Prompt_production_modèle.md
Normal file
164
docs/Prompt_production_modèle.md
Normal file
|
|
@ -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": "<texte final réécrit, prêt pour l'examen, sans annotation>",
|
||||||
|
"notes_pedagogiques": [
|
||||||
|
{
|
||||||
|
"passage": "<extrait court du texte modèle>",
|
||||||
|
"explication": "<pourquoi ce passage est efficace au TCF>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"passage": "<extrait court du texte modèle>",
|
||||||
|
"explication": "<pourquoi ce passage est efficace au TCF>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
### TD-14 — Erreurs TypeScript TS2835 pré-existantes
|
||||||
**Priorité :** 🟡 Important
|
**Priorité :** 🟡 Important
|
||||||
**Statut :** Résolu — session correction build TypeScript
|
**Statut :** Résolu — session correction build TypeScript
|
||||||
|
|
|
||||||
421
src/controllers/__tests__/correctionController.test.ts
Normal file
421
src/controllers/__tests__/correctionController.test.ts
Normal file
|
|
@ -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<string, unknown>; id: string }[] = []
|
||||||
|
|
||||||
|
const builder = (table: string, productionRow: unknown) => {
|
||||||
|
const obj: Record<string, unknown> = {}
|
||||||
|
obj.select = () => obj
|
||||||
|
obj.eq = () => obj
|
||||||
|
obj.single = async () => ({ data: productionRow, error: null })
|
||||||
|
obj.update = (data: Record<string, unknown>) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -411,6 +411,95 @@ describe('GET /simulations/:id', () => {
|
||||||
expect(body.rapport).toBeNull()
|
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 () => {
|
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
|
||||||
const profile = buildProfile({ id: 'user-123' })
|
const profile = buildProfile({ id: 'user-123' })
|
||||||
mockAuth(profile)
|
mockAuth(profile)
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { PLANS, type Plan } from '../lib/access.js'
|
||||||
import type { EERapport, EORapport } from '../lib/deepseek.js'
|
|
||||||
import type { AuthProfile } from '../middleware/auth.js'
|
import type { AuthProfile } from '../middleware/auth.js'
|
||||||
|
|
||||||
type CorrectionError = {
|
type CorrectionError = {
|
||||||
|
|
@ -11,16 +38,23 @@ type CorrectionError = {
|
||||||
status: number
|
status: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CorrectEEInput {
|
||||||
|
simulationId: string
|
||||||
|
contenu: string
|
||||||
|
tache: TacheEE
|
||||||
|
nclcCible: NclcCible
|
||||||
|
}
|
||||||
|
|
||||||
export async function correctEE(
|
export async function correctEE(
|
||||||
simulationId: string,
|
input: CorrectEEInput,
|
||||||
contenu: string,
|
profile: AuthProfile,
|
||||||
tache: string,
|
): Promise<{ data: CorrectionRapport & { simulation_id: string } } | CorrectionError> {
|
||||||
profile: AuthProfile
|
const { simulationId, contenu, tache, nclcCible } = input
|
||||||
): Promise<{ data: EERapport & { simulation_id: string } } | CorrectionError> {
|
|
||||||
// 1. Vérifier que la production existe et appartient à l'utilisateur
|
// 1. Vérifier que la production existe et appartient à l'utilisateur
|
||||||
const { data: production, error: fetchError } = await supabase
|
const { data: production, error: fetchError } = await supabase
|
||||||
.from('productions')
|
.from('productions')
|
||||||
.select('id, user_id, tache')
|
.select('id, user_id, tache, sujet_id, rapport')
|
||||||
.eq('id', simulationId)
|
.eq('id', simulationId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|
@ -42,11 +76,59 @@ export async function correctEE(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Appeler DeepSeek pour la correction
|
// 2. Charger le sujet pour alimenter le prompt maître (consigne + docs T3)
|
||||||
let rapport: EERapport
|
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 {
|
try {
|
||||||
rapport = await deepseekCorrectEE(contenu, tache)
|
rapport = await correctionPromise
|
||||||
} catch {
|
} 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 {
|
return {
|
||||||
error: true,
|
error: true,
|
||||||
code: 'INTERNAL_ERROR',
|
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
|
const { error: updateError } = await supabase
|
||||||
.from('productions')
|
.from('productions')
|
||||||
.update({
|
.update({
|
||||||
score: rapport.score,
|
score: rapport.score,
|
||||||
nclc: rapport.nclc,
|
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),
|
rapport: JSON.stringify(rapport),
|
||||||
|
exercices_status: 'pending',
|
||||||
|
modele_status: 'pending',
|
||||||
})
|
})
|
||||||
.eq('id', simulationId)
|
.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) {
|
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
|
||||||
await supabase
|
await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
|
|
@ -82,17 +175,69 @@ export async function correctEE(
|
||||||
.eq('id', profile.id)
|
.eq('id', profile.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Retourner le rapport complet enrichi avec simulation_id
|
|
||||||
return { data: { ...rapport, simulation_id: simulationId } }
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
export async function correctEO(
|
||||||
simulationId: string,
|
simulationId: string,
|
||||||
transcript: string,
|
transcript: string,
|
||||||
tache: string,
|
tache: string,
|
||||||
profile: AuthProfile
|
profile: AuthProfile,
|
||||||
): Promise<{ data: EORapport & { simulation_id: string } } | CorrectionError> {
|
): 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
|
const { data: production, error: fetchError } = await supabase
|
||||||
.from('productions')
|
.from('productions')
|
||||||
.select('id, user_id, tache')
|
.select('id, user_id, tache')
|
||||||
|
|
@ -117,7 +262,6 @@ export async function correctEO(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Appeler DeepSeek pour la correction EO
|
|
||||||
let rapport: EORapport
|
let rapport: EORapport
|
||||||
try {
|
try {
|
||||||
rapport = await deepseekCorrectEO(transcript, tache)
|
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
|
const { error: updateError } = await supabase
|
||||||
.from('productions')
|
.from('productions')
|
||||||
.update({
|
.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) {
|
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
|
||||||
await supabase
|
await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
|
|
@ -158,6 +300,5 @@ export async function correctEO(
|
||||||
.eq('id', profile.id)
|
.eq('id', profile.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Retourner le rapport complet enrichi avec simulation_id
|
|
||||||
return { data: { ...rapport, simulation_id: simulationId } }
|
return { data: { ...rapport, simulation_id: simulationId } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { supabase } from '../lib/supabase.js'
|
import { supabase } from '../lib/supabase.js'
|
||||||
import { canUserSimulate } from '../lib/access.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'
|
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 Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE'
|
||||||
export type Mode = 'entrainement' | 'examen'
|
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 —
|
// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc,
|
||||||
// on utilise EERapport comme représentation canonique du rapport parsé.
|
// erreurs_codes) + statuts des jobs asynchrones (modele, exercices).
|
||||||
//
|
//
|
||||||
// FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée).
|
// FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée).
|
||||||
// Le frontend distingue :
|
// Le frontend distingue :
|
||||||
|
|
@ -139,7 +145,12 @@ export interface GetByIdResult {
|
||||||
created_at: string
|
created_at: string
|
||||||
contenu: string | null
|
contenu: string | null
|
||||||
sujet: SujetData | 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 = {
|
type ControllerError = {
|
||||||
|
|
@ -155,7 +166,9 @@ export async function getById(
|
||||||
): Promise<{ data: GetByIdResult } | ControllerError> {
|
): Promise<{ data: GetByIdResult } | ControllerError> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('productions')
|
.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)
|
.eq('id', id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|
@ -188,7 +201,18 @@ export async function getById(
|
||||||
if (sujetRow) sujet = sujetRow as SujetData
|
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 {
|
return {
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -199,6 +223,11 @@ export async function getById(
|
||||||
contenu: data.contenu ?? null,
|
contenu: data.contenu ?? null,
|
||||||
sujet,
|
sujet,
|
||||||
rapport,
|
rapport,
|
||||||
|
nclc_cible: nclcCible,
|
||||||
|
exercices,
|
||||||
|
exercices_status: exercicesStatus,
|
||||||
|
modele,
|
||||||
|
modele_status: modeleStatus,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,150 +1,425 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { CorrectionRapport } from '../deepseek'
|
||||||
|
|
||||||
|
// ── Fixture correction — Sprint 3.6a, forme nouvelle ──────────────────
|
||||||
|
|
||||||
const VALID_RAPPORT = {
|
const VALID_RAPPORT = {
|
||||||
score: 14.5,
|
score: 14,
|
||||||
nclc: 8,
|
nclc: 9,
|
||||||
feedback_court:
|
revelation: {
|
||||||
'Bonne production générale. Quelques points à améliorer sur le lexique et la morphosyntaxe.',
|
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: [
|
criteres: [
|
||||||
{ nom: 'Coherence et cohesion', score: 4, commentaire: 'Bonne organisation.' },
|
{
|
||||||
{ nom: 'Lexique', score: 3, commentaire: 'Vocabulaire correct mais limite.' },
|
nom: 'Adéquation à la tâche et au registre',
|
||||||
{ nom: 'Morphosyntaxe', score: 4, commentaire: 'Structures variees.' },
|
score: 4,
|
||||||
{ nom: 'Pertinence', score: 3.5, commentaire: 'Adequation partielle a la consigne.' },
|
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'],
|
conseil_nclc: {
|
||||||
modele: 'Texte modele corrige ici.',
|
nclc_cible: 'NCLC 9',
|
||||||
idees: ['Developper argumentation', 'Ajouter des exemples concrets'],
|
ecart: 'objectif atteint',
|
||||||
exercices: ['Exercice connecteurs logiques', 'Exercice accords sujet-verbe'],
|
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<CorrectionRapport, 'nclc_cible'> & { erreurs_codes: unknown[] }
|
||||||
|
|
||||||
function mockFetchSuccess(rapport: unknown) {
|
function mockFetchSuccess(payload: unknown) {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({ choices: [{ message: { content: JSON.stringify(payload) } }] }),
|
||||||
choices: [{ message: { content: JSON.stringify(rapport) } }],
|
}),
|
||||||
}),
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('deepseek.correctEE', () => {
|
// ── correctEE (nouvelle signature) ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('deepseek.correctEE — Sprint 3.6a', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
vi.restoreAllMocks()
|
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)
|
mockFetchSuccess(VALID_RAPPORT)
|
||||||
const { correctEE } = await import('../deepseek')
|
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.score).toBe(14)
|
||||||
expect(rapport).toHaveProperty('nclc')
|
expect(rapport.nclc).toBe(9)
|
||||||
expect(rapport).toHaveProperty('feedback_court')
|
expect(rapport.nclc_cible).toBe(9)
|
||||||
expect(rapport).toHaveProperty('criteres')
|
expect(rapport.revelation).toMatchObject({
|
||||||
expect(rapport).toHaveProperty('erreurs')
|
croyance: expect.any(String),
|
||||||
expect(rapport).toHaveProperty('modele')
|
realite: expect.any(String),
|
||||||
expect(rapport).toHaveProperty('idees')
|
consequence: expect.any(String),
|
||||||
expect(rapport).toHaveProperty('exercices')
|
})
|
||||||
|
expect(rapport.diagnostic).toBeTypeOf('string')
|
||||||
expect(rapport.criteres).toHaveLength(4)
|
expect(rapport.criteres).toHaveLength(4)
|
||||||
expect(typeof rapport.feedback_court).toBe('string')
|
expect(rapport.conseil_nclc.nclc_cible).toBe('NCLC 9')
|
||||||
expect(rapport.feedback_court.length).toBeGreaterThan(0)
|
expect(rapport.erreurs_codes.length).toBeGreaterThan(0)
|
||||||
expect(Array.isArray(rapport.erreurs)).toBe(true)
|
|
||||||
expect(Array.isArray(rapport.idees)).toBe(true)
|
|
||||||
expect(Array.isArray(rapport.exercices)).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('score est entre 0 et 20', async () => {
|
it('nclc_cible=10 est propagé dans le rapport', async () => {
|
||||||
mockFetchSuccess(VALID_RAPPORT)
|
mockFetchSuccess({ ...VALID_RAPPORT, score: 18 })
|
||||||
const { correctEE } = await import('../deepseek')
|
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.nclc_cible).toBe(10)
|
||||||
expect(rapport.score).toBeLessThanOrEqual(20)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('nclc est entre 4 et 12', async () => {
|
it('score hors bornes → throw', 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 () => {
|
|
||||||
mockFetchSuccess({ ...VALID_RAPPORT, score: 25 })
|
mockFetchSuccess({ ...VALID_RAPPORT, score: 25 })
|
||||||
const { correctEE } = await import('../deepseek')
|
const { correctEE } = await import('../deepseek')
|
||||||
|
await expect(
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('Score invalide')
|
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 })
|
mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 })
|
||||||
const { correctEE } = await import('../deepseek')
|
const { correctEE } = await import('../deepseek')
|
||||||
|
await expect(
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('NCLC invalide')
|
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 () => {
|
it('revelation absente → throw', async () => {
|
||||||
// Cas 1 : champ absent (JSON.stringify drop les undefined)
|
const bad = { ...VALID_RAPPORT, revelation: undefined }
|
||||||
mockFetchSuccess({ ...VALID_RAPPORT, feedback_court: undefined })
|
mockFetchSuccess(bad)
|
||||||
const { correctEE } = await import('../deepseek')
|
const { correctEE } = await import('../deepseek')
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('feedback_court invalide')
|
await expect(
|
||||||
|
correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }),
|
||||||
// Cas 2 : chaîne vide (whitespace uniquement)
|
).rejects.toThrow('revelation invalide')
|
||||||
mockFetchSuccess({ ...VALID_RAPPORT, feedback_court: ' ' })
|
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('feedback_court 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(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal' }),
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
statusText: 'Internal Server Error',
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
const { correctEE } = await import('../deepseek')
|
const { correctEE } = await import('../deepseek')
|
||||||
|
await expect(
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('DeepSeek API error')
|
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(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ choices: [{ message: { content: '' } }] }),
|
json: async () => ({ choices: [{ message: { content: 'pas du json' } }] }),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
const { correctEE } = await import('../deepseek')
|
const { correctEE } = await import('../deepseek')
|
||||||
|
await expect(
|
||||||
await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow()
|
correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }),
|
||||||
})
|
).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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 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 = {
|
const VALID_RAPPORT_EO = {
|
||||||
score: 12,
|
score: 12,
|
||||||
nclc: 7,
|
nclc: 7,
|
||||||
|
|
@ -168,81 +443,59 @@ describe('deepseek.correctEO', () => {
|
||||||
vi.restoreAllMocks()
|
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)
|
mockFetchSuccess(VALID_RAPPORT_EO)
|
||||||
const { correctEO } = await import('../deepseek')
|
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('feedback_court')
|
||||||
expect(rapport).toHaveProperty('criteres')
|
|
||||||
expect(rapport.criteres).toHaveLength(4)
|
expect(rapport.criteres).toHaveLength(4)
|
||||||
expect(rapport).toHaveProperty('erreurs')
|
expect(rapport.criteres.find((c) => c.nom === 'Phonologie')?.score).toBe(0)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('phonologie est a 0', async () => {
|
it('score hors bornes → throw', 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 () => {
|
|
||||||
mockFetchSuccess({ ...VALID_RAPPORT_EO, score: 25 })
|
mockFetchSuccess({ ...VALID_RAPPORT_EO, score: 25 })
|
||||||
const { correctEO } = await import('../deepseek')
|
const { correctEO } = await import('../deepseek')
|
||||||
|
await expect(correctEO('t', 'EO_T1')).rejects.toThrow('Score invalide')
|
||||||
await expect(correctEO('Transcription', '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 })
|
mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 })
|
||||||
const { correctEO } = await import('../deepseek')
|
const { correctEO } = await import('../deepseek')
|
||||||
|
await expect(correctEO('t', 'EO_T1')).rejects.toThrow('NCLC invalide')
|
||||||
await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('NCLC invalide')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('erreur HTTP depuis DeepSeek API', async () => {
|
it('HTTP error → throw', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'I' }),
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
statusText: 'Internal Server Error',
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
const { correctEO } = await import('../deepseek')
|
const { correctEO } = await import('../deepseek')
|
||||||
|
await expect(correctEO('t', 'EO_T1')).rejects.toThrow('DeepSeek API error')
|
||||||
await expect(correctEO('Transcription', '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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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_API_KEY = process.env.DEEPSEEK_API_KEY ?? ''
|
||||||
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
|
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
|
nom: string
|
||||||
score: number
|
score: number
|
||||||
commentaire: string
|
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
|
score: number
|
||||||
nclc: number
|
nclc: number
|
||||||
feedback_court: string
|
nclc_cible: NclcCible
|
||||||
criteres: EECritere[]
|
revelation: {
|
||||||
erreurs: string[]
|
croyance: string
|
||||||
modele: string
|
realite: string
|
||||||
idees: string[]
|
consequence: string
|
||||||
exercices: 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<TacheEE, { min: number; max: number }> = {
|
||||||
|
EE_T1: { min: 60, max: 120 },
|
||||||
|
EE_T2: { min: 120, max: 150 },
|
||||||
|
EE_T3: { min: 120, max: 180 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_DESCRIPTIONS: Record<TacheEE, string> = {
|
||||||
|
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": <entier 0-20, somme des 4 critères>,
|
||||||
|
"nclc": <entier 4-12, niveau estimé à partir du score>,
|
||||||
|
"revelation": {
|
||||||
|
"croyance": "<ce que le candidat croit faire bien>",
|
||||||
|
"realite": "<ce que le correcteur observe réellement>",
|
||||||
|
"consequence": "<impact concret sur la note>"
|
||||||
|
},
|
||||||
|
"diagnostic": "<phrase courte et directe identifiant le principal frein>",
|
||||||
|
"criteres": [
|
||||||
|
{ "nom": "${CRITERE_LABELS.adequation_tache}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
|
||||||
|
{ "nom": "${CRITERE_LABELS.coherence_cohesion}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
|
||||||
|
{ "nom": "${CRITERE_LABELS.competence_lexicale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
|
||||||
|
{ "nom": "${CRITERE_LABELS.competence_grammaticale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" }
|
||||||
|
],
|
||||||
|
"conseil_nclc": {
|
||||||
|
"nclc_cible": "NCLC ${nclcCible}",
|
||||||
|
"ecart": "<manque X points / objectif atteint / X points au-dessus>",
|
||||||
|
"action_prioritaire": "<conseil direct, concret et personnalisé>"
|
||||||
|
},
|
||||||
|
"erreurs_codes": [
|
||||||
|
{ "code": "<code taxonomie>", "critere": "<un des 4 critères>", "description": <null OU texte si code="autre"> }
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
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": "<texte final, prêt pour l'examen, sans annotation>",
|
||||||
|
"notes_pedagogiques": [
|
||||||
|
{ "passage": "<extrait court du texte modèle>", "explication": "<pourquoi efficace au TCF>" }
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{ "original": "<extrait du candidat>", "ameliore": "<version améliorée>", "explication": "<pourquoi c'est mieux>" }
|
||||||
|
],
|
||||||
|
"message": "<phrase courte encourageante sur les idées du candidat>"
|
||||||
|
}`
|
||||||
|
|
||||||
|
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": "<code taxonomie concerné, ex: accord_sujet_verbe>",
|
||||||
|
"diagnostic": "<1 phrase : quelle erreur cet exercice cible>",
|
||||||
|
"consigne": "<instruction claire donnée au candidat>",
|
||||||
|
"extrait": "<citation exacte du candidat>",
|
||||||
|
"indice": "<piste courte pour corriger sans donner la réponse>",
|
||||||
|
"correction": "<la version corrigée attendue>",
|
||||||
|
"explication": "<pourquoi la correction est meilleure, 2 phrases max>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown>
|
||||||
|
|
||||||
|
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<string, unknown> | 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<string, unknown>
|
||||||
|
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<string, unknown> | 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<CorrectionRapport> {
|
||||||
|
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<ProductionModele> {
|
||||||
|
const { system, user } = buildModelPrompt(input)
|
||||||
|
const content = await callDeepSeek(system, user, 0.3)
|
||||||
|
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||||
|
|
||||||
|
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<string, unknown>)
|
||||||
|
.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<string, unknown>)
|
||||||
|
.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<ExerciceItem[]> {
|
||||||
|
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<string, unknown>)
|
||||||
|
.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 {
|
export interface EOCritere {
|
||||||
nom: string
|
nom: string
|
||||||
score: number
|
score: number
|
||||||
|
|
@ -35,86 +628,6 @@ export interface EORapport {
|
||||||
exercices: string[]
|
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": <number 0-20>,
|
|
||||||
"nclc": <number 4-12>,
|
|
||||||
"feedback_court": "<2 à 3 lignes de feedback global, orientées action>",
|
|
||||||
"criteres": [
|
|
||||||
{ "nom": "Cohérence et cohésion", "score": <number 0-5>, "commentaire": "<string>" },
|
|
||||||
{ "nom": "Lexique", "score": <number 0-5>, "commentaire": "<string>" },
|
|
||||||
{ "nom": "Morphosyntaxe", "score": <number 0-5>, "commentaire": "<string>" },
|
|
||||||
{ "nom": "Pertinence", "score": <number 0-5>, "commentaire": "<string>" }
|
|
||||||
],
|
|
||||||
"erreurs": ["<erreur 1>", "<erreur 2>", ...],
|
|
||||||
"modele": "<texte modèle corrigé>",
|
|
||||||
"idees": ["<idée 1>", "<idée 2>", ...],
|
|
||||||
"exercices": ["<exercice recommandé 1>", "<exercice recommandé 2>", ...]
|
|
||||||
}
|
|
||||||
|
|
||||||
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<EERapport> {
|
|
||||||
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).
|
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 :
|
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
|
1. Cohérence et cohésion
|
||||||
|
|
@ -255,3 +768,7 @@ export async function correctEO(transcript: string, tache: string): Promise<EORa
|
||||||
|
|
||||||
return rapport
|
return rapport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias legacy — temporairement conservé le temps que correctionController.correctEE
|
||||||
|
// soit migré vers la nouvelle signature (étape E5).
|
||||||
|
export type EERapport = CorrectionRapport
|
||||||
|
|
|
||||||
151
src/lib/taxonomieErreurs.ts
Normal file
151
src/lib/taxonomieErreurs.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Taxonomie fermée des erreurs — cf. docs/TAXONOMIE_ERREURS.md v1.0.
|
||||||
|
*
|
||||||
|
* Source unique des codes acceptés dans les rapports DeepSeek. Utilisée par :
|
||||||
|
* 1. le prompt maître (injection de la liste des codes par critère)
|
||||||
|
* 2. la validation runtime des réponses DeepSeek (garde-fou taxonomie)
|
||||||
|
*
|
||||||
|
* Règle : chaque erreur retournée doit avoir un code présent ici, ou `autre`
|
||||||
|
* avec une `description` non vide.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Critere =
|
||||||
|
| 'adequation_tache'
|
||||||
|
| 'coherence_cohesion'
|
||||||
|
| 'competence_lexicale'
|
||||||
|
| 'competence_grammaticale'
|
||||||
|
|
||||||
|
export const CRITERES: readonly Critere[] = [
|
||||||
|
'adequation_tache',
|
||||||
|
'coherence_cohesion',
|
||||||
|
'competence_lexicale',
|
||||||
|
'competence_grammaticale',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellés officiels TCF Canada — affichés côté frontend.
|
||||||
|
* Alignés sur les intitulés du prompt maître (docs/Prompt_maître.md §CRITÈRES).
|
||||||
|
*/
|
||||||
|
export const CRITERE_LABELS: Record<Critere, string> = {
|
||||||
|
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<Critere, readonly string[]> = {
|
||||||
|
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<number, number> = {
|
||||||
|
7: 10,
|
||||||
|
8: 12,
|
||||||
|
9: 14,
|
||||||
|
10: 16,
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,12 @@ const VALID_TACHES_EO = ['EO_T1', 'EO_T3']
|
||||||
const corrections = new Hono<{ Variables: AppVariables }>()
|
const corrections = new Hono<{ Variables: AppVariables }>()
|
||||||
|
|
||||||
corrections.post('/ee', authMiddleware, async (c) => {
|
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 {
|
try {
|
||||||
body = await c.req.json()
|
body = await c.req.json()
|
||||||
} catch {
|
} 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 profile = c.get('profile')
|
||||||
const result = await correctionController.correctEE(
|
const result = await correctionController.correctEE(
|
||||||
body.simulationId as string,
|
{
|
||||||
body.contenu as string,
|
simulationId: body.simulationId,
|
||||||
body.tache as string,
|
contenu: body.contenu,
|
||||||
profile
|
tache: body.tache as 'EE_T1' | 'EE_T2' | 'EE_T3',
|
||||||
|
nclcCible,
|
||||||
|
},
|
||||||
|
profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
|
|
|
||||||
|
|
@ -78,21 +78,10 @@ simulations.post('/', authMiddleware, async (c) => {
|
||||||
return c.json(result.data, 201)
|
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.
|
// 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) => {
|
simulations.patch('/:id/contenu', authMiddleware, async (c) => {
|
||||||
const id = c.req.param('id')!
|
const id = c.req.param('id')!
|
||||||
|
|
||||||
|
|
@ -162,4 +151,19 @@ simulations.patch('/:id/sujet', authMiddleware, async (c) => {
|
||||||
return c.json(result.data, 200)
|
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
|
export default simulations
|
||||||
|
|
|
||||||
39
supabase/migrations/004_sprint_3_6a_qualite_correction.sql
Normal file
39
supabase/migrations/004_sprint_3_6a_qualite_correction.sql
Normal file
|
|
@ -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);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue