feat(corrections/eo): évaluation phonologique Gemini — 5 critères × /4 (Sprint 4.8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34b4bcdd82
commit
ec0598d122
15 changed files with 2086 additions and 290 deletions
|
|
@ -6,6 +6,27 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
|
|||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO
|
||||
|
||||
### Added
|
||||
|
||||
- `src/lib/geminiPhonology.ts` — évaluation phonologique via Gemini 2.5 Flash (audio brut, JSON strict, timeout 45s + 1 retry). `PHONOLOGY_STUB` pour Mode A (transcript sans audio).
|
||||
- `src/lib/__tests__/geminiPhonology.test.ts` — 9 tests (parse, cap 0..4, retry, erreurs HTTP).
|
||||
- `src/controllers/__tests__/correctionEoPhonology.test.ts` — 4 tests (injection 5e critère, stub Mode A, fallback Gemini down, score max 20).
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /corrections/eo` — passe de 4 critères × /5 à 5 critères × /4 (score total /20 inchangé). Phonologie évaluée par Gemini en parallèle de la transcription (Mode B audio). Fallback stub si Gemini phonologie échoue.
|
||||
- `src/lib/deepseek.ts` — prompt EO : cap critère /5 → /4, libellés officiels TCF Canada, mention phonologie évaluée séparément. Cap EE inchangé /5.
|
||||
- TD-08 partiellement résolu (T1/T3). T2 Live reste à 0 (Sprint 6).
|
||||
|
||||
### Notes
|
||||
|
||||
- ⚠️ Breaking change frontend : criteres.length passe de 4 à 5, échelle /5 → /4.
|
||||
- Tests : 248 → 261 verts (+13).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-04-25 — Sprint 4a/4b — Backend EO
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
# 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.
|
||||
697
docs/IMPLEMENTATION_T2_LIVE.md
Normal file
697
docs/IMPLEMENTATION_T2_LIVE.md
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
# IMPLEMENTATION_T2_LIVE.md — Algorithme d'implémentation T2 EO Live
|
||||
|
||||
> **Document de référence technique — Sprint 6**
|
||||
> Basé exclusivement sur la documentation officielle Google Gemini Live API.
|
||||
> Sources : ai.google.dev/gemini-api/docs/live-api (get-started-websocket, capabilities, session-management, ephemeral-tokens, best-practices, rate-limits, pricing)
|
||||
> Date de vérification : 2026-04-26
|
||||
|
||||
---
|
||||
|
||||
## 1. Spécifications officielles vérifiées
|
||||
|
||||
### 1.1 Modèle
|
||||
|
||||
| Paramètre | Valeur | Source |
|
||||
|---|---|---|
|
||||
| Modèle cible | `gemini-2.5-flash-native-audio` ou `gemini-2.5-flash-native-audio-preview-12-2025` | ai.google.dev/gemini-api/docs/models |
|
||||
| Accès Hermann | Confirmé | Session 2026-04-26 |
|
||||
|
||||
### 1.2 Audio — formats officiels
|
||||
|
||||
| Direction | Format | Sample rate | Encoding | MIME type |
|
||||
|---|---|---|---|---|
|
||||
| Client → Gemini | PCM brut | 16 kHz | 16 bits, little-endian, mono | `audio/pcm;rate=16000` |
|
||||
| Gemini → Client | PCM brut | 24 kHz | 16 bits, little-endian, mono | `audio/pcm;rate=24000` |
|
||||
|
||||
Source : « Audio data in the Live API is always raw, little-endian, 16-bit PCM. Audio output always uses a sample rate of 24kHz. Input audio is natively 16kHz, but the Live API will resample if needed so any sample rate can be sent. » — ai.google.dev/gemini-api/docs/live-guide
|
||||
|
||||
### 1.3 Endpoint WebSocket
|
||||
|
||||
```
|
||||
wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key={API_KEY}
|
||||
```
|
||||
|
||||
Source : ai.google.dev/gemini-api/docs/live-api/get-started-websocket
|
||||
|
||||
### 1.4 Limites de session
|
||||
|
||||
| Paramètre | Valeur | Source |
|
||||
|---|---|---|
|
||||
| Durée max session audio-only | **15 minutes** | ai.google.dev/gemini-api/docs/live-guide §Limitations |
|
||||
| Context window | 128k tokens (native audio) | idem |
|
||||
| Session state | Stateful dans une session, pas de mémoire inter-session | idem |
|
||||
|
||||
### 1.5 Fonctionnalités natives utilisées
|
||||
|
||||
| Fonctionnalité | Activation | Utilité Expria |
|
||||
|---|---|---|
|
||||
| Voice Activity Detection (VAD) | Automatique, configuré : `endOfSpeechSensitivity: LOW`, `silenceDurationMs: 2000` | Détecte quand le candidat parle/s'arrête. 2s de silence avant que l'IA réponde — laisse le temps de réfléchir |
|
||||
| Barge-in (interruption) | Natif, non configurable | L'utilisateur peut interrompre l'IA naturellement |
|
||||
| Input transcription | `inputAudioTranscription: {}` dans config | Transcript de ce que dit le candidat |
|
||||
| Output transcription | `outputAudioTranscription: {}` dans config | Transcript de ce que dit l'IA |
|
||||
| Affective dialog | `enableAffectiveDialog: true` (v1alpha) | Optionnel — ton naturel |
|
||||
|
||||
### 1.6 Configuration VAD — justification
|
||||
|
||||
| Paramètre | Valeur retenue | Justification |
|
||||
|---|---|---|
|
||||
| `disabled` | `false` | VAD automatique côté Gemini — le frontend n'a pas à gérer la détection de parole |
|
||||
| `startOfSpeechSensitivity` | `START_SENSITIVITY_LOW` | Évite les faux positifs (bruits ambiants, respiration) |
|
||||
| `endOfSpeechSensitivity` | `END_SENSITIVITY_LOW` | Tolère les pauses de réflexion du candidat sans couper la parole |
|
||||
| `silenceDurationMs` | `2000` | 2 secondes de silence avant que l'IA considère que le candidat a fini. À ajuster entre 1500-3000ms après tests manuels |
|
||||
|
||||
**Fallback si le VAD automatique ne convient pas :**
|
||||
Désactiver le VAD (`disabled: true`) et basculer sur un mode "talkie-walkie" :
|
||||
le frontend envoie `activityStart` quand le candidat appuie sur un bouton "Parler",
|
||||
et `activityEnd` quand il relâche. Moins naturel mais fiable à 100%.
|
||||
|
||||
### 1.6 Tarification
|
||||
|
||||
| Tier | Audio input | Audio output | Source |
|
||||
|---|---|---|---|
|
||||
| Free | Disponible avec rate limits | idem | ai.google.dev/gemini-api/docs/pricing |
|
||||
| Paid (Tier 1+) | Inclus dans le token count | idem | idem |
|
||||
|
||||
Note : le pricing Live API est basé sur le token count, pas sur la durée. Les tokens audio sont comptés différemment des tokens texte. Vérifier les rate limits réels dans AI Studio pour le projet Expria.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture — vue d'ensemble
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Navigateur candidat │
|
||||
│ (React + AudioWorklet) │
|
||||
│ PCM 16kHz → base64 │
|
||||
│ base64 → PCM 24kHz │
|
||||
└──────────┬──────────────┘
|
||||
│ WebSocket (wss://api.expria.app/t2/live?token=jwt&sujet=uuid)
|
||||
│
|
||||
┌──────────▼──────────────┐
|
||||
│ Backend Expria │
|
||||
│ (Hono / Node.js) │
|
||||
│ Render Frankfurt │
|
||||
│ │
|
||||
│ 1. Auth JWT + plan │
|
||||
│ 2. Fetch sujet │
|
||||
│ 3. Build prompt │
|
||||
│ 4. Proxy bidirectionnel│
|
||||
│ 5. Accumule transcript │
|
||||
│ 6. Évaluation finale │
|
||||
│ 7. Sauvegarde BDD │
|
||||
└──────────┬──────────────┘
|
||||
│ WebSocket (wss://generativelanguage.googleapis.com/ws/...)
|
||||
│
|
||||
┌──────────▼──────────────┐
|
||||
│ Gemini Live API │
|
||||
│ gemini-2.5-flash- │
|
||||
│ native-audio │
|
||||
│ Google Cloud │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Pourquoi un proxy backend (et pas client-to-server direct)
|
||||
|
||||
Google recommande officiellement les tokens éphémères pour les apps client-to-server. Cependant, pour Expria :
|
||||
|
||||
1. La clé API (`GEMINI_API_KEY`) ne doit jamais être exposée côté frontend (règle absolue SECURITY.md)
|
||||
2. Le backend doit accumuler le transcript pour l'évaluation finale
|
||||
3. Le backend doit sauvegarder la production en base après la session
|
||||
4. Le gating plan Premium doit être vérifié côté serveur
|
||||
|
||||
Le proxy backend est la seule architecture viable.
|
||||
|
||||
---
|
||||
|
||||
## 3. Algorithme d'exécution — Backend
|
||||
|
||||
### Phase 1 : Connexion (< 2 secondes)
|
||||
|
||||
```
|
||||
ENTRÉE : WebSocket client avec ?token=jwt&sujet=uuid
|
||||
|
||||
1. EXTRAIRE jwt et sujet_id des query params
|
||||
2. VÉRIFIER jwt via Supabase → obtenir profile
|
||||
├─ INVALIDE → close(4001, "AUTH_REQUIRED")
|
||||
└─ VALIDE → continuer
|
||||
3. VÉRIFIER hasAccess(profile.plan, 'oral_t2_live')
|
||||
├─ INSUFFISANT → close(4003, "PLAN_INSUFFICIENT")
|
||||
└─ OK → continuer
|
||||
4. FETCH sujet FROM sujets WHERE id = sujet_id AND mode = 'EO' AND tache = 2
|
||||
├─ NOT FOUND → close(4004, "SUJET_NOT_FOUND")
|
||||
└─ TROUVÉ → extraire consigne, contexte, role
|
||||
5. CONSTRUIRE systemPrompt à partir du template Prompt_t2live.md §3
|
||||
avec substitution {role} et {contexte}
|
||||
6. OUVRIR WebSocket vers Gemini Live API :
|
||||
URL = wss://generativelanguage.googleapis.com/ws/
|
||||
google.ai.generativelanguage.v1beta.
|
||||
GenerativeService.BidiGenerateContent?key={GEMINI_API_KEY}
|
||||
7. ENVOYER setup frame :
|
||||
{
|
||||
"config": {
|
||||
"model": "models/gemini-2.5-flash-native-audio",
|
||||
"responseModalities": ["AUDIO"],
|
||||
"systemInstruction": {
|
||||
"parts": [{ "text": systemPrompt }]
|
||||
},
|
||||
"inputAudioTranscription": {},
|
||||
"outputAudioTranscription": {},
|
||||
"speechConfig": {
|
||||
"voiceConfig": {
|
||||
"prebuiltVoiceConfig": { "voiceName": "Kore" }
|
||||
}
|
||||
},
|
||||
"realtimeInputConfig": {
|
||||
"automaticActivityDetection": {
|
||||
"disabled": false,
|
||||
"startOfSpeechSensitivity": "START_SENSITIVITY_LOW",
|
||||
"endOfSpeechSensitivity": "END_SENSITIVITY_LOW",
|
||||
"silenceDurationMs": 2000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// VAD : END_SENSITIVITY_LOW + 2s de silence avant que l'IA réponde
|
||||
// → le candidat peut réfléchir entre ses phrases sans être interrompu
|
||||
// À ajuster entre 1500-3000ms après tests manuels
|
||||
// Si le VAD automatique ne convient pas : option fallback VAD manuel
|
||||
// disabled: true + activityStart/activityEnd côté client (mode talkie-walkie)
|
||||
8. INITIALISER accumulateurs :
|
||||
- inputTranscript = [] // ce que dit le candidat
|
||||
- outputTranscript = [] // ce que dit l'IA
|
||||
- sessionStartTime = Date.now()
|
||||
```
|
||||
|
||||
### Phase 2 : Proxy bidirectionnel (durée libre, max 15 min)
|
||||
|
||||
```
|
||||
BOUCLE PARALLÈLE :
|
||||
|
||||
THREAD A — Client → Gemini :
|
||||
POUR CHAQUE message reçu du client :
|
||||
SI message.type === 'audio' :
|
||||
TRANSMETTRE à Gemini : {
|
||||
"realtimeInput": {
|
||||
"audio": {
|
||||
"data": message.data, // base64 PCM 16kHz
|
||||
"mimeType": "audio/pcm;rate=16000"
|
||||
}
|
||||
}
|
||||
}
|
||||
SI message.type === 'end' :
|
||||
DÉCLENCHER Phase 3
|
||||
|
||||
THREAD B — Gemini → Client :
|
||||
POUR CHAQUE message reçu de Gemini :
|
||||
SI message.serverContent.modelTurn?.parts :
|
||||
POUR CHAQUE part :
|
||||
SI part.inlineData :
|
||||
TRANSMETTRE au client : {
|
||||
type: 'audio',
|
||||
data: part.inlineData.data, // base64 PCM 24kHz
|
||||
mimeType: part.inlineData.mimeType
|
||||
}
|
||||
|
||||
SI message.serverContent.inputTranscription :
|
||||
ACCUMULER dans inputTranscript[]
|
||||
|
||||
SI message.serverContent.outputTranscription :
|
||||
ACCUMULER dans outputTranscript[]
|
||||
|
||||
SI message.serverContent.interrupted :
|
||||
TRANSMETTRE au client : { type: 'interrupted' }
|
||||
|
||||
SI message.serverContent.turnComplete :
|
||||
TRANSMETTRE au client : { type: 'turnComplete' }
|
||||
|
||||
GUARD — Timeout 15 min :
|
||||
SI Date.now() - sessionStartTime > 14 * 60 * 1000 :
|
||||
TRANSMETTRE au client : { type: 'warning', message: '1 minute restante' }
|
||||
SI Date.now() - sessionStartTime > 15 * 60 * 1000 :
|
||||
DÉCLENCHER Phase 3
|
||||
```
|
||||
|
||||
### Phase 3 : Fin de session + évaluation (< 30 secondes)
|
||||
|
||||
```
|
||||
1. FERMER WebSocket Gemini (close 1000)
|
||||
2. RECONSTRUIRE le transcript complet :
|
||||
fullTranscript = inputTranscript.map(t => "Candidat : " + t.text)
|
||||
.interleave(outputTranscript.map(t => "Examinateur : " + t.text))
|
||||
3. CRÉER production en base :
|
||||
INSERT INTO productions (
|
||||
user_id, tache, mode, contenu, created_at
|
||||
) VALUES (
|
||||
profile.id, 'EO_T2_LIVE', 'entrainement', fullTranscript, NOW()
|
||||
)
|
||||
→ obtenir production.id
|
||||
4. ENVOYER le transcript au pipeline de correction EO existant :
|
||||
correctionResult = await correctEO({
|
||||
transcript: fullTranscript,
|
||||
tache: 'EO_T2',
|
||||
nclcCible: profile.nclc_cible || 9,
|
||||
productionId: production.id
|
||||
})
|
||||
// Réutilise le prompt de correction EO + DeepSeek
|
||||
// Note : phonologie = 0 (TD-08, pas d'audio brut disponible)
|
||||
5. METTRE À JOUR la production :
|
||||
UPDATE productions SET rapport = correctionResult.rapport,
|
||||
score = correctionResult.score,
|
||||
nclc = correctionResult.nclc
|
||||
WHERE id = production.id
|
||||
6. TRANSMETTRE au client :
|
||||
{ type: 'report', data: correctionResult }
|
||||
7. FERMER WebSocket client (close 1000)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Algorithme d'exécution — Frontend
|
||||
|
||||
### Phase 1 : Initialisation
|
||||
|
||||
```
|
||||
1. PAGE DE SÉLECTION SUJET :
|
||||
- Fetch GET /sujets?mode=EO&tache=2 → liste sujets
|
||||
- Afficher grille de sujets (réutiliser SujetsEOPage)
|
||||
- Clic sujet → stocker sujet.id + sujet.consigne
|
||||
|
||||
2. PAGE DE PRÉPARATION :
|
||||
- Afficher consigne + contexte du sujet
|
||||
- Explication : "Vous êtes le candidat. C'est à vous de prendre la parole
|
||||
en premier pour initier la conversation, comme à l'examen réel."
|
||||
- Bouton "Démarrer le dialogue"
|
||||
- Demander permission micro (navigator.mediaDevices.getUserMedia)
|
||||
```
|
||||
|
||||
### Phase 2 : Connexion audio + WebSocket
|
||||
|
||||
```
|
||||
AU CLIC "Démarrer" :
|
||||
|
||||
1. OUVRIR WebSocket :
|
||||
ws = new WebSocket(`wss://api.expria.app/t2/live?token=${jwt}&sujet=${sujetId}`)
|
||||
|
||||
2. STATE MACHINE → 'connecting'
|
||||
|
||||
3. INITIALISER AudioContext capture (16kHz) :
|
||||
captureCtx = new AudioContext({ sampleRate: 16000 })
|
||||
// Si le navigateur ne supporte pas 16kHz nativement,
|
||||
// créer à sampleRate par défaut et rééchantillonner dans le worklet
|
||||
|
||||
4. CHARGER AudioWorklet :
|
||||
await captureCtx.audioWorklet.addModule('pcm-capture-processor.js')
|
||||
// Le worklet :
|
||||
// - Reçoit des Float32 du micro
|
||||
// - Rééchantillonne à 16kHz si nécessaire
|
||||
// - Convertit Float32 → Int16 PCM little-endian
|
||||
// - Envoie les chunks via port.postMessage
|
||||
|
||||
5. CONNECTER le micro :
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
})
|
||||
source = captureCtx.createMediaStreamSource(stream)
|
||||
workletNode = new AudioWorkletNode(captureCtx, 'pcm-capture-processor')
|
||||
source.connect(workletNode)
|
||||
|
||||
6. INITIALISER AudioContext playback (24kHz) :
|
||||
playbackCtx = new AudioContext({ sampleRate: 24000 })
|
||||
// File d'attente de buffers audio pour lecture séquentielle
|
||||
|
||||
7. ÉCOUTER les chunks du worklet :
|
||||
workletNode.port.onmessage = (e) => {
|
||||
const pcmBase64 = arrayBufferToBase64(e.data)
|
||||
ws.send(JSON.stringify({ type: 'audio', data: pcmBase64 }))
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3 : Dialogue en temps réel
|
||||
|
||||
```
|
||||
STATE MACHINE :
|
||||
'connecting' → ws.onopen → 'ready' (candidat peut parler)
|
||||
'ready' → audio candidat détecté → 'speaking'
|
||||
'speaking' → silence détecté (VAD) → 'listening' (IA répond)
|
||||
'listening' → audio candidat détecté → 'speaking'
|
||||
'speaking' ↔ 'listening' (boucle dialogue)
|
||||
'*' → bouton "Terminer" → 'processing'
|
||||
'processing' → rapport reçu → 'ended'
|
||||
'*' → erreur WS → 'error'
|
||||
|
||||
// Le candidat initie la conversation (Option A — conforme à l'examen réel).
|
||||
// L'IA attend en silence que le candidat prenne la parole.
|
||||
// Gemini VAD (silenceDurationMs: 2000) gère la détection automatiquement.
|
||||
|
||||
RÉCEPTION messages WebSocket :
|
||||
POUR CHAQUE message reçu :
|
||||
SI message.type === 'audio' :
|
||||
DÉCODER base64 → Int16 PCM
|
||||
CRÉER AudioBuffer (24kHz, mono)
|
||||
AJOUTER à la file de lecture
|
||||
SI pas déjà en lecture → DÉMARRER lecture
|
||||
|
||||
SI message.type === 'turnComplete' :
|
||||
STATE → 'ready' (le candidat peut reprendre la parole)
|
||||
|
||||
SI message.type === 'interrupted' :
|
||||
ARRÊTER lecture audio en cours
|
||||
VIDER file de lecture
|
||||
|
||||
SI message.type === 'report' :
|
||||
STATE → 'ended'
|
||||
NAVIGUER vers /rapport/:productionId
|
||||
|
||||
SI message.type === 'warning' :
|
||||
AFFICHER notification "1 minute restante"
|
||||
|
||||
SI message.type === 'error' :
|
||||
STATE → 'error'
|
||||
AFFICHER message + bouton "Réessayer"
|
||||
|
||||
ENVOI fin de dialogue :
|
||||
AU CLIC "Terminer" :
|
||||
ws.send(JSON.stringify({ type: 'end' }))
|
||||
STATE → 'processing'
|
||||
ARRÊTER capture micro
|
||||
AFFICHER spinner "Évaluation en cours..."
|
||||
```
|
||||
|
||||
### Phase 4 : Cleanup
|
||||
|
||||
```
|
||||
À LA FERMETURE (fin normale, erreur, ou navigation) :
|
||||
1. FERMER WebSocket si ouvert
|
||||
2. ARRÊTER MediaStream (stream.getTracks().forEach(t => t.stop()))
|
||||
3. FERMER captureCtx (captureCtx.close())
|
||||
4. FERMER playbackCtx (playbackCtx.close())
|
||||
5. ANNULER tout rAF ou timer en cours
|
||||
6. SI fin normale (state === 'ended') :
|
||||
- Conserver recordingChunks pour le bouton "Télécharger"
|
||||
SINON :
|
||||
- Libérer recordingChunks (= [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AudioWorklet — pcm-capture-processor.js
|
||||
|
||||
```javascript
|
||||
// Exécuté dans un thread séparé (Audio Worklet Thread)
|
||||
class PcmCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.buffer = new Float32Array(0)
|
||||
// Chunk size : 4096 samples à 16kHz = 256ms de latence
|
||||
// Compromis entre latence et overhead réseau
|
||||
this.chunkSize = 4096
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0]
|
||||
if (!input || !input[0]) return true
|
||||
|
||||
const channelData = input[0] // mono
|
||||
|
||||
// Accumuler
|
||||
const newBuffer = new Float32Array(this.buffer.length + channelData.length)
|
||||
newBuffer.set(this.buffer)
|
||||
newBuffer.set(channelData, this.buffer.length)
|
||||
this.buffer = newBuffer
|
||||
|
||||
// Envoyer quand on a assez de samples
|
||||
while (this.buffer.length >= this.chunkSize) {
|
||||
const chunk = this.buffer.slice(0, this.chunkSize)
|
||||
this.buffer = this.buffer.slice(this.chunkSize)
|
||||
|
||||
// Float32 → Int16 PCM little-endian
|
||||
const pcm = new ArrayBuffer(chunk.length * 2)
|
||||
const view = new DataView(pcm)
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, chunk[i]))
|
||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true) // true = little-endian
|
||||
}
|
||||
|
||||
this.port.postMessage(pcm, [pcm]) // Transferable
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-capture-processor', PcmCaptureProcessor)
|
||||
```
|
||||
|
||||
Note : si le navigateur crée l'AudioContext à 44.1kHz ou 48kHz au lieu de 16kHz, un rééchantillonnage est nécessaire dans le processor. Gemini accepte tout sample rate (il rééchantillonne côté serveur), mais envoyer du 48kHz triple la bande passante inutilement. Privilégier `new AudioContext({ sampleRate: 16000 })` — supporté sur Chrome, Firefox, Edge modernes.
|
||||
|
||||
---
|
||||
|
||||
## 5bis. Enregistrement audio téléchargeable
|
||||
|
||||
La conversation complète (candidat + IA) est enregistrée côté frontend pour
|
||||
permettre le téléchargement en fin de session — comme pour EO T1/T3.
|
||||
|
||||
### Principe : buffer chronologique unique
|
||||
|
||||
Les chunks audio du candidat (16kHz) et de l'IA (24kHz) arrivent en temps réel.
|
||||
Ils sont **horodatés et accumulés dans un buffer unique** dans l'ordre
|
||||
chronologique réel, puis assemblés en un fichier WAV mono 24kHz en fin de session.
|
||||
|
||||
```
|
||||
AU DÉMARRAGE de la session :
|
||||
recordingChunks = [] // { data: Int16Array, source: 'candidate'|'ai', time: number }
|
||||
sessionStartTime = Date.now()
|
||||
|
||||
À CHAQUE chunk envoyé par le candidat (PCM 16kHz) :
|
||||
1. RÉÉCHANTILLONNER 16kHz → 24kHz (interpolation linéaire : chaque sample
|
||||
est dupliqué × 1.5 — ou utiliser OfflineAudioContext pour un résultat propre)
|
||||
2. recordingChunks.push({
|
||||
data: resampled24k, // Int16Array à 24kHz
|
||||
source: 'candidate',
|
||||
time: Date.now()
|
||||
})
|
||||
|
||||
À CHAQUE chunk reçu de l'IA (PCM 24kHz) :
|
||||
recordingChunks.push({
|
||||
data: chunk, // Int16Array déjà à 24kHz
|
||||
source: 'ai',
|
||||
time: Date.now()
|
||||
})
|
||||
|
||||
EN FIN DE SESSION (après réception du rapport) :
|
||||
1. TRIER recordingChunks par time (normalement déjà ordonné)
|
||||
2. CONCATÉNER tous les .data en un seul Int16Array
|
||||
3. ENCODER en WAV :
|
||||
- Header WAV : 44 octets (PCM, mono, 24kHz, 16 bits)
|
||||
- Data : le buffer concaténé
|
||||
4. CRÉER Blob + URL.createObjectURL
|
||||
5. PROPOSER bouton "Télécharger l'audio" (download filename :
|
||||
expria-t2-{date}.wav)
|
||||
```
|
||||
|
||||
### Détail du rééchantillonnage candidat 16kHz → 24kHz
|
||||
|
||||
```javascript
|
||||
// Méthode simple : interpolation linéaire
|
||||
// Ratio : 24000 / 16000 = 1.5 → pour 2 samples en entrée, 3 en sortie
|
||||
function resample16to24(input16k) {
|
||||
const ratio = 24000 / 16000 // 1.5
|
||||
const outputLength = Math.ceil(input16k.length * ratio)
|
||||
const output = new Int16Array(outputLength)
|
||||
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
const srcIndex = i / ratio
|
||||
const srcFloor = Math.floor(srcIndex)
|
||||
const srcCeil = Math.min(srcFloor + 1, input16k.length - 1)
|
||||
const frac = srcIndex - srcFloor
|
||||
|
||||
output[i] = Math.round(
|
||||
input16k[srcFloor] * (1 - frac) + input16k[srcCeil] * frac
|
||||
)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
```
|
||||
|
||||
### Estimation mémoire
|
||||
|
||||
- Dialogue de 10 minutes = 600 secondes
|
||||
- PCM 24kHz mono 16 bits = 48 000 octets/seconde
|
||||
- Total : 600 × 48 000 = **~28 Mo en mémoire**
|
||||
- Acceptable pour un navigateur moderne (RAM > 1 Go)
|
||||
|
||||
### Fichier WAV header
|
||||
|
||||
```javascript
|
||||
function createWavFile(pcmData, sampleRate = 24000) {
|
||||
const numChannels = 1
|
||||
const bitsPerSample = 16
|
||||
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
|
||||
const blockAlign = numChannels * (bitsPerSample / 8)
|
||||
const dataSize = pcmData.byteLength
|
||||
const buffer = new ArrayBuffer(44 + dataSize)
|
||||
const view = new DataView(buffer)
|
||||
|
||||
// RIFF header
|
||||
writeString(view, 0, 'RIFF')
|
||||
view.setUint32(4, 36 + dataSize, true)
|
||||
writeString(view, 8, 'WAVE')
|
||||
// fmt chunk
|
||||
writeString(view, 12, 'fmt ')
|
||||
view.setUint32(16, 16, true) // chunk size
|
||||
view.setUint16(20, 1, true) // PCM format
|
||||
view.setUint16(22, numChannels, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, byteRate, true)
|
||||
view.setUint16(32, blockAlign, true)
|
||||
view.setUint16(34, bitsPerSample, true)
|
||||
// data chunk
|
||||
writeString(view, 36, 'data')
|
||||
view.setUint32(40, dataSize, true)
|
||||
// PCM data
|
||||
new Uint8Array(buffer, 44).set(new Uint8Array(pcmData.buffer))
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Scalabilité et limites
|
||||
|
||||
### 6.1 Render (plan Starter)
|
||||
|
||||
| Contrainte | Valeur | Impact Expria |
|
||||
|---|---|---|
|
||||
| Connexions WS simultanées | Pas de limite documentée (Starter) | OK pour MVP |
|
||||
| Timeout connexion | Pas de hard limit WS | OK — Gemini a son propre cap de 15 min |
|
||||
| Mémoire | 512 Mo (Starter) | Chaque session T2 = 2 WS + buffers audio ≈ 5-10 Mo. ~50 sessions simultanées max théorique |
|
||||
| CPU | Partagé (Starter) | Le backend est un proxy passif (pas de traitement audio) — charge CPU minimale |
|
||||
|
||||
**Scalabilité :** le goulot d'étranglement n'est pas Render mais le rate limit Gemini. Avec le plan Paid Tier 1 Google, le nombre de sessions simultanées est limité par les RPM/TPM du projet Google AI.
|
||||
|
||||
### 6.2 Gemini Live API
|
||||
|
||||
| Contrainte | Valeur | Impact |
|
||||
|---|---|---|
|
||||
| Session max | 15 min (audio-only) | Suffisant pour T2 EO — dialogue libre en entraînement |
|
||||
| Context window | 128k tokens | Largement suffisant pour un dialogue oral de 15 min |
|
||||
| Rate limits | Variables par tier — vérifier dans AI Studio | À monitorer en production |
|
||||
| Sessions simultanées | Non documenté précisément — dépend du tier | Commencer avec 1-3 simultanées, scaler au besoin |
|
||||
|
||||
### 6.3 Stratégie de scalabilité progressive
|
||||
|
||||
```
|
||||
Phase 1 — MVP (Sprint 6) :
|
||||
- 1 seul projet Google AI
|
||||
- Plan Free ou Paid Tier 1
|
||||
- Objectif : < 5 sessions T2 simultanées
|
||||
- Monitoring : log chaque session (durée, tokens, erreurs)
|
||||
|
||||
Phase 2 — Production (post-launch) :
|
||||
- Passer en Paid Tier 2 si nécessaire
|
||||
- Ajouter un rate limiter côté backend (max 1 session T2 par utilisateur)
|
||||
- Queue de sessions si le rate limit Gemini est atteint
|
||||
- Monitoring : alertes sur le coût token mensuel
|
||||
|
||||
Phase 3 — Scale (si croissance) :
|
||||
- Considérer Vertex AI pour SLA et rate limits supérieurs
|
||||
- Load balancing multi-instance Render
|
||||
- Session affinity (sticky sessions pour les WS)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Gestion des erreurs
|
||||
|
||||
### 7.1 Erreurs de connexion Gemini
|
||||
|
||||
| Erreur | Cause probable | Action backend |
|
||||
|---|---|---|
|
||||
| Gemini WS refuse connexion | Rate limit atteint ou clé invalide | close(4005, "GEMINI_UNAVAILABLE") → client affiche "Service temporairement indisponible" |
|
||||
| Gemini WS drop en cours | Instabilité réseau | Tenter 1 reconnexion automatique. Si échec → close(4006, "GEMINI_DISCONNECTED") |
|
||||
| Gemini setup frame rejeté | Modèle invalide ou config incorrecte | Log erreur + close(4005) |
|
||||
|
||||
### 7.2 Erreurs côté client
|
||||
|
||||
| Erreur | Cause | Action frontend |
|
||||
|---|---|---|
|
||||
| `getUserMedia` refusé | Permission micro refusée | Afficher message explicite + lien vers paramètres navigateur |
|
||||
| AudioContext non supporté | Navigateur ancien | Afficher "Navigateur non supporté" (Firefox < 76, Safari < 14.1) |
|
||||
| WebSocket drop | Réseau instable | State → 'error' + bouton "Réessayer" |
|
||||
|
||||
---
|
||||
|
||||
## 8. Fichiers à créer / modifier — inventaire Sprint 6
|
||||
|
||||
### Backend (expria-backend)
|
||||
|
||||
| Fichier | Action | Description |
|
||||
|---|---|---|
|
||||
| `src/lib/geminiLive.ts` | **Modifier** | Remplacer prompt agent immobilier par prompt dynamique, ajouter inputAudioTranscription + outputAudioTranscription dans config, accumuler transcript |
|
||||
| `src/routes/t2live.ts` | **Modifier** | Ajouter fetch sujet, passer consigne/contexte/role à openGeminiLiveSession, déclencher évaluation finale + sauvegarde BDD après fin de session |
|
||||
| `docs/Prompt_t2live.md` | **Créer** | Déjà rédigé — à committer |
|
||||
|
||||
### Frontend (expria-frontend)
|
||||
|
||||
| Fichier | Action | Description |
|
||||
|---|---|---|
|
||||
| `public/pcm-capture-processor.js` | **Créer** | AudioWorklet pour capture PCM 16kHz |
|
||||
| `src/features/t2-live/pages/T2LivePage.tsx` | **Créer** | Page de sélection sujet T2 |
|
||||
| `src/features/t2-live/pages/T2PreparationPage.tsx` | **Créer** | Page de préparation (consigne + bouton démarrer) |
|
||||
| `src/features/t2-live/pages/T2DialoguePage.tsx` | **Créer** | Page de dialogue live (waveform, état IA, bouton terminer) |
|
||||
| `src/features/t2-live/hooks/useT2LiveSession.ts` | **Créer** | Hook WebSocket + state machine |
|
||||
| `src/features/t2-live/hooks/useAudioCapture.ts` | **Créer** | AudioContext + AudioWorklet + envoi PCM |
|
||||
| `src/features/t2-live/hooks/useAudioPlayback.ts` | **Créer** | Réception PCM 24kHz + file de lecture |
|
||||
| `src/features/t2-live/hooks/useAudioRecording.ts` | **Créer** | Buffer chronologique candidat+IA, rééchantillonnage 16→24kHz, export WAV, bouton télécharger |
|
||||
| `src/features/t2-live/state/t2-machine.ts` | **Créer** | State machine pure (testable — FTD-09) |
|
||||
| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | **Créer** | 7+ tests state machine |
|
||||
| `src/app/router.tsx` | **Modifier** | Ajouter routes /simulation/eo/t2/* |
|
||||
|
||||
---
|
||||
|
||||
## 9. Découpage en sous-sprints recommandé
|
||||
|
||||
Le Sprint 6 est trop large pour une seule session. Découpage proposé :
|
||||
|
||||
```
|
||||
Sprint 6a — Backend T2 Live (1 session)
|
||||
- Modifier geminiLive.ts (prompt dynamique, transcription, accumulation)
|
||||
- Modifier t2live.ts (fetch sujet, évaluation finale, sauvegarde)
|
||||
- Tests backend
|
||||
- Test manuel : connexion WS via wscat ou script Node
|
||||
|
||||
Sprint 6b — Frontend capture + playback audio (1 session)
|
||||
- pcm-capture-processor.js (AudioWorklet)
|
||||
- useAudioCapture.ts
|
||||
- useAudioPlayback.ts
|
||||
- Test manuel : enregistrer + lire du PCM dans le navigateur
|
||||
|
||||
Sprint 6c — Frontend state machine + UI (1 session)
|
||||
- t2-machine.ts + tests
|
||||
- useT2LiveSession.ts
|
||||
- Pages T2 (sélection, préparation, dialogue)
|
||||
- Intégration complète
|
||||
|
||||
Sprint 6d — Clean + Golden Dataset (1 session)
|
||||
- Tests Groupe D (D2-D6)
|
||||
- Factorisation
|
||||
- CHANGELOG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Références officielles
|
||||
|
||||
| Document | URL |
|
||||
|---|---|
|
||||
| Get started WebSockets | https://ai.google.dev/gemini-api/docs/live-api/get-started-websocket |
|
||||
| Capabilities guide | https://ai.google.dev/gemini-api/docs/live-api/capabilities |
|
||||
| Session management | https://ai.google.dev/gemini-api/docs/live-api/session-management |
|
||||
| Ephemeral tokens | https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens |
|
||||
| Best practices | https://ai.google.dev/gemini-api/docs/live-api/best-practices |
|
||||
| WebSocket API reference | https://ai.google.dev/api/live |
|
||||
| Rate limits | https://ai.google.dev/gemini-api/docs/rate-limits |
|
||||
| Pricing | https://ai.google.dev/gemini-api/docs/pricing |
|
||||
| Example app (JS + proxy) | https://github.com/google-gemini/gemini-live-api-examples |
|
||||
176
docs/Prompt_exercices_long_terme.md
Normal file
176
docs/Prompt_exercices_long_terme.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Prompt Exercices Long Terme — Analyse patterns TCF Canada
|
||||
|
||||
> **Source :** `src/lib/deepseek.ts` → fonction `generatePatternExercices(patterns)`
|
||||
> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.4` · `response_format: json_object` · `AbortSignal.timeout(20_000)`
|
||||
> **Introduit :** Sprint 3.6c (2026-04-22) — commit `c48ae8d`
|
||||
|
||||
---
|
||||
|
||||
## Contexte & Variables dynamiques
|
||||
|
||||
Ce prompt est déclenché par `GET /users/patterns` (Premium uniquement) quand l'analyse des 5 dernières productions révèle un ou plusieurs **patterns confirmés** (même code d'erreur présent dans ≥ 3 productions sur 5).
|
||||
|
||||
Les exercices produits sont **distincts** des exercices individuels générés par correction (prompt dans `Prompt_maître.md`) : ils ciblent des faiblesses *structurelles récurrentes* plutôt qu'une production spécifique.
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `patterns` | Liste des patterns confirmés issus de `aggregatePatterns` | Voir structure ci-dessous |
|
||||
|
||||
### Structure d'un pattern en entrée
|
||||
|
||||
```typescript
|
||||
interface PatternInput {
|
||||
code: string // Code taxonomie (cf. TAXONOMIE_ERREURS.md)
|
||||
critere: Critere // 'adequation_tache' | 'coherence_cohesion' |
|
||||
// 'competence_lexicale' | 'competence_grammaticale'
|
||||
frequency: number // 3, 4 ou 5 (seuil d'agrégation)
|
||||
description: string | null // non-null uniquement pour code === 'autre'
|
||||
}
|
||||
```
|
||||
|
||||
Exemple d'entrée (3 patterns) :
|
||||
```
|
||||
- accord_sujet_verbe (competence_grammaticale) — apparu 4/5 fois
|
||||
- connecteurs_repetes (coherence_cohesion) — apparu 3/5 fois
|
||||
- repetition_lexicale (competence_lexicale) — apparu 3/5 fois
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt système envoyé au modèle
|
||||
|
||||
```
|
||||
Tu es un coach spécialisé dans la préparation au TCF Canada (Test de connaissance du français).
|
||||
|
||||
Un candidat commet systématiquement les mêmes erreurs sur ses 5 dernières productions écrites. Tu dois produire UN exercice ciblé par pattern d'erreur récurrent identifié.
|
||||
|
||||
CONTEXTE :
|
||||
- Ces exercices sont des exercices LONG TERME destinés à corriger des faiblesses structurelles récurrentes.
|
||||
- Ils sont DISTINCTS des exercices individuels générés après chaque correction (qui ciblent une production spécifique).
|
||||
- Tu n'as PAS accès au texte du candidat. Tes exemples doivent être génériques et représentatifs de l'erreur.
|
||||
|
||||
RÈGLES :
|
||||
1. Un exercice par pattern en entrée, dans le même ordre.
|
||||
2. Le diagnostic explique en 1-2 phrases POURQUOI cette erreur est problématique pour le TCF Canada.
|
||||
3. La consigne demande au candidat de corriger ou reformuler une phrase.
|
||||
4. L'exemple est une phrase incorrecte illustrant le pattern (inventée, pas tirée du candidat).
|
||||
5. La correction est la version correcte de l'exemple.
|
||||
6. L'astuce est un procédé mnémotechnique, une règle pratique ou un réflexe de relecture que le candidat doit appliquer APRÈS avoir rédigé son texte pour détecter et corriger cette erreur lui-même. Formulée comme un conseil direct et actionnable.
|
||||
Exemples d'astuces :
|
||||
- Subjonctif : "Après 'bien que', 'pourvu que', 'avant que' → le verbe qui suit est TOUJOURS au subjonctif. Relisez votre texte en cherchant ces expressions."
|
||||
- Accords : "Relisez chaque phrase en pointant du doigt le sujet et son verbe. S'ils sont éloignés, vérifiez l'accord."
|
||||
- Connecteurs : "Après rédaction, surlignez tous vos connecteurs. Si le même revient plus de 2 fois, remplacez-en un."
|
||||
7. Niveau de langue : NCLC 7-9 (ni trop simple, ni trop littéraire).
|
||||
8. Les exemples doivent être en contexte TCF Canada : courriels, lettres formelles, essais argumentatifs, situations professionnelles canadiennes.
|
||||
|
||||
FORMAT DE SORTIE — JSON strict, aucun texte avant ni après :
|
||||
{
|
||||
"exercises": [
|
||||
{
|
||||
"code": "<code_taxonomie>",
|
||||
"critere": "<critere>",
|
||||
"diagnostic": "<1-2 phrases>",
|
||||
"exercice": {
|
||||
"consigne": "<instruction au candidat>",
|
||||
"exemple": "<phrase incorrecte>",
|
||||
"correction": "<phrase corrigée>",
|
||||
"astuce": "<procédé mnémotechnique ou réflexe de relecture>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt utilisateur — template dynamique
|
||||
|
||||
Construit par `buildPatternExercicesUserPrompt(patterns)` dans `src/lib/deepseek.ts` :
|
||||
|
||||
```
|
||||
Voici les patterns d'erreurs récurrents détectés sur les 5 dernières productions du candidat :
|
||||
|
||||
- <code> (<critere>) — apparu <frequency>/5 fois[ — « <description> »]
|
||||
- ...
|
||||
|
||||
Produis un exercice ciblé par pattern. JSON strict uniquement.
|
||||
```
|
||||
|
||||
Le fragment `— « <description> »` n'apparaît que lorsque le code est `autre` (description textuelle obligatoire selon la taxonomie — cf. `TAXONOMIE_ERREURS.md` §Règles d'utilisation).
|
||||
|
||||
---
|
||||
|
||||
## Structure de la réponse JSON attendue
|
||||
|
||||
```json
|
||||
{
|
||||
"exercises": [
|
||||
{
|
||||
"code": "accord_sujet_verbe",
|
||||
"critere": "competence_grammaticale",
|
||||
"diagnostic": "<1-2 phrases expliquant pourquoi cette erreur coûte des points au TCF>",
|
||||
"exercice": {
|
||||
"consigne": "<instruction claire au candidat>",
|
||||
"exemple": "<phrase incorrecte générique, contexte TCF Canada>",
|
||||
"correction": "<version correcte>",
|
||||
"astuce": "<procédé mnémotechnique ou réflexe de relecture actionnable>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Champs expliqués
|
||||
|
||||
| Champ | Rôle |
|
||||
|---|---|
|
||||
| `code` | Code taxonomie propagé depuis l'entrée — permet au frontend de rattacher l'exercice à son pattern |
|
||||
| `critere` | Critère TCF parmi les 4 officiels — validé en runtime via `isValidCritere` |
|
||||
| `diagnostic` | Explication pédagogique courte : pourquoi cette erreur pénalise au TCF |
|
||||
| `exercice.consigne` | Instruction explicite au candidat (« Corrigez », « Reformulez », « Complétez ») |
|
||||
| `exercice.exemple` | Phrase **incorrecte** illustrant l'erreur — inventée par le modèle, en contexte TCF Canada (courriel formel, lettre, essai, cadre professionnel) |
|
||||
| `exercice.correction` | Version correcte de l'exemple |
|
||||
| `exercice.astuce` | **Conseil de relecture actionnable** — procédé mnémotechnique ou règle pratique que le candidat applique lors de la phase de relecture pour détecter ses propres erreurs |
|
||||
|
||||
---
|
||||
|
||||
## Post-traitement côté serveur
|
||||
|
||||
Après réception de la réponse DeepSeek, `generatePatternExercices` valide chaque item :
|
||||
|
||||
1. **Présence stricte** des champs `code`, `critere`, `diagnostic`, `exercice.consigne`, `exercice.exemple`, `exercice.correction`, `exercice.astuce` — tous doivent être des chaînes non vides.
|
||||
2. **Validation du critère** via `isValidCritere` (cf. `src/lib/taxonomieErreurs.ts`) — tout critère hors taxonomie est **filtré**.
|
||||
3. **Les items invalides sont silencieusement ignorés** — pas de throw. La liste retournée peut donc être plus courte que la liste de patterns en entrée.
|
||||
|
||||
La réponse persistée dans `pattern_analyses.exercises` (JSONB) est la liste filtrée.
|
||||
|
||||
---
|
||||
|
||||
## Dégradation gracieuse
|
||||
|
||||
Si l'appel DeepSeek échoue (timeout, API error, JSON invalide) :
|
||||
- `patternsController.list` capture l'erreur, logue `[patternsController.list] generatePatternExercices failed`.
|
||||
- L'analyse est persistée avec `exercises: []` — patterns et indice de préparation restent disponibles.
|
||||
- Le frontend affiche la liste des patterns sans section exercices (cf. `ProgressionPremium`).
|
||||
- Un refetch ultérieur (après `staleTime` ou nouvelle correction) retentera la génération.
|
||||
|
||||
---
|
||||
|
||||
## Contraintes opérationnelles
|
||||
|
||||
| Paramètre | Valeur | Justification |
|
||||
|---|---|---|
|
||||
| Modèle | `deepseek-chat` | Cohérent avec les autres prompts (correction, modèle, idées) |
|
||||
| Température | `0.4` | Légèrement plus élevée que la correction (`0.2`) pour favoriser la variété des exemples |
|
||||
| Format | `json_object` | Strict, pas de markdown parasite |
|
||||
| Timeout | 20 000 ms | Plus long que `generateIdees` (15 s) car la réponse peut contenir jusqu'à ~10 exercices (4 critères × patterns) |
|
||||
|
||||
---
|
||||
|
||||
## Historique de ce document
|
||||
|
||||
| Version | Date | Changements |
|
||||
|---|---|---|
|
||||
| 1.0 | 2026-04-23 | Création — documentation du prompt validé par Hermann lors du Sprint 3.6c (2026-04-22) |
|
||||
215
docs/Prompt_t2live.md
Normal file
215
docs/Prompt_t2live.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Prompt_t2live.md — Expria Backend
|
||||
# Spécification du prompt système T2 EO Live
|
||||
|
||||
> **Document de référence — Sprint 6**
|
||||
> À lire conjointement avec PARCOURS_UTILISATEURS.md et PLANS_TARIFAIRES.md.
|
||||
> Ce document doit être commité dans `expria-backend/docs/` avant le démarrage du Sprint 6.
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte pédagogique
|
||||
|
||||
La Tâche 2 de l'Expression Orale TCF Canada est une **interaction de service** :
|
||||
le candidat joue le rôle d'une personne dans une situation concrète du quotidien
|
||||
qui a besoin d'informations pour prendre une décision. Il pose des questions à
|
||||
un interlocuteur (joué par l'IA) qui détient ces informations.
|
||||
|
||||
**Ce que cette tâche évalue :**
|
||||
- La capacité à initier et maintenir une conversation en français
|
||||
- La formulation de questions claires et adaptées au registre
|
||||
- Le lexique lié à la vie quotidienne
|
||||
- La morphosyntaxe en situation d'interaction orale
|
||||
- La phonologie (évaluée sur l'audio)
|
||||
|
||||
**Ce que cette tâche n'est pas :**
|
||||
- Un débat d'opinions
|
||||
- Un exposé monologique
|
||||
- Un jeu de questions-réponses guidé par l'examinateur
|
||||
|
||||
---
|
||||
|
||||
## 2. Rôle de l'IA
|
||||
|
||||
L'IA joue le rôle de l'interlocuteur de la situation décrite dans le sujet
|
||||
(ex : un bailleur, un employeur, un vendeur, un agent de voyage, etc.).
|
||||
|
||||
**Règles absolues du comportement de l'IA :**
|
||||
|
||||
1. **Répondre uniquement en français** — quelle que soit la langue utilisée
|
||||
par le candidat.
|
||||
2. **Ne pas faciliter la tâche** — ne pas reformuler les questions du candidat,
|
||||
ne pas anticiper ce qu'il veut savoir, ne pas lui souffler les mots.
|
||||
3. **Répondre aux questions posées** — réponses naturelles, réalistes,
|
||||
ni trop courtes (monosyllabiques) ni trop longues (monologues).
|
||||
4. **Ne pas relancer au-delà de** : *"Avez-vous d'autres questions ?"*
|
||||
si le candidat marque une pause prolongée ou semble avoir terminé.
|
||||
5. **Ne pas évaluer le candidat** pendant la conversation — aucun commentaire
|
||||
sur sa langue, ses erreurs, ou sa performance.
|
||||
6. **Ne pas sortir du rôle** — même si le candidat pose des questions hors sujet
|
||||
ou tente de changer de registre.
|
||||
7. **Attendre que le candidat prenne la parole** — c'est le candidat qui initie
|
||||
la conversation, comme à l'examen réel. L'IA ne parle pas en premier.
|
||||
Elle attend en silence et répond dès que le candidat s'adresse à elle.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prompt système (à injecter dans `geminiLive.ts`)
|
||||
|
||||
```
|
||||
Tu joues le rôle de {role} dans la situation suivante : {contexte}
|
||||
|
||||
Règles à respecter impérativement :
|
||||
- Tu réponds uniquement en français, quelle que soit la langue de ton interlocuteur.
|
||||
- Tu joues ton rôle de façon naturelle et réaliste. Tu n'es pas un examinateur —
|
||||
tu es {role}.
|
||||
- Tu réponds aux questions qu'on te pose de façon honnête et naturelle,
|
||||
comme le ferait une vraie personne dans cette situation.
|
||||
- Tu ne facilites pas la tâche : tu ne reformules pas les questions,
|
||||
tu n'anticipes pas ce que l'interlocuteur veut savoir,
|
||||
tu ne lui suggères pas quoi demander.
|
||||
- Si ton interlocuteur marque une longue pause ou semble avoir terminé,
|
||||
tu peux dire : "Avez-vous d'autres questions ?" — c'est la seule relance autorisée.
|
||||
- Tu ne fais aucun commentaire sur la langue, les erreurs ou le niveau de français
|
||||
de ton interlocuteur.
|
||||
- Tu ne sors jamais de ton rôle.
|
||||
- Tu ne prends PAS la parole en premier. Tu attends que ton interlocuteur
|
||||
s'adresse à toi, puis tu réponds naturellement dans ton rôle.
|
||||
- Tes réponses sont concises et naturelles : ni monosyllabiques, ni des monologues.
|
||||
```
|
||||
|
||||
**Variables à substituer dynamiquement depuis le sujet :**
|
||||
- `{role}` — ex : "un bailleur qui loue un appartement"
|
||||
- `{contexte}` — la consigne + contexte du sujet issu de la table `sujets`
|
||||
|
||||
---
|
||||
|
||||
## 4. Format du sujet T2 en base
|
||||
|
||||
Les sujets T2 sont stockés dans la table `sujets` avec les champs :
|
||||
- `consigne` — la situation décrite au candidat (ce qu'il doit faire)
|
||||
- `contexte` — les informations de cadrage (lieu, situation, interlocuteur)
|
||||
- `tache` — valeur `'EO_T2'`
|
||||
- `mode` — valeur `'entrainement'`
|
||||
|
||||
**Exemple de sujet :**
|
||||
```
|
||||
consigne : "Vous avez vu une annonce pour un appartement à louer.
|
||||
Appelez le bailleur pour obtenir les informations
|
||||
nécessaires avant de prendre votre décision."
|
||||
contexte : "Vous cherchez un appartement de 2 pièces dans le
|
||||
centre-ville, votre budget est limité et vous souhaitez
|
||||
emménager le mois prochain."
|
||||
role : "un bailleur qui propose un appartement à louer"
|
||||
```
|
||||
|
||||
> **Note :** Le champ `role` existe dans la table `sujets` et a été
|
||||
> alimenté pour les 9 sujets EO T2 (session 2026-04-26, script SQL
|
||||
> `update_sujets_t2.sql`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Structure du rapport T2
|
||||
|
||||
Le rapport T2 suit **exactement la même structure** que les rapports EO T1 et T3 :
|
||||
4 critères officiels TCF Canada :
|
||||
|
||||
| Critère | Pondération |
|
||||
|---|---|
|
||||
| Cohérence et cohésion | 25 % |
|
||||
| Étendue et maîtrise du lexique | 25 % |
|
||||
| Maîtrise morphosyntaxique | 25 % |
|
||||
| Phonologie | 25 % |
|
||||
|
||||
**Conséquence :** l'évaluation finale peut réutiliser le prompt de correction EO
|
||||
existant (`POST /corrections/eo`) en passant le transcript de la session comme
|
||||
entrée, avec `tache: 'EO_T2'`.
|
||||
|
||||
> **Rappel TD-08 (backend) :** la phonologie est temporairement fixée à 0
|
||||
> pour les tâches EO live car l'évaluation nécessite l'audio brut.
|
||||
> Applicable à T2 également — à résoudre post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## 6. Flux technique complet Sprint 6
|
||||
|
||||
```
|
||||
1. Candidat choisit un sujet T2 dans la liste → clic → page préparation
|
||||
2. Page préparation : consigne affichée + bouton "Démarrer le dialogue"
|
||||
3. Frontend ouvre WS : wss://api.expria.app/t2/live?token=<jwt>&sujet=<uuid>
|
||||
4. Backend vérifie JWT + plan oral_t2_live (Premium)
|
||||
5. Backend lit le sujet depuis Supabase (id sujet passé en query param)
|
||||
6. Backend ouvre WS vers Gemini Live API avec prompt système construit
|
||||
dynamiquement depuis {role} + {contexte} du sujet
|
||||
7. Backend → Gemini : setup frame (modèle + prompt + responseModalities: AUDIO
|
||||
+ VAD : endOfSpeechSensitivity LOW, silenceDurationMs 2000)
|
||||
8. L'IA attend en silence — c'est le CANDIDAT qui prend la parole en premier
|
||||
(conforme à l'examen réel TCF Canada)
|
||||
9. Frontend → Backend → Gemini : audio candidat (PCM 16kHz base64) en continu
|
||||
+ accumulation dans le buffer d'enregistrement chronologique (rééchantillonné 24kHz)
|
||||
10. Gemini → Backend → Frontend : réponses audio (PCM 24kHz base64) en continu
|
||||
+ accumulation dans le buffer d'enregistrement
|
||||
11. Candidat clique "Terminer" → Frontend envoie signal de fin
|
||||
12. Backend ferme WS Gemini, récupère transcript complet (inputTranscription
|
||||
+ outputTranscription accumulés pendant la session)
|
||||
13. Backend POST /corrections/eo avec transcript + tache='EO_T2'
|
||||
→ rapport généré (même pipeline que T1/T3)
|
||||
14. Backend sauvegarde production en base (tache='EO_T2_LIVE')
|
||||
15. Backend envoie rapport au Frontend via WS (close code 1000 + payload rapport)
|
||||
16. Frontend → state machine 'ended' → affichage rapport
|
||||
17. Frontend propose bouton "Télécharger l'audio" (WAV mono 24kHz assemblé
|
||||
depuis le buffer chronologique)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Spécifications audio
|
||||
|
||||
| Direction | Format | Sample rate | Encoding |
|
||||
|---|---|---|---|
|
||||
| Frontend → Gemini | PCM brut | 16kHz | 16 bits, little-endian, mono |
|
||||
| Gemini → Frontend | PCM brut | 24kHz | 16 bits, little-endian, mono |
|
||||
|
||||
**MIME type à envoyer à Gemini :** `audio/pcm;rate=16000`
|
||||
|
||||
**Côté frontend :**
|
||||
- Capture via `AudioContext` + `AudioWorklet` (ou `ScriptProcessorNode` provisoire)
|
||||
- Rééchantillonnage obligatoire : le navigateur capture à 44.1kHz ou 48kHz → downsampler à 16kHz
|
||||
- Conversion Float32 → Int16 PCM avant envoi
|
||||
- Lecture de l'audio reçu : `AudioContext` à 24kHz + `AudioBufferSourceNode` par chunk
|
||||
|
||||
---
|
||||
|
||||
## 8. Gestion des erreurs WebSocket
|
||||
|
||||
| Close code | Cause | Action frontend |
|
||||
|---|---|---|
|
||||
| 1000 | Fin normale + rapport prêt | State → 'ended', afficher rapport |
|
||||
| 4001 | AUTH_REQUIRED | State → 'error', redirect /login |
|
||||
| 4003 | PLAN_INSUFFICIENT | State → 'error', PaywallModal Premium |
|
||||
| 4004 | SUJET_NOT_FOUND | State → 'error', retour liste sujets |
|
||||
| Autre | Erreur réseau / Gemini | State → 'error', bouton "Réessayer" |
|
||||
|
||||
---
|
||||
|
||||
## 9. Questions ouvertes à trancher au Sprint 6
|
||||
|
||||
| # | Question | Impact |
|
||||
|---|---|---|
|
||||
| Q1 | Le champ `role` existe-t-il dans la table `sujets` ou faut-il le dériver du `contexte` ? | Migration SQL ou prompt engineering |
|
||||
| Q2 | L'id du sujet est-il passé en query param WS (`?token=jwt&sujet=uuid`) ou via le premier message WS ? | Protocole de connexion |
|
||||
| Q3 | Le transcript est-il accumulé côté backend pendant la session ou demandé à Gemini en fin de session ? | Architecture geminiLive.ts |
|
||||
|
||||
---
|
||||
|
||||
## 10. Ce qui existe déjà (à ne pas recoder)
|
||||
|
||||
- `src/routes/t2live.ts` — 101 lignes, route WS + auth + gating ✅
|
||||
- `src/lib/geminiLive.ts` — 154 lignes, proxy bidirectionnel + setup frame ✅
|
||||
- Pipeline correction EO (`POST /corrections/eo`) — réutilisable pour évaluation finale ✅
|
||||
- Modèle `gemini-live-2.5-flash-native-audio` — accès confirmé ✅
|
||||
|
||||
**À modifier :**
|
||||
- `src/lib/geminiLive.ts` — remplacer le prompt agent immobilier par le prompt
|
||||
dynamique §3, brancher la récupération du sujet depuis Supabase,
|
||||
accumuler le transcript, déclencher l'évaluation finale.
|
||||
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
## 1. Stubs temporaires — à compléter
|
||||
|
||||
### TD-01 — src/lib/supabase.ts (backend)
|
||||
|
||||
**Priorité :** 🔴 Critique
|
||||
**Statut :** Ouvert
|
||||
**Description :** Client Supabase créé comme stub. Fonctionne en développement avec les variables d'environnement mais n'a pas de gestion d'erreur robuste si `SUPABASE_URL` ou `SUPABASE_SERVICE_ROLE_KEY` sont absentes.
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
---
|
||||
|
||||
### TD-02 — src/lib/planController.ts (backend)
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session Stripe
|
||||
**Description :** Stub créé pour permettre les tests de `updateUserPlan`. La vraie implémentation (mise à jour Supabase + gestion Stripe) n'est pas encore codée.
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
---
|
||||
|
||||
### TD-03 — src/lib/stripe.ts (backend)
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session Stripe
|
||||
**Description :** Stub créé pour permettre les tests de `verifyStripeWebhook` et `calculateProrata`. La vraie implémentation Stripe n'est pas encore codée.
|
||||
|
|
@ -42,6 +45,7 @@
|
|||
## 2. Décisions pragmatiques — à revisiter
|
||||
|
||||
### TD-04 — Déploiement manuel (frontend + backend)
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — accepté jusqu'aux premiers revenus
|
||||
**Description :** Cloudflare Pages et Render ne supportent pas l'auto-deploy depuis Codeberg. Le déploiement est manuel (CLI + dashboard).
|
||||
|
|
@ -51,10 +55,12 @@
|
|||
---
|
||||
|
||||
### TD-05 — Comptes de test avec emails @gmail.com
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert
|
||||
**Description :** Les comptes de test utilisent `@gmail.com` au lieu de `@expria.local` prévu dans TEST_ENVIRONMENT.md. Raison : Supabase bloque la création d'utilisateurs avec des domaines non standards via l'API admin, et le dashboard est inaccessible depuis la Russie.
|
||||
**Emails actuels :**
|
||||
|
||||
- `test.free@gmail.com`
|
||||
- `test.standard@gmail.com`
|
||||
- `test.premium@gmail.com`
|
||||
|
|
@ -64,10 +70,12 @@
|
|||
---
|
||||
|
||||
### TD-06 — Pas de migration SQL versionnée pour les tables initiales
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert
|
||||
**Description :** Les tables `profiles` et `productions` ont été créées directement via SQL Editor, sans fichier de migration dans `supabase/migrations/`. Viole la Règle F de DEVELOPMENT_PRINCIPLES.md.
|
||||
**À faire :** Créer les fichiers de migration correspondants :
|
||||
|
||||
- `supabase/migrations/001_create_profiles.sql`
|
||||
- `supabase/migrations/002_create_productions.sql`
|
||||
- `supabase/migrations/003_create_test_accounts.sql`
|
||||
|
|
@ -76,6 +84,7 @@
|
|||
---
|
||||
|
||||
### TD-07 — Ancien projet Supabase partagé
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert — accepté temporairement
|
||||
**Description :** Le nouveau projet Expria V2 utilise la même base Supabase que l'ancien projet (en maintenance). Les anciennes tables ont été remplacées mais d'autres tables de l'ancien projet subsistent (`sujets`, `eo_t2_results`, `payment_transactions`, etc.).
|
||||
|
|
@ -86,10 +95,12 @@
|
|||
---
|
||||
|
||||
### TD-13 — Webhook Stripe non idempotent
|
||||
|
||||
**Priorité :** 🔴 Critique
|
||||
**Statut :** Ouvert — à faire avant mise en production
|
||||
**Description :** Stripe peut livrer un même event webhook deux fois (retries réseau, rejeu manuel depuis le dashboard). La route `POST /stripe/webhook` traite chaque réception sans dédoublonnage. En pratique, les opérations `updateUserPlan` et `updateUserStripeInfo` sont idempotentes par nature (même résultat en cas de double appel), mais si de la logique non idempotente est ajoutée plus tard (ex: compteur, envoi d'email, crédit utilisateur), un double traitement causerait un bug.
|
||||
**À faire :**
|
||||
|
||||
- Créer une table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ)`
|
||||
- Avant traitement, vérifier si `event.id` est déjà en base → si oui, retourner 200 sans rien faire
|
||||
- Après traitement, insérer l'`event.id` dans la table
|
||||
|
|
@ -99,11 +110,13 @@
|
|||
---
|
||||
|
||||
### 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`.
|
||||
|
|
@ -113,6 +126,7 @@
|
|||
---
|
||||
|
||||
### TD-14 — Erreurs TypeScript TS2835 pré-existantes
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session correction build TypeScript
|
||||
**Description :** Erreurs TS2835 sur plusieurs fichiers de routes.
|
||||
|
|
@ -125,16 +139,17 @@ Gate de qualité actuel : npm run test.
|
|||
## 3. Fonctionnalités reportées
|
||||
|
||||
### TD-08 — Phonologie T2 EO à 0
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert
|
||||
**Description :** L'évaluation de la phonologie pour la T2 EO live est temporairement à 0 (non évaluée). L'évaluation se fait sur 4 critères au lieu de 5.
|
||||
**Raison :** La T2 live utilise un transcript texte — évaluer la phonologie nécessite l'audio brut, ce qui dépasse la limite de taille des requêtes.
|
||||
**À faire :** Implémenter l'évaluation phonologique via un endpoint séparé qui traite l'audio en chunks.
|
||||
**Session concernée :** T2 live (WebSocket)
|
||||
**Statut :** Partiellement résolu — Sprint 4.8
|
||||
**Description :** L'évaluation de la phonologie est désormais opérationnelle pour **EO T1 et T3** : `POST /corrections/eo` reçoit l'audio brut (Mode B), Gemini 2.5 Flash évalue la phonologie en parallèle de la transcription via `evaluatePhonology` (cf. `src/lib/geminiPhonology.ts`), et le score `/4` est injecté comme 5e critère du rapport. Le format passe officiellement à 5 critères × /4 (total /20 inchangé).
|
||||
**Reste à faire :** **EO T2 Live (Sprint 6)** continue de retourner phonologie 0/4 — pas d'audio brut côté backend dans le pipeline WebSocket actuel (`t2live.ts` proxifie l'audio entre client et Gemini Live sans le bufferiser pour évaluation différée). À résoudre lors du Sprint 6 en accumulant l'audio côté backend ou en demandant à Gemini Live de produire une note phonologique en fin de session.
|
||||
**Session concernée :** T2 Live (WebSocket) — Sprint 6.
|
||||
|
||||
---
|
||||
|
||||
### TD-09 — ScriptProcessorNode déprécié (T2 live)
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Reporté à après le lancement
|
||||
**Description :** Le traitement audio côté client utilise `ScriptProcessorNode` qui est déprécié. Doit être remplacé par `AudioWorklet`.
|
||||
|
|
@ -144,6 +159,7 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-10 — Analyse des patterns (Premium) non implémentée
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — Sprint 3.6c
|
||||
**Description :** La feature d'analyse des patterns sur les 5 dernières productions (Premium) a été livrée Sprint 3.6c (table `pattern_analyses`, `generatePatternExercices`).
|
||||
|
|
@ -151,6 +167,7 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-11 — Indice de préparation non implémenté
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Résolu — Sprint 3.6c
|
||||
**Description :** Le calcul de l'indice de préparation (0-100) a été livré Sprint 3.6c en même temps que l'analyse des patterns (colonne `preparation_index` + `preparation_message`).
|
||||
|
|
@ -160,6 +177,7 @@ Gate de qualité actuel : npm run test.
|
|||
## 4. Tests à automatiser
|
||||
|
||||
### TD-12 — Tests manuels du Golden Dataset non automatisés
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Accepté — par conception
|
||||
**Description :** Les 41 tests du Golden Dataset sont manuels. Certains pourraient être automatisés (tests d'intégration HTTP avec Supertest).
|
||||
|
|
@ -168,6 +186,7 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-16 — Bucket Supabase Storage `audio-productions` créé manuellement
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — Sprint 4b
|
||||
**Description :** Décision Hermann (2026-04-25) : abandon du stockage audio backend. La transcription live passe par Deepgram en connexion directe navigateur ↔ Deepgram via token éphémère. L'audio brut est téléchargé en local par l'utilisateur. Plus aucun bucket Storage requis côté serveur.
|
||||
|
|
@ -175,6 +194,7 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-17 — Limite audioBase64 in-memory à 14 Mo (≈ 10 Mo binaire)
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Résolu — Sprint 4b
|
||||
**Description :** Plus de payload audio reçu côté backend (POST /corrections/eo accepte uniquement `transcript`). La limite n'a plus lieu d'être.
|
||||
|
|
@ -182,6 +202,7 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-18 — RLS Storage `audio-productions` non testée en intégration
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — Sprint 4b
|
||||
**Description :** Plus de bucket Storage backend à protéger. Les policies RLS de la migration 006 sont supprimées (DROP IF EXISTS) au profit d'un commentaire historique.
|
||||
|
|
@ -189,10 +210,12 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-19 — Token Deepgram non rotatif côté frontend
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert — introduit au Sprint 4b
|
||||
**Description :** `POST /transcriptions/token` retourne un token Deepgram éphémère valide 600 s (10 min). Une session EO T1 (2 min) tient largement, mais une session T3 (4:30) ou un enchaînement de 2 tâches dépasse la fenêtre si l'utilisateur prend des pauses. Si le token expire en cours de session, la connexion Deepgram drop sans renégociation automatique.
|
||||
**À faire (côté frontend Sprint 4c) :**
|
||||
|
||||
- Demander un nouveau token via `/transcriptions/token` à T-60 s avant expiration.
|
||||
- Reconnecter Deepgram en réutilisant la même session WebSocket si supporté.
|
||||
**Condition de résolution :** stratégie de rotation de token implémentée et testée côté frontend.
|
||||
|
|
@ -200,20 +223,24 @@ Gate de qualité actuel : npm run test.
|
|||
---
|
||||
|
||||
### TD-20 — `transcribeAudio` (Gemini) sans consommateur
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — introduit au Sprint 4b
|
||||
**Description :** La fonction `transcribeAudio` dans `src/lib/gemini.ts` n'est plus appelée par le flux EO (Deepgram a remplacé Gemini batch). Conservée volontairement comme point d'extension futur pour TD-08 (évaluation phonologique séparée) ou un fallback si Deepgram est indisponible.
|
||||
**À faire :**
|
||||
|
||||
- Si TD-08 reste fermé 30 jours après la mise en prod du Sprint 4b sans plan d'usage, supprimer `transcribeAudio` et `gemini.ts` complet.
|
||||
**Condition de résolution :** décision sur TD-08 (résolution ou abandon).
|
||||
|
||||
---
|
||||
|
||||
### TD-21 — Pas de rate limiting sur `/transcriptions/token`
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — introduit au Sprint 4b
|
||||
**Description :** Un utilisateur authentifié peut générer un nombre illimité de tokens Deepgram. Chaque token consomme un crédit côté Deepgram (selon usage de la connexion live qui suit). Un user malveillant pourrait scripter des appels en boucle pour épuiser le quota Deepgram.
|
||||
**À faire :**
|
||||
|
||||
- Ajouter un rate limit (par user, ex. 30 tokens/heure) via le middleware `rateLimit.ts` existant.
|
||||
**Condition de résolution :** middleware rate-limit branché sur la route et testé.
|
||||
|
||||
|
|
@ -222,7 +249,7 @@ Gate de qualité actuel : npm run test.
|
|||
## 5. Historique des résolutions
|
||||
|
||||
| ID | Description | Résolu le | Comment |
|
||||
|---|---|---|---|
|
||||
| ----- | ------------------------------------------- | ---------- | --------------------------- |
|
||||
| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render |
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
# TECH_DEBT.md — Expria / Coach TCF Canada
|
||||
|
||||
> **Document de référence — Version 1.0**
|
||||
> Ce document recense les décisions techniques prises par pragmatisme
|
||||
> qui devront être revisitées, les stubs temporaires, et les fonctionnalités
|
||||
> reportées. À mettre à jour après chaque session de développement.
|
||||
>
|
||||
> Format : chaque entrée a un identifiant (TD-XX), une priorité, et un statut.
|
||||
> Priorités : 🔴 Critique (bloque la production) / 🟡 Important / 🟢 Mineur
|
||||
|
||||
---
|
||||
|
||||
## 1. Stubs temporaires — à compléter
|
||||
|
||||
### TD-01 — src/lib/supabase.ts (backend)
|
||||
**Priorité :** 🔴 Critique
|
||||
**Statut :** Ouvert
|
||||
**Description :** Client Supabase créé comme stub. Fonctionne en développement avec les variables d'environnement mais n'a pas de gestion d'erreur robuste si `SUPABASE_URL` ou `SUPABASE_SERVICE_ROLE_KEY` sont absentes.
|
||||
**À faire :** Ajouter une validation au démarrage — si les variables manquent, le serveur refuse de démarrer avec un message clair.
|
||||
**Session concernée :** Initialisation backend
|
||||
|
||||
---
|
||||
|
||||
### TD-02 — src/lib/planController.ts (backend)
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session Stripe
|
||||
**Description :** Stub créé pour permettre les tests de `updateUserPlan`. La vraie implémentation (mise à jour Supabase + gestion Stripe) n'est pas encore codée.
|
||||
**À faire :** Implémenter lors de la session Stripe (POST /stripe/webhook).
|
||||
**Session concernée :** Tests automatisés
|
||||
|
||||
---
|
||||
|
||||
### TD-03 — src/lib/stripe.ts (backend)
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session Stripe
|
||||
**Description :** Stub créé pour permettre les tests de `verifyStripeWebhook` et `calculateProrata`. La vraie implémentation Stripe n'est pas encore codée.
|
||||
**À faire :** Implémenter lors de la session Stripe.
|
||||
**Session concernée :** Tests automatisés
|
||||
|
||||
---
|
||||
|
||||
## 2. Décisions pragmatiques — à revisiter
|
||||
|
||||
### TD-04 — Déploiement manuel (frontend + backend)
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert — accepté jusqu'aux premiers revenus
|
||||
**Description :** Cloudflare Pages et Render ne supportent pas l'auto-deploy depuis Codeberg. Le déploiement est manuel (CLI + dashboard).
|
||||
**À faire :** Migrer vers VPS Hetzner + Coolify pour restaurer l'auto-deploy. Voir ARCHITECTURE.md §9 Phase 2.
|
||||
**Condition de résolution :** Quand Expria génère ses premiers revenus réguliers.
|
||||
|
||||
---
|
||||
|
||||
### TD-05 — Comptes de test avec emails @gmail.com
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Ouvert
|
||||
**Description :** Les comptes de test utilisent `@gmail.com` au lieu de `@expria.local` prévu dans TEST_ENVIRONMENT.md. Raison : Supabase bloque la création d'utilisateurs avec des domaines non standards via l'API admin, et le dashboard est inaccessible depuis la Russie.
|
||||
**Emails actuels :**
|
||||
- `test.free@gmail.com`
|
||||
- `test.standard@gmail.com`
|
||||
- `test.premium@gmail.com`
|
||||
- `test.quota@gmail.com`
|
||||
**À faire :** Mettre à jour TEST_ENVIRONMENT.md pour refléter les vrais emails. Vérifier que la validation `@expria.local` dans le middleware n'est pas implémentée (elle ne l'est pas).
|
||||
|
||||
---
|
||||
|
||||
### TD-06 — Pas de migration SQL versionnée pour les tables initiales
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert
|
||||
**Description :** Les tables `profiles` et `productions` ont été créées directement via SQL Editor, sans fichier de migration dans `supabase/migrations/`. Viole la Règle F de DEVELOPMENT_PRINCIPLES.md.
|
||||
**À faire :** Créer les fichiers de migration correspondants :
|
||||
- `supabase/migrations/001_create_profiles.sql`
|
||||
- `supabase/migrations/002_create_productions.sql`
|
||||
- `supabase/migrations/003_create_test_accounts.sql`
|
||||
**Impact :** Si la base doit être recréée (nouveau projet Supabase), les migrations permettent de tout reconstruire en une commande.
|
||||
|
||||
---
|
||||
|
||||
### TD-07 — Ancien projet Supabase partagé
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert — accepté temporairement
|
||||
**Description :** Le nouveau projet Expria V2 utilise la même base Supabase que l'ancien projet (en maintenance). Les anciennes tables ont été remplacées mais d'autres tables de l'ancien projet subsistent (`sujets`, `eo_t2_results`, `payment_transactions`, etc.).
|
||||
**À faire :** Nettoyer les tables inutilisées quand V2 est stable en production.
|
||||
**Tables à évaluer :** `anon_rate_limits`, `contact_submissions`, `eo_t2_results`, `events`, `payment_transactions`, `sujets`, `waitlist`
|
||||
**Condition de résolution :** Après 30 jours de production stable de V2.
|
||||
|
||||
---
|
||||
|
||||
### TD-13 — Webhook Stripe non idempotent
|
||||
**Priorité :** 🔴 Critique
|
||||
**Statut :** Ouvert — à faire avant mise en production
|
||||
**Description :** Stripe peut livrer un même event webhook deux fois (retries réseau, rejeu manuel depuis le dashboard). La route `POST /stripe/webhook` traite chaque réception sans dédoublonnage. En pratique, les opérations `updateUserPlan` et `updateUserStripeInfo` sont idempotentes par nature (même résultat en cas de double appel), mais si de la logique non idempotente est ajoutée plus tard (ex: compteur, envoi d'email, crédit utilisateur), un double traitement causerait un bug.
|
||||
**À faire :**
|
||||
- Créer une table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ)`
|
||||
- Avant traitement, vérifier si `event.id` est déjà en base → si oui, retourner 200 sans rien faire
|
||||
- Après traitement, insérer l'`event.id` dans la table
|
||||
**Session concernée :** Stripe (POST /stripe/webhook)
|
||||
**Condition de résolution :** Avant la mise en production publique.
|
||||
|
||||
---
|
||||
|
||||
### TD-15 — Jobs asynchrones modèle/exercices : status peut rester "pending" indéfiniment
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert — introduit au Sprint 3.6a
|
||||
**Description :** Le flux POST /corrections/ee lance deux jobs DeepSeek en fire-and-forget (`runModeleJob`, `runExercicesJob` dans `correctionController.ts`). Si le process Node redémarre (deploy Render, crash, OOM) pendant l'exécution d'un de ces jobs, la colonne `exercices_status` ou `modele_status` reste figée à `'pending'` — l'utilisateur voit un loader infini côté frontend.
|
||||
**Impact actuel :** faible en conditions normales (DeepSeek répond en ~5-15 s, Render redémarre rarement). Perceptible uniquement si un deploy a lieu pendant une correction active.
|
||||
**À faire :**
|
||||
- Option 1 (simple) : job de reprise au boot → scanner `productions WHERE (exercices_status='pending' OR modele_status='pending') AND created_at < NOW() - INTERVAL '2 minutes'` → relancer.
|
||||
- Option 2 (robuste) : file d'attente persistée (pg-boss, BullMQ) au lieu de fire-and-forget.
|
||||
- Option 3 (minimal) : timeout côté frontend → si `pending` depuis > 2 min, afficher "La génération a échoué, réessayer ?" + endpoint `POST /simulations/:id/retry-jobs`.
|
||||
**Session concernée :** à planifier après livraison Sprint 3.6a/3.6b en prod stable.
|
||||
**Condition de résolution :** après 7 jours d'observation en prod avec monitoring des colonnes `*_status='pending'` âgées.
|
||||
|
||||
---
|
||||
|
||||
### TD-14 — Erreurs TypeScript TS2835 pré-existantes
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Résolu — session correction build TypeScript
|
||||
**Description :** Erreurs TS2835 sur plusieurs fichiers de routes.
|
||||
Non bloquant (tests verts) mais à corriger.
|
||||
Gate de qualité actuel : npm run test.
|
||||
**À faire :** Ajouter les extensions `.js` aux imports relatifs ou ajuster `moduleResolution` dans `tsconfig.json` pour permettre `npm run build` de passer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fonctionnalités reportées
|
||||
|
||||
### TD-08 — Phonologie T2 EO à 0
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Ouvert
|
||||
**Description :** L'évaluation de la phonologie pour la T2 EO live est temporairement à 0 (non évaluée). L'évaluation se fait sur 4 critères au lieu de 5.
|
||||
**Raison :** La T2 live utilise un transcript texte — évaluer la phonologie nécessite l'audio brut, ce qui dépasse la limite de taille des requêtes.
|
||||
**À faire :** Implémenter l'évaluation phonologique via un endpoint séparé qui traite l'audio en chunks.
|
||||
**Session concernée :** T2 live (WebSocket)
|
||||
|
||||
---
|
||||
|
||||
### TD-09 — ScriptProcessorNode déprécié (T2 live)
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Reporté à après le lancement
|
||||
**Description :** Le traitement audio côté client utilise `ScriptProcessorNode` qui est déprécié. Doit être remplacé par `AudioWorklet`.
|
||||
**Impact :** Fonctionne mais génère des warnings dans la console. Peut poser problème sur certains navigateurs futurs.
|
||||
**À faire :** Migrer vers AudioWorklet après le lancement MVP.
|
||||
|
||||
---
|
||||
|
||||
### TD-10 — Analyse des patterns (Premium) non implémentée
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Planifié
|
||||
**Description :** La feature d'analyse des patterns sur les 5 dernières productions (Premium) n'est pas encore implémentée côté backend.
|
||||
**À faire :** Implémenter après les corrections EE/EO et Stripe.
|
||||
|
||||
---
|
||||
|
||||
### TD-11 — Indice de préparation non implémenté
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Planifié
|
||||
**Description :** Le calcul de l'indice de préparation (0-100) basé sur progression + régularité n'est pas encore implémenté.
|
||||
**À faire :** Implémenter en même temps que l'analyse des patterns (TD-10).
|
||||
|
||||
---
|
||||
|
||||
## 4. Tests à automatiser
|
||||
|
||||
### TD-12 — Tests manuels du Golden Dataset non automatisés
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Accepté — par conception
|
||||
**Description :** Les 41 tests du Golden Dataset sont manuels. Certains pourraient être automatisés (tests d'intégration HTTP avec Supertest).
|
||||
**À faire :** Ajouter des tests d'intégration pour les routes critiques après le lancement MVP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Historique des résolutions
|
||||
|
||||
| ID | Description | Résolu le | Comment |
|
||||
|---|---|---|---|
|
||||
| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe |
|
||||
| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render |
|
||||
|
|
@ -11,6 +11,9 @@ const PROFILE: AuthProfile = {
|
|||
simulations_used: 3,
|
||||
};
|
||||
|
||||
// Sprint 4.8 — DeepSeek renvoie 4 critères textuels /4 (somme ≤ 16). Le
|
||||
// controller ajoute la 5e dimension Phonologie (Gemini) puis recalcule le
|
||||
// score final /20.
|
||||
const VALID_RAPPORT_EO: CorrectionRapport = {
|
||||
score: 14,
|
||||
nclc: 9,
|
||||
|
|
@ -19,7 +22,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
|
|||
diagnostic: "d",
|
||||
criteres: [
|
||||
{
|
||||
nom: "Réalisation de la tâche",
|
||||
nom: "Adéquation à la tâche",
|
||||
score: 4,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
|
|
@ -27,7 +30,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
|
|||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Cohérence et fluidité",
|
||||
nom: "Cohérence et cohésion",
|
||||
score: 3,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
|
|
@ -35,7 +38,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
|
|||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Étendue du lexique",
|
||||
nom: "Étendue et maîtrise du lexique",
|
||||
score: 3,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
|
|
@ -43,7 +46,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
|
|||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Maîtrise grammaticale orale",
|
||||
nom: "Maîtrise morphosyntaxique",
|
||||
score: 4,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
|
|
@ -60,7 +63,6 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
|
|||
},
|
||||
],
|
||||
transcription_affichee: "Bonjour. Je m'appelle Pierre.",
|
||||
note_phonologie: "Analyse phonologique non disponible pour cette session.",
|
||||
};
|
||||
|
||||
interface ProductionRow {
|
||||
|
|
@ -144,7 +146,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn().mockResolvedValue(VALID_RAPPORT_EO),
|
||||
generateProductionModele: vi.fn().mockResolvedValue({
|
||||
|
|
@ -177,8 +196,13 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
expect("data" in result).toBe(true);
|
||||
if ("data" in result) {
|
||||
expect(result.data.simulation_id).toBe("sim-1");
|
||||
// Mode transcript : phonologie = stub 0/4 → total = 14 (textuel) + 0 = 14.
|
||||
expect(result.data.score).toBe(14);
|
||||
expect(result.data.note_phonologie).toContain("phonologique");
|
||||
// Sprint 4.8 : 5 critères (4 textuels + Phonologie).
|
||||
expect(result.data.criteres).toHaveLength(5);
|
||||
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
|
||||
expect(result.data.criteres[4]!.score).toBe(0);
|
||||
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
|
||||
}
|
||||
|
||||
const persisted = updates.find(
|
||||
|
|
@ -201,7 +225,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
it("simulation introuvable → SIMULATION_NOT_FOUND 404", async () => {
|
||||
const { mock } = createSupabaseMock(null);
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn(),
|
||||
generateProductionModele: vi.fn(),
|
||||
|
|
@ -234,7 +275,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn(),
|
||||
generateProductionModele: vi.fn(),
|
||||
|
|
@ -271,7 +329,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
const correctEOSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ...VALID_RAPPORT_EO, nclc_cible: 10 });
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: correctEOSpy,
|
||||
generateProductionModele: vi.fn().mockResolvedValue({
|
||||
|
|
@ -320,7 +395,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
|
||||
const correctEOSpy = vi.fn().mockResolvedValue(VALID_RAPPORT_EO);
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: correctEOSpy,
|
||||
generateProductionModele: vi.fn().mockResolvedValue({
|
||||
|
|
@ -385,7 +477,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn(),
|
||||
generateProductionModele: vi.fn(),
|
||||
|
|
@ -423,7 +532,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn(),
|
||||
generateProductionModele: vi.fn(),
|
||||
|
|
@ -461,7 +587,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
|
|||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../../lib/deepseek", () => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: vi.fn(),
|
||||
generateProductionModele: vi.fn(),
|
||||
|
|
|
|||
369
src/controllers/__tests__/correctionEoPhonology.test.ts
Normal file
369
src/controllers/__tests__/correctionEoPhonology.test.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
/**
|
||||
* Tests Sprint 4.8 — fusion phonologie Gemini dans correctionController.correctEO.
|
||||
*
|
||||
* Couvre :
|
||||
* - Mode B (audioBase64) : phonologie /4 injectée comme 5e critère, score
|
||||
* final /20 = somme des 5 critères.
|
||||
* - Mode A (transcript) : phonologie = stub 0/4 avec commentaire.
|
||||
* - evaluatePhonology rejette → fallback stub, la correction n'échoue pas.
|
||||
* - Persistance Supabase : criteres à 5 entrées, score recalculé.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { CorrectionRapport } from "../../lib/deepseek";
|
||||
import type { AuthProfile } from "../../middleware/auth";
|
||||
|
||||
const PROFILE: AuthProfile = {
|
||||
id: "user-1",
|
||||
email: "u@test.com",
|
||||
plan: "standard",
|
||||
simulations_used: 3,
|
||||
};
|
||||
|
||||
const RAPPORT_TEXTUEL: CorrectionRapport = {
|
||||
score: 12, // somme textuelle 4+3+2+3 = 12
|
||||
nclc: 8,
|
||||
nclc_cible: 9,
|
||||
revelation: { croyance: "c", realite: "r", consequence: "co" },
|
||||
diagnostic: "d",
|
||||
criteres: [
|
||||
{
|
||||
nom: "Adéquation à la tâche",
|
||||
score: 4,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Cohérence et cohésion",
|
||||
score: 3,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Étendue et maîtrise du lexique",
|
||||
score: 2,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
{
|
||||
nom: "Maîtrise morphosyntaxique",
|
||||
score: 3,
|
||||
commentaire: "",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
],
|
||||
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "ok", action_prioritaire: "a" },
|
||||
erreurs_codes: [],
|
||||
transcription_affichee: "Bonjour.",
|
||||
};
|
||||
|
||||
interface ProductionRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tache: string;
|
||||
sujet_id: string | null;
|
||||
}
|
||||
|
||||
function createSupabaseMock(production: ProductionRow) {
|
||||
const updates: { table: string; data: Record<string, unknown> }[] = [];
|
||||
const fromMock = vi.fn((table: string) => {
|
||||
if (table === "productions") {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({ data: production, error: null }),
|
||||
}),
|
||||
}),
|
||||
update: (data: Record<string, unknown>) => ({
|
||||
eq: async () => {
|
||||
updates.push({ table, data });
|
||||
return { error: null };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "sujets") {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: async () => ({
|
||||
data: { consigne: "Présentez-vous." },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "profiles") {
|
||||
return {
|
||||
update: (data: Record<string, unknown>) => ({
|
||||
eq: async () => {
|
||||
updates.push({ table, data });
|
||||
return { error: null };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
return { mock: { from: fromMock }, updates };
|
||||
}
|
||||
|
||||
const STANDARD_DEEPSEEK_MOCK = (correctEOImpl: ReturnType<typeof vi.fn>) => ({
|
||||
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
|
||||
correctEE: vi.fn(),
|
||||
correctEO: correctEOImpl,
|
||||
generateProductionModele: 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: 200,
|
||||
tcf_word_max: 300,
|
||||
tcf_truncated: false,
|
||||
}),
|
||||
generateExercices: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const STANDARD_GEMINI_MOCK = {
|
||||
transcribeAudio: vi.fn().mockResolvedValue("Bonjour, je m'appelle Marie."),
|
||||
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
describe("correctionController.correctEO — phonologie (Sprint 4.8)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("Mode B (audio) : phonologie injectée comme 5e critère, score = textuel + phono", async () => {
|
||||
const { mock, updates } = createSupabaseMock({
|
||||
id: "sim-phono-1",
|
||||
user_id: "user-1",
|
||||
tache: "EO_T1",
|
||||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/deepseek", () =>
|
||||
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
|
||||
);
|
||||
vi.doMock("../../lib/gemini", () => ({
|
||||
transcribeAudio: vi
|
||||
.fn()
|
||||
.mockResolvedValue("Bonjour, je m'appelle Marie."),
|
||||
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 3,
|
||||
commentaire: "Prononciation correcte avec quelques liaisons manquées.",
|
||||
exemple: "les amis",
|
||||
suggestion: "Réaliser la liaison.",
|
||||
astuce: "S'entraîner sur les liaisons.",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "stub",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
|
||||
const { correctEO } = await import("../correctionController");
|
||||
const result = await correctEO(
|
||||
{
|
||||
simulationId: "sim-phono-1",
|
||||
tache: "EO_T1",
|
||||
nclcCible: 9,
|
||||
audioBase64: "AAAA",
|
||||
mimeType: "audio/webm",
|
||||
},
|
||||
PROFILE,
|
||||
);
|
||||
|
||||
expect("data" in result).toBe(true);
|
||||
if (!("data" in result)) return;
|
||||
// 4 textuels (4+3+2+3 = 12) + phonologie 3 = 15
|
||||
expect(result.data.score).toBe(15);
|
||||
expect(result.data.criteres).toHaveLength(5);
|
||||
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
|
||||
expect(result.data.criteres[4]!.score).toBe(3);
|
||||
expect(result.data.criteres[4]!.commentaire).toMatch(/Prononciation/);
|
||||
|
||||
const persisted = updates.find(
|
||||
(u) => u.table === "productions" && u.data.score !== undefined,
|
||||
);
|
||||
expect(persisted!.data.score).toBe(15);
|
||||
});
|
||||
|
||||
it("Mode A (transcript) : phonologie = stub 0/4 avec commentaire indisponibilité", async () => {
|
||||
const { mock } = createSupabaseMock({
|
||||
id: "sim-phono-2",
|
||||
user_id: "user-1",
|
||||
tache: "EO_T1",
|
||||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/deepseek", () =>
|
||||
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
|
||||
);
|
||||
const evaluatePhonology = vi.fn();
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology,
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
|
||||
const { correctEO } = await import("../correctionController");
|
||||
const result = await correctEO(
|
||||
{
|
||||
simulationId: "sim-phono-2",
|
||||
tache: "EO_T1",
|
||||
nclcCible: 9,
|
||||
transcript: "Bonjour je m appelle Pierre",
|
||||
},
|
||||
PROFILE,
|
||||
);
|
||||
|
||||
expect("data" in result).toBe(true);
|
||||
if (!("data" in result)) return;
|
||||
// Mode A → phonologie stub 0 → score = 12 + 0 = 12.
|
||||
expect(result.data.score).toBe(12);
|
||||
expect(result.data.criteres).toHaveLength(5);
|
||||
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
|
||||
expect(result.data.criteres[4]!.score).toBe(0);
|
||||
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
|
||||
// evaluatePhonology n'est PAS appelée en Mode A.
|
||||
expect(evaluatePhonology).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Mode B + evaluatePhonology rejette → fallback stub, correction réussit", async () => {
|
||||
const { mock, updates } = createSupabaseMock({
|
||||
id: "sim-phono-3",
|
||||
user_id: "user-1",
|
||||
tache: "EO_T1",
|
||||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/deepseek", () =>
|
||||
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
|
||||
);
|
||||
vi.doMock("../../lib/gemini", () => ({
|
||||
transcribeAudio: vi
|
||||
.fn()
|
||||
.mockResolvedValue("Bonjour, je m'appelle Marie."),
|
||||
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Gemini phonology timeout")),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
|
||||
const { correctEO } = await import("../correctionController");
|
||||
const result = await correctEO(
|
||||
{
|
||||
simulationId: "sim-phono-3",
|
||||
tache: "EO_T1",
|
||||
nclcCible: 9,
|
||||
audioBase64: "AAAA",
|
||||
mimeType: "audio/webm",
|
||||
},
|
||||
PROFILE,
|
||||
);
|
||||
|
||||
expect("data" in result).toBe(true);
|
||||
if (!("data" in result)) return;
|
||||
// Phonologie tombe sur le stub → score = 12 + 0 = 12, correction OK.
|
||||
expect(result.data.score).toBe(12);
|
||||
expect(result.data.criteres).toHaveLength(5);
|
||||
expect(result.data.criteres[4]!.score).toBe(0);
|
||||
|
||||
const persisted = updates.find(
|
||||
(u) => u.table === "productions" && u.data.score !== undefined,
|
||||
);
|
||||
expect(persisted!.data.score).toBe(12);
|
||||
});
|
||||
|
||||
it("score phonologie 4 + textuel 16 → total final 20 (cap respecté)", async () => {
|
||||
const RAPPORT_PARFAIT: CorrectionRapport = {
|
||||
...RAPPORT_TEXTUEL,
|
||||
score: 16,
|
||||
criteres: RAPPORT_TEXTUEL.criteres.map((c) => ({ ...c, score: 4 })),
|
||||
};
|
||||
const { mock } = createSupabaseMock({
|
||||
id: "sim-phono-4",
|
||||
user_id: "user-1",
|
||||
tache: "EO_T1",
|
||||
sujet_id: null,
|
||||
});
|
||||
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
|
||||
vi.doMock("../../lib/deepseek", () =>
|
||||
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_PARFAIT)),
|
||||
);
|
||||
vi.doMock("../../lib/gemini", () => ({
|
||||
transcribeAudio: vi
|
||||
.fn()
|
||||
.mockResolvedValue("Bonjour, je m'appelle Marie."),
|
||||
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
vi.doMock("../../lib/geminiPhonology", () => ({
|
||||
evaluatePhonology: vi.fn().mockResolvedValue({
|
||||
score: 4,
|
||||
commentaire: "Prononciation native.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
}),
|
||||
PHONOLOGY_STUB: {
|
||||
score: 0,
|
||||
commentaire: "stub",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
},
|
||||
}));
|
||||
|
||||
const { correctEO } = await import("../correctionController");
|
||||
const result = await correctEO(
|
||||
{
|
||||
simulationId: "sim-phono-4",
|
||||
tache: "EO_T1",
|
||||
nclcCible: 9,
|
||||
audioBase64: "AAAA",
|
||||
mimeType: "audio/webm",
|
||||
},
|
||||
PROFILE,
|
||||
);
|
||||
|
||||
expect("data" in result).toBe(true);
|
||||
if (!("data" in result)) return;
|
||||
expect(result.data.score).toBe(20);
|
||||
expect(result.data.criteres[4]!.score).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,14 +23,25 @@ import {
|
|||
correctEO as deepseekCorrectEO,
|
||||
generateProductionModele,
|
||||
generateExercices,
|
||||
CRITERE_LABEL_PHONOLOGIE,
|
||||
type CorrectionRapport,
|
||||
type CorrectionCritereDetail,
|
||||
type NclcCible,
|
||||
type TacheEE,
|
||||
type TacheEO,
|
||||
type TacheCorrection,
|
||||
} from "../lib/deepseek.js";
|
||||
import { PLANS, type Plan } from "../lib/access.js";
|
||||
import { transcribeAudio, isAcceptedAudioMime } from "../lib/gemini.js";
|
||||
import {
|
||||
transcribeAudio,
|
||||
isAcceptedAudioMime,
|
||||
type AcceptedAudioMime,
|
||||
} from "../lib/gemini.js";
|
||||
import {
|
||||
evaluatePhonology,
|
||||
PHONOLOGY_STUB,
|
||||
type PhonologyResult,
|
||||
} from "../lib/geminiPhonology.js";
|
||||
import type { AuthProfile } from "../middleware/auth.js";
|
||||
|
||||
type CorrectionError = {
|
||||
|
|
@ -391,8 +402,14 @@ export async function correctEO(
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Mode batch audio : transcrire d'abord. Mode transcript direct : passer.
|
||||
// 3. Préparer l'audio (Mode B) ou le transcript (Mode A).
|
||||
// Mode B : on lance la transcription Gemini ET l'évaluation phonologique
|
||||
// en parallèle sur le même payload audio (Sprint 4.8).
|
||||
// Mode A : le client fournit déjà le transcript, la phonologie devient un
|
||||
// stub /4 (cf. PHONOLOGY_STUB) — pas d'audio à analyser.
|
||||
let transcript: string;
|
||||
let phonologyPromise: Promise<PhonologyResult>;
|
||||
|
||||
if (input.audioBase64 && input.mimeType) {
|
||||
// Normalisation du MIME : `MediaRecorder` côté navigateur produit souvent
|
||||
// un type complet `audio/webm;codecs=opus`. La whitelist Gemini compare
|
||||
|
|
@ -407,8 +424,23 @@ export async function correctEO(
|
|||
status: 400,
|
||||
};
|
||||
}
|
||||
const acceptedMime = normalizedMime as AcceptedAudioMime;
|
||||
// Démarrer la phonologie tout de suite — elle tourne en parallèle de la
|
||||
// transcription puis de la correction DeepSeek. Si elle échoue, on bascule
|
||||
// sur le stub et on log : la correction ne doit JAMAIS être bloquée par
|
||||
// une défaillance phonologique.
|
||||
phonologyPromise = evaluatePhonology(input.audioBase64, acceptedMime).catch(
|
||||
(err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
"[correctionController.correctEO] phonology evaluation failed",
|
||||
{ simulationId, message },
|
||||
);
|
||||
return PHONOLOGY_STUB;
|
||||
},
|
||||
);
|
||||
try {
|
||||
transcript = await transcribeAudio(input.audioBase64, normalizedMime);
|
||||
transcript = await transcribeAudio(input.audioBase64, acceptedMime);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[correctionController.correctEO] transcription failed", {
|
||||
|
|
@ -425,6 +457,7 @@ export async function correctEO(
|
|||
}
|
||||
} else if (typeof input.transcript === "string") {
|
||||
transcript = input.transcript;
|
||||
phonologyPromise = Promise.resolve(PHONOLOGY_STUB);
|
||||
} else {
|
||||
return {
|
||||
error: true,
|
||||
|
|
@ -451,9 +484,13 @@ export async function correctEO(
|
|||
nclcObtenu: nclcObtenuEstime,
|
||||
});
|
||||
|
||||
let rapport: CorrectionRapport;
|
||||
let rapportTextuel: CorrectionRapport;
|
||||
let phonology: PhonologyResult;
|
||||
try {
|
||||
rapport = await correctionPromise;
|
||||
[rapportTextuel, phonology] = await Promise.all([
|
||||
correctionPromise,
|
||||
phonologyPromise,
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[correctionController.correctEO] correction failed", {
|
||||
|
|
@ -471,6 +508,30 @@ export async function correctEO(
|
|||
};
|
||||
}
|
||||
|
||||
// 4-bis. Sprint 4.8 — fusionner la phonologie comme 5e critère et recalculer
|
||||
// le score global ∈ [0,20] (4 textuels × /4 + phonologie × /4).
|
||||
const phonologyCritere: CorrectionCritereDetail = {
|
||||
nom: CRITERE_LABEL_PHONOLOGIE,
|
||||
score: phonology.score,
|
||||
commentaire: phonology.commentaire,
|
||||
exemple: phonology.exemple,
|
||||
suggestion: phonology.suggestion,
|
||||
astuce: phonology.astuce,
|
||||
};
|
||||
const criteresAvecPhonologie: CorrectionCritereDetail[] = [
|
||||
...rapportTextuel.criteres,
|
||||
phonologyCritere,
|
||||
];
|
||||
const scoreFinal = criteresAvecPhonologie.reduce(
|
||||
(acc, c) => acc + c.score,
|
||||
0,
|
||||
);
|
||||
const rapport: CorrectionRapport = {
|
||||
...rapportTextuel,
|
||||
criteres: criteresAvecPhonologie,
|
||||
score: scoreFinal,
|
||||
};
|
||||
|
||||
// 5. Persister le rapport. Pas de *_status (race condition — cf. correctEE).
|
||||
const { error: updateError } = await supabase
|
||||
.from("productions")
|
||||
|
|
|
|||
|
|
@ -474,7 +474,7 @@ const VALID_RAPPORT_EO = {
|
|||
"Bonjour, je vais me présenter. Je m'appelle Pierre. Je travaille comme ingénieur.",
|
||||
criteres: [
|
||||
{
|
||||
nom: "Réalisation de la tâche",
|
||||
nom: "Adéquation à la tâche",
|
||||
score: 4,
|
||||
commentaire: "Tâche globalement respectée.",
|
||||
exemple: "Je vais me présenter",
|
||||
|
|
@ -482,7 +482,7 @@ const VALID_RAPPORT_EO = {
|
|||
astuce: "Soigner les ouvertures.",
|
||||
},
|
||||
{
|
||||
nom: "Cohérence et fluidité",
|
||||
nom: "Cohérence et cohésion",
|
||||
score: 3,
|
||||
commentaire: "Ruptures fréquentes.",
|
||||
exemple: "euh euh",
|
||||
|
|
@ -490,7 +490,7 @@ const VALID_RAPPORT_EO = {
|
|||
astuce: "Limiter les hésitations vocalisées.",
|
||||
},
|
||||
{
|
||||
nom: "Étendue du lexique",
|
||||
nom: "Étendue et maîtrise du lexique",
|
||||
score: 3,
|
||||
commentaire: "Vocabulaire basique.",
|
||||
exemple: "mon travail",
|
||||
|
|
@ -498,7 +498,7 @@ const VALID_RAPPORT_EO = {
|
|||
astuce: "Varier les mots du même champ.",
|
||||
},
|
||||
{
|
||||
nom: "Maîtrise grammaticale orale",
|
||||
nom: "Maîtrise morphosyntaxique",
|
||||
score: 4,
|
||||
commentaire: "Accords globalement corrects.",
|
||||
exemple: "les gens travaille",
|
||||
|
|
@ -542,22 +542,23 @@ describe("deepseek.correctEO", () => {
|
|||
expect(rapport.diagnostic).toBeDefined();
|
||||
expect(rapport.criteres).toHaveLength(4);
|
||||
expect(rapport.transcription_affichee).toContain("Bonjour");
|
||||
expect(rapport.note_phonologie).toBe(
|
||||
"Analyse phonologique non disponible pour cette session.",
|
||||
);
|
||||
// Sprint 4.8 : `note_phonologie` est retiré ; la phonologie est désormais
|
||||
// un 5e critère injecté par le controller (pas par DeepSeek).
|
||||
expect(rapport.note_phonologie).toBeUndefined();
|
||||
expect(rapport.erreurs_codes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cap score critère à 5 et recalcule le total", async () => {
|
||||
// DeepSeek déclare score=10 mais sort 7 sur le 1er critère (>5). On vérifie
|
||||
// que (a) chaque critère est cappé à 5 et (b) le total est recalculé sur la
|
||||
// somme des critères cappés (5+5+3+4=17), pas sur le score déclaré.
|
||||
it("cap score critère à 4 et recalcule le total textuel", async () => {
|
||||
// Sprint 4.8 : DeepSeek déclare score=10 mais sort 7 sur le 1er critère
|
||||
// (>4). On vérifie que (a) chaque critère est cappé à 4 et (b) le total
|
||||
// textuel est recalculé sur la somme des critères cappés (4+4+3+4=15),
|
||||
// pas sur le score déclaré. La phonologie /4 sera ajoutée par le controller.
|
||||
mockFetchSuccess({
|
||||
...VALID_RAPPORT_EO,
|
||||
score: 10,
|
||||
criteres: [
|
||||
{ ...VALID_RAPPORT_EO.criteres[0], score: 7 },
|
||||
{ ...VALID_RAPPORT_EO.criteres[1], score: 5 },
|
||||
{ ...VALID_RAPPORT_EO.criteres[1], score: 4 },
|
||||
{ ...VALID_RAPPORT_EO.criteres[2], score: 3 },
|
||||
{ ...VALID_RAPPORT_EO.criteres[3], score: 4 },
|
||||
],
|
||||
|
|
@ -565,9 +566,9 @@ describe("deepseek.correctEO", () => {
|
|||
const { correctEO } = await import("../deepseek");
|
||||
const rapport = await correctEO("t", "EO_T1", 9);
|
||||
|
||||
expect(rapport.criteres.every((c) => c.score <= 5)).toBe(true);
|
||||
// 5 (cappé) + 5 + 3 + 4 = 17 (et non 99)
|
||||
expect(rapport.score).toBe(17);
|
||||
expect(rapport.criteres.every((c) => c.score <= 4)).toBe(true);
|
||||
// 4 (cappé) + 4 + 3 + 4 = 15 (et non 99)
|
||||
expect(rapport.score).toBe(15);
|
||||
});
|
||||
|
||||
it("transcription_affichee absente → fallback sur le transcript brut", async () => {
|
||||
|
|
|
|||
123
src/lib/__tests__/geminiPhonology.test.ts
Normal file
123
src/lib/__tests__/geminiPhonology.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
function mockFetchSuccess(jsonText: string) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [{ content: { parts: [{ text: jsonText }] } }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_PAYLOAD = JSON.stringify({
|
||||
score: 3,
|
||||
commentaire:
|
||||
"Prononciation globalement claire avec quelques liaisons manquées.",
|
||||
exemple: "les amis",
|
||||
suggestion: "Réaliser la liaison _les_amis_.",
|
||||
astuce: "S'entraîner sur 5 paires liaison/non-liaison.",
|
||||
});
|
||||
|
||||
describe("geminiPhonology.evaluatePhonology", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retourne un PhonologyResult valide sur succès", async () => {
|
||||
mockFetchSuccess(VALID_PAYLOAD);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
const result = await evaluatePhonology("base64audio", "audio/webm");
|
||||
expect(result.score).toBe(3);
|
||||
expect(result.commentaire).toMatch(/Prononciation/);
|
||||
expect(result.exemple).toBe("les amis");
|
||||
expect(result.suggestion).toMatch(/liaison/);
|
||||
expect(result.astuce).toMatch(/entraîner/);
|
||||
});
|
||||
|
||||
it("cap le score à 4 si Gemini renvoie 5+", async () => {
|
||||
mockFetchSuccess(
|
||||
JSON.stringify({ score: 7, commentaire: "Score sur-évalué." }),
|
||||
);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
const result = await evaluatePhonology("base64audio", "audio/webm");
|
||||
expect(result.score).toBe(4);
|
||||
});
|
||||
|
||||
it("ramène le score à 0 si Gemini renvoie négatif", async () => {
|
||||
mockFetchSuccess(
|
||||
JSON.stringify({ score: -2, commentaire: "Score négatif." }),
|
||||
);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
const result = await evaluatePhonology("base64audio", "audio/webm");
|
||||
expect(result.score).toBe(0);
|
||||
});
|
||||
|
||||
it("arrondit un score décimal", async () => {
|
||||
mockFetchSuccess(
|
||||
JSON.stringify({ score: 2.7, commentaire: "Score décimal." }),
|
||||
);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
const result = await evaluatePhonology("base64audio", "audio/webm");
|
||||
expect(result.score).toBe(3);
|
||||
});
|
||||
|
||||
it("rejette si la réponse n'est pas du JSON", async () => {
|
||||
mockFetchSuccess("ceci n'est pas du JSON");
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
await expect(
|
||||
evaluatePhonology("base64audio", "audio/webm"),
|
||||
).rejects.toThrow(/non-JSON/);
|
||||
});
|
||||
|
||||
it("rejette si le commentaire est manquant", async () => {
|
||||
mockFetchSuccess(JSON.stringify({ score: 3 }));
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
await expect(
|
||||
evaluatePhonology("base64audio", "audio/webm"),
|
||||
).rejects.toThrow(/commentaire manquant/);
|
||||
});
|
||||
|
||||
it("rejette sur erreur HTTP applicative (pas de retry)", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
await expect(
|
||||
evaluatePhonology("base64audio", "audio/webm"),
|
||||
).rejects.toThrow(/Gemini phonology API error/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("réessaie une fois sur TimeoutError et réussit au 2e essai", async () => {
|
||||
const timeoutErr = Object.assign(new Error("timeout"), {
|
||||
name: "TimeoutError",
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutErr)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [{ content: { parts: [{ text: VALID_PAYLOAD }] } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { evaluatePhonology } = await import("../geminiPhonology");
|
||||
const result = await evaluatePhonology("base64audio", "audio/webm");
|
||||
expect(result.score).toBe(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("PHONOLOGY_STUB est un objet exploitable directement", async () => {
|
||||
const { PHONOLOGY_STUB } = await import("../geminiPhonology");
|
||||
expect(PHONOLOGY_STUB.score).toBe(0);
|
||||
expect(PHONOLOGY_STUB.commentaire).toMatch(/audio requis/);
|
||||
});
|
||||
});
|
||||
|
|
@ -85,14 +85,17 @@ export interface CorrectionRapport {
|
|||
* identique (mappage via le champ `critere` interne adequation_tache, etc.).
|
||||
*/
|
||||
export const CRITERE_LABELS_EO: Record<Critere, string> = {
|
||||
adequation_tache: "Réalisation de la tâche",
|
||||
coherence_cohesion: "Cohérence et fluidité",
|
||||
competence_lexicale: "Étendue du lexique",
|
||||
competence_grammaticale: "Maîtrise grammaticale orale",
|
||||
adequation_tache: "Adéquation à la tâche",
|
||||
coherence_cohesion: "Cohérence et cohésion",
|
||||
competence_lexicale: "Étendue et maîtrise du lexique",
|
||||
competence_grammaticale: "Maîtrise morphosyntaxique",
|
||||
};
|
||||
|
||||
const EO_NOTE_PHONOLOGIE_DEFAULT =
|
||||
"Analyse phonologique non disponible pour cette session.";
|
||||
/**
|
||||
* Sprint 4.8 — Label officiel TCF Canada du 5e critère, ajouté hors prompt
|
||||
* DeepSeek (évalué par Gemini sur l'audio brut, cf. geminiPhonology.ts).
|
||||
*/
|
||||
export const CRITERE_LABEL_PHONOLOGIE = "Phonologie";
|
||||
|
||||
export interface ProductionModeleInput {
|
||||
tache: TacheCorrection;
|
||||
|
|
@ -953,13 +956,13 @@ RÈGLES ABSOLUES :
|
|||
- 'exemple' = citation textuelle EXACTE, mot pour mot, extraite du transcript 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' par critère = entier de 0 à 5 UNIQUEMENT.
|
||||
- 'score' global = somme des 4 scores critères (0 à 20).
|
||||
- 'score' par critère = entier de 0 à 4 UNIQUEMENT.
|
||||
- 'score' global = somme des 4 scores critères textuels (0 à 16). Le 5e critère « Phonologie » est évalué séparément côté serveur ; tu N'INCLUS PAS la phonologie dans ce score ni dans la liste 'criteres'.
|
||||
- Dans les valeurs JSON (chaînes), n'utilise JAMAIS de guillemets doubles ; préfère les guillemets simples ou les chevrons « ».
|
||||
- 'transcription_affichee' = version NETTOYÉE du transcript brut : ponctuation restaurée, majuscules en début de phrase, paragraphes ajoutés. Tu ne MODIFIES PAS les mots prononcés ; tu n'ajoutes ni n'enlèves rien au contenu.
|
||||
- JSON strict sans aucun texte avant ni après.
|
||||
|
||||
CRITÈRES OFFICIELS TCF Canada — Expression Orale (chacun noté 0 à 5) :
|
||||
CRITÈRES OFFICIELS TCF Canada — Expression Orale (les 4 critères textuels ci-dessous, chacun noté 0 à 4 — la Phonologie /4 est évaluée à part sur l'audio) :
|
||||
1. ${CRITERE_LABELS_EO.adequation_tache} — respect de la consigne, durée perçue, registre, pertinence du contenu.
|
||||
2. ${CRITERE_LABELS_EO.coherence_cohesion} — structure logique, fluidité discursive, connecteurs, progression thématique, capacité à enchaîner sans rupture excessive.
|
||||
3. ${CRITERE_LABELS_EO.competence_lexicale} — étendue du vocabulaire à l'oral, précision, variété, absence de répétitions excessives.
|
||||
|
|
@ -971,8 +974,8 @@ ${buildConseilNclcRulesBlock(nclcCible, minScore, "single")}
|
|||
|
||||
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>,
|
||||
'score': <entier 0-16, somme des 4 critères textuels — phonologie évaluée séparément>,
|
||||
'nclc': <entier 4-12, niveau estimé à partir du score textuel + phonologie attendue>,
|
||||
'revelation': {
|
||||
'croyance': '<ce que le candidat croit faire bien à l oral>',
|
||||
'realite': '<ce que le correcteur observe réellement dans le transcript>',
|
||||
|
|
@ -981,10 +984,10 @@ FORMAT DE RÉPONSE (JSON strict, aucun autre texte) :
|
|||
'diagnostic': '<phrase courte et directe identifiant le principal frein à l oral>',
|
||||
'transcription_affichee': '<transcript nettoyé : ponctuation, majuscules, paragraphes>',
|
||||
'criteres': [
|
||||
{ 'nom': '${CRITERE_LABELS_EO.adequation_tache}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.coherence_cohesion}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.competence_lexicale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.competence_grammaticale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' }
|
||||
{ 'nom': '${CRITERE_LABELS_EO.adequation_tache}', 'score': <0-4>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.coherence_cohesion}', 'score': <0-4>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.competence_lexicale}', 'score': <0-4>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
|
||||
{ 'nom': '${CRITERE_LABELS_EO.competence_grammaticale}', 'score': <0-4>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' }
|
||||
],
|
||||
'conseil_nclc': {
|
||||
'nclc_cible': 'NCLC ${nclcCible}',
|
||||
|
|
@ -1079,22 +1082,29 @@ export async function generateIdees(
|
|||
}
|
||||
|
||||
/**
|
||||
* Sprint 4a — Validation runtime du rapport EO.
|
||||
* Sprint 4a / Sprint 4.8 — Validation runtime du rapport EO.
|
||||
*
|
||||
* Différences avec validateCorrectionRapport (EE) :
|
||||
* - Cap chaque score critère à 5 (sécurité — DeepSeek peut sortir 6+ malgré la consigne).
|
||||
* - Recalcule le score global comme somme des 4 scores cappés.
|
||||
* - Cap chaque score critère textuel à 4 (Sprint 4.8 : passage de /5 à /4).
|
||||
* La 5e dimension « Phonologie » /4 est ajoutée par le controller à partir
|
||||
* de l'évaluation Gemini sur l'audio brut (cf. geminiPhonology.ts) — elle
|
||||
* N'EST PAS gérée ici.
|
||||
* - Recalcule le score textuel comme somme des 4 scores cappés ∈ [0,16]. Le
|
||||
* total final /20 est calculé par le controller après injection de la
|
||||
* phonologie.
|
||||
* - Lit `transcription_affichee` (chaîne, fallback : transcript brut nettoyé minimalement).
|
||||
* - Ajoute `note_phonologie` fixe (MVP — TD-08).
|
||||
*
|
||||
* Note : `note_phonologie` (champ fixe MVP) est retiré au Sprint 4.8 puisque
|
||||
* la phonologie est désormais un critère structuré à part entière.
|
||||
*/
|
||||
function validateCorrectionRapportEO(
|
||||
raw: unknown,
|
||||
nclcCible: NclcCible,
|
||||
transcriptBrut: string,
|
||||
): CorrectionRapport {
|
||||
// Pré-traitement EO : cap chaque score critère à [0,5] et recalcule le score
|
||||
// global comme somme des critères cappés AVANT la validation EE de base, pour
|
||||
// éviter que le validateur parent ne rejette une valeur > 5 ou un total > 20
|
||||
// Pré-traitement EO : cap chaque score critère à [0,4] et recalcule le score
|
||||
// textuel comme somme des critères cappés (≤ 16) AVANT la validation EE de
|
||||
// base, pour éviter que le validateur parent ne rejette une valeur > 16
|
||||
// (DeepSeek peut dériver malgré la consigne).
|
||||
if (typeof raw === "object" && raw !== null) {
|
||||
const r = raw as Record<string, unknown>;
|
||||
|
|
@ -1104,7 +1114,7 @@ function validateCorrectionRapportEO(
|
|||
const o = c as Record<string, unknown>;
|
||||
const s = typeof o.score === "number" ? o.score : Number(o.score);
|
||||
const capped = Number.isFinite(s)
|
||||
? Math.max(0, Math.min(5, Math.round(s)))
|
||||
? Math.max(0, Math.min(4, Math.round(s)))
|
||||
: 0;
|
||||
return { ...o, score: capped };
|
||||
});
|
||||
|
|
@ -1128,7 +1138,6 @@ function validateCorrectionRapportEO(
|
|||
return {
|
||||
...baseRapport,
|
||||
transcription_affichee: transcriptionAffichee,
|
||||
note_phonologie: EO_NOTE_PHONOLOGIE_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
170
src/lib/geminiPhonology.ts
Normal file
170
src/lib/geminiPhonology.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Évaluation phonologique EO via Gemini batch — Sprint 4.8.
|
||||
*
|
||||
* Reçoit l'audio brut du candidat (base64) et retourne un score `/4` ainsi
|
||||
* qu'un commentaire pédagogique structuré, alignés sur la grille TCF Canada.
|
||||
* Cet appel est complémentaire de `transcribeAudio` (cf. gemini.ts) :
|
||||
* - `transcribeAudio` extrait le texte → DeepSeek évalue 4 critères /4.
|
||||
* - `evaluatePhonology` écoute l'audio → 5e critère Phonologie /4.
|
||||
*
|
||||
* Robustesse : timeout 45 s + 1 retry sur erreur transitoire (TimeoutError,
|
||||
* AbortError, TypeError). Pas de retry sur erreur HTTP applicative (config
|
||||
* Gemini cassée → un second essai échouera identiquement).
|
||||
*
|
||||
* Mode A (transcript fourni sans audio) : utiliser `PHONOLOGY_STUB`
|
||||
* directement plutôt que d'appeler cette fonction.
|
||||
*/
|
||||
|
||||
import type { AcceptedAudioMime } from "./gemini.js";
|
||||
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
const GEMINI_TIMEOUT_MS = 45_000;
|
||||
|
||||
export interface PhonologyResult {
|
||||
/** Score entier 0..4 (capé côté serveur pour neutraliser les dérives). */
|
||||
score: number;
|
||||
commentaire: string;
|
||||
exemple: string;
|
||||
suggestion: string;
|
||||
astuce: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub utilisé quand aucune piste audio n'est disponible (ex. Mode A —
|
||||
* transcript fourni directement par le client). Le score est volontairement
|
||||
* 0 pour que le total /20 reflète l'absence d'évaluation.
|
||||
*/
|
||||
export const PHONOLOGY_STUB: PhonologyResult = {
|
||||
score: 0,
|
||||
commentaire: "Évaluation phonologique indisponible — audio requis.",
|
||||
exemple: "",
|
||||
suggestion: "",
|
||||
astuce: "",
|
||||
};
|
||||
|
||||
const PHONOLOGY_SYSTEM_PROMPT = `Tu es un correcteur TCF Canada certifié, spécialiste de la phonologie pour l'épreuve d'Expression Orale.
|
||||
|
||||
Tu écoutes un enregistrement audio bref (≤ 5 minutes) et tu évalues UNIQUEMENT la phonologie selon la grille officielle TCF Canada :
|
||||
- Prononciation des sons consonantiques et vocaliques
|
||||
- Liaisons et enchaînements
|
||||
- Rythme, débit, accentuation
|
||||
- Intonation et prosodie
|
||||
- Fluidité phonique (présence d'hésitations marquées, hachures)
|
||||
|
||||
Échelle : entier de 0 à 4 UNIQUEMENT.
|
||||
- 0 : prononciation très défaillante, intelligibilité fortement compromise.
|
||||
- 1 : nombreux écarts, intelligibilité difficile.
|
||||
- 2 : écarts notables mais intelligibilité préservée.
|
||||
- 3 : prononciation correcte avec quelques écarts ponctuels.
|
||||
- 4 : prononciation maîtrisée, naturelle, proche du francophone natif.
|
||||
|
||||
Réponds par un JSON STRICT, sans aucun texte avant ni après, sans markdown, sans backtick :
|
||||
{
|
||||
"score": <entier 0-4>,
|
||||
"commentaire": "<2 phrases max — observations concrètes sur la prononciation>",
|
||||
"exemple": "<mot ou expression où l'erreur phonologique est notable, ou chaîne vide si rien à signaler>",
|
||||
"suggestion": "<reformulation orale ciblée, par ex. 'détacher la liaison de _les_amis_'>",
|
||||
"astuce": "<conseil court et actionnable pour s'entraîner>"
|
||||
}`;
|
||||
|
||||
const PHONOLOGY_USER_PROMPT =
|
||||
"Évalue la phonologie de cet enregistrement selon la grille TCF Canada. Renvoie uniquement le JSON décrit dans le prompt système.";
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
||||
}
|
||||
|
||||
function clampScore(raw: unknown): number {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(4, Math.round(n)));
|
||||
}
|
||||
|
||||
function parsePhonologyJson(text: string): PhonologyResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Gemini phonology: réponse non-JSON");
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Gemini phonology: payload invalide");
|
||||
}
|
||||
const r = parsed as Record<string, unknown>;
|
||||
const score = clampScore(r.score);
|
||||
const commentaire = typeof r.commentaire === "string" ? r.commentaire : "";
|
||||
if (commentaire.trim().length === 0) {
|
||||
throw new Error("Gemini phonology: commentaire manquant");
|
||||
}
|
||||
const exemple = typeof r.exemple === "string" ? r.exemple : "";
|
||||
const suggestion = typeof r.suggestion === "string" ? r.suggestion : "";
|
||||
const astuce = typeof r.astuce === "string" ? r.astuce : "";
|
||||
return { score, commentaire, exemple, suggestion, astuce };
|
||||
}
|
||||
|
||||
async function callGeminiPhonology(
|
||||
audioBase64: string,
|
||||
mimeType: AcceptedAudioMime,
|
||||
): Promise<PhonologyResult> {
|
||||
const response = await fetch(
|
||||
`${GEMINI_BASE_URL}/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: PHONOLOGY_SYSTEM_PROMPT }] },
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ inlineData: { mimeType, data: audioBase64 } },
|
||||
{ text: PHONOLOGY_USER_PROMPT },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
temperature: 0.2,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(GEMINI_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Gemini phonology API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeminiResponse;
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
throw new Error("Gemini phonology: réponse vide");
|
||||
}
|
||||
return parsePhonologyJson(text.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue la phonologie sur l'audio brut. 1 retry automatique sur erreur
|
||||
* transitoire ; les erreurs HTTP applicatives ne sont PAS retentées.
|
||||
*/
|
||||
export async function evaluatePhonology(
|
||||
audioBase64: string,
|
||||
mimeType: AcceptedAudioMime,
|
||||
): Promise<PhonologyResult> {
|
||||
try {
|
||||
return await callGeminiPhonology(audioBase64, mimeType);
|
||||
} catch (err) {
|
||||
const isRetryable =
|
||||
err instanceof Error &&
|
||||
(err.name === "TimeoutError" ||
|
||||
err.name === "AbortError" ||
|
||||
err instanceof TypeError);
|
||||
if (!isRetryable) throw err;
|
||||
console.warn(
|
||||
`[geminiPhonology.evaluatePhonology] retry après erreur transitoire : ${err.message}`,
|
||||
);
|
||||
return await callGeminiPhonology(audioBase64, mimeType);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue