From 7cac057062b6e398c271e35e36f69f1ce6476d8d Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sat, 25 Apr 2026 05:04:26 +0300 Subject: [PATCH] feat(eo): align correction EO on 3.6a format + Deepgram token + T1 presentation generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 4a: - correctEO aligned on CorrectionRapport format (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes) - nclc_cible parameter (default 9, accepts 9|10) - Fire-and-forget modele + exercices jobs (same pattern as EE) - EO-specific DeepSeek prompt (oral transcript tolerance, 4 TCF criteria) - Gemini transcribeAudio: 30s timeout + 1 retry - POST /presentations/generate: 5-field questionnaire → DeepSeek generates oral presentation (~220-260 words, NCLC 7-8) - Migration 006_sprint_4a_eo.sql (documentation only — no audio storage) Sprint 4b: - POST /transcriptions/token: Deepgram temporary API key (600s TTL) - Removed audio storage pipeline (audioStorage.ts, XOR validation, 14MB limit) - Backend receives transcript text only, no audio files - TD-10/TD-11 resolved (Sprint 3.6c), TD-16/17/18 resolved (4b cleanup) Typecheck: OK · Tests: 241/241 ✅ Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 1 + docs/TECH_DEBT-backend.md | 233 +++++ src/controllers/__tests__/correctEO.test.ts | 310 ++++++ .../__tests__/presentationGenerate.test.ts | 148 +++ src/controllers/correctionController.ts | 383 ++++--- src/controllers/presentationController.ts | 178 ++++ src/index.ts | 4 + src/lib/__tests__/deepseek.test.ts | 742 ++++++++------ src/lib/deepgram.ts | 53 + src/lib/deepseek.ts | 935 +++++++++++------- src/lib/gemini.ts | 94 +- src/routes/__tests__/correctionsEO.test.ts | 161 +++ .../__tests__/presentationsGenerate.test.ts | 132 +++ .../__tests__/transcriptionsToken.test.ts | 142 +++ src/routes/corrections.ts | 189 ++-- src/routes/presentations.ts | 39 + src/routes/transcriptions.ts | 36 + supabase/migrations/006_sprint_4a_eo.sql | 38 + 18 files changed, 2907 insertions(+), 911 deletions(-) create mode 100644 docs/TECH_DEBT-backend.md create mode 100644 src/controllers/__tests__/correctEO.test.ts create mode 100644 src/controllers/__tests__/presentationGenerate.test.ts create mode 100644 src/controllers/presentationController.ts create mode 100644 src/lib/deepgram.ts create mode 100644 src/routes/__tests__/correctionsEO.test.ts create mode 100644 src/routes/__tests__/presentationsGenerate.test.ts create mode 100644 src/routes/__tests__/transcriptionsToken.test.ts create mode 100644 src/routes/presentations.ts create mode 100644 src/routes/transcriptions.ts create mode 100644 supabase/migrations/006_sprint_4a_eo.sql diff --git a/.env.example b/.env.example index 2f6e4fb..a59443e 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ SUPABASE_SERVICE_ROLE_KEY=xxx # APIs DEEPSEEK_API_KEY=xxx GEMINI_API_KEY=xxx +DEEPGRAM_API_KEY=xxx # Stripe STRIPE_SECRET_KEY=xxx diff --git a/docs/TECH_DEBT-backend.md b/docs/TECH_DEBT-backend.md new file mode 100644 index 0000000..05d0788 --- /dev/null +++ b/docs/TECH_DEBT-backend.md @@ -0,0 +1,233 @@ +# 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 :** 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`). + +--- + +### 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`). + +--- + +## 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. + +--- + +### 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. + +--- + +### 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. + +--- + +### 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. + +--- + +### 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. + +--- + +### 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é. + +--- + +## 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 | +| TD-10 | Analyse des patterns (Premium) livrée | 2026-04-25 | Sprint 3.6c | +| TD-11 | Indice de préparation livré | 2026-04-25 | Sprint 3.6c | +| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct | +| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b | +| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b | diff --git a/src/controllers/__tests__/correctEO.test.ts b/src/controllers/__tests__/correctEO.test.ts new file mode 100644 index 0000000..acd0c26 --- /dev/null +++ b/src/controllers/__tests__/correctEO.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { CorrectionRapport } from "../../lib/deepseek"; +import type { AuthProfile } from "../../middleware/auth"; + +// ── Helpers mocks ──────────────────────────────────────────────────────── + +const PROFILE: AuthProfile = { + id: "user-1", + email: "u@test.com", + plan: "standard", + simulations_used: 3, +}; + +const VALID_RAPPORT_EO: CorrectionRapport = { + score: 14, + nclc: 9, + nclc_cible: 9, + revelation: { croyance: "c", realite: "r", consequence: "co" }, + diagnostic: "d", + criteres: [ + { + nom: "Réalisation de la tâche", + score: 4, + commentaire: "", + exemple: "", + suggestion: "", + astuce: "", + }, + { + nom: "Cohérence et fluidité", + score: 3, + commentaire: "", + exemple: "", + suggestion: "", + astuce: "", + }, + { + nom: "Étendue du lexique", + score: 3, + commentaire: "", + exemple: "", + suggestion: "", + astuce: "", + }, + { + nom: "Maîtrise grammaticale orale", + score: 4, + commentaire: "", + exemple: "", + suggestion: "", + astuce: "", + }, + ], + conseil_nclc: { nclc_cible: "NCLC 9", ecart: "ok", action_prioritaire: "a" }, + erreurs_codes: [ + { + code: "vocabulaire_basique", + critere: "competence_lexicale", + description: null, + }, + ], + transcription_affichee: "Bonjour. Je m'appelle Pierre.", + note_phonologie: "Analyse phonologique non disponible pour cette session.", +}; + +interface ProductionRow { + id: string; + user_id: string; + tache: string; + sujet_id: string | null; +} + +function createSupabaseMock(production: ProductionRow | null) { + const updates: { + table: string; + data: Record; + id?: string; + }[] = []; + + const fromMock = vi.fn((table: string) => { + if (table === "productions") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ + data: production, + error: production ? null : { message: "not found" }, + }), + }), + }), + update: (data: Record) => ({ + eq: async (_col: string, id: string) => { + updates.push({ table, data, id }); + 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) => ({ + eq: async (_col: string, id: string) => { + updates.push({ table, data, id }); + return { error: null }; + }, + }), + }; + } + return {}; + }); + + return { + mock: { from: fromMock }, + updates, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("correctionController.correctEO — Sprint 4b (transcript-only)", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("retourne un rapport EO 3.6a et persiste les champs (mode transcript)", async () => { + const { mock, updates } = createSupabaseMock({ + id: "sim-1", + user_id: "user-1", + tache: "EO_T1", + sujet_id: "sujet-1", + }); + vi.doMock("../../lib/supabase", () => ({ supabase: mock })); + + vi.doMock("../../lib/deepseek", () => ({ + correctEE: vi.fn(), + correctEO: vi.fn().mockResolvedValue(VALID_RAPPORT_EO), + generateProductionModele: vi.fn().mockResolvedValue({ + production_modele_propre: "texte", + 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 { correctEO } = await import("../correctionController"); + const result = await correctEO( + { + simulationId: "sim-1", + tache: "EO_T1", + nclcCible: 9, + transcript: "Bonjour je m appelle Pierre", + }, + PROFILE, + ); + + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.simulation_id).toBe("sim-1"); + expect(result.data.score).toBe(14); + expect(result.data.note_phonologie).toContain("phonologique"); + } + + const persisted = updates.find( + (u) => u.table === "productions" && u.data.score !== undefined, + ); + expect(persisted).toBeDefined(); + expect(persisted!.data).toMatchObject({ + score: 14, + nclc: 9, + nclc_cible: 9, + }); + expect(persisted!.data.contenu).toBe("Bonjour je m appelle Pierre"); + // Pas de modele_status / exercices_status dans l'update principal (race). + expect(persisted!.data.modele_status).toBeUndefined(); + expect(persisted!.data.exercices_status).toBeUndefined(); + // Sprint 4b — plus de stockage audio backend. + expect(persisted!.data.audio_url).toBeUndefined(); + }); + + it("simulation introuvable → SIMULATION_NOT_FOUND 404", async () => { + const { mock } = createSupabaseMock(null); + vi.doMock("../../lib/supabase", () => ({ supabase: mock })); + vi.doMock("../../lib/deepseek", () => ({ + correctEE: vi.fn(), + correctEO: vi.fn(), + generateProductionModele: vi.fn(), + generateExercices: vi.fn(), + })); + + const { correctEO } = await import("../correctionController"); + const result = await correctEO( + { + simulationId: "sim-x", + tache: "EO_T1", + nclcCible: 9, + transcript: "t", + }, + PROFILE, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.code).toBe("SIMULATION_NOT_FOUND"); + expect(result.status).toBe(404); + } + }); + + it("simulation appartenant à un autre user → AUTH_REQUIRED 401", async () => { + const { mock } = createSupabaseMock({ + id: "sim-4", + user_id: "other-user", + tache: "EO_T1", + sujet_id: null, + }); + vi.doMock("../../lib/supabase", () => ({ supabase: mock })); + vi.doMock("../../lib/deepseek", () => ({ + correctEE: vi.fn(), + correctEO: vi.fn(), + generateProductionModele: vi.fn(), + generateExercices: vi.fn(), + })); + + const { correctEO } = await import("../correctionController"); + const result = await correctEO( + { + simulationId: "sim-4", + tache: "EO_T1", + nclcCible: 9, + transcript: "t", + }, + PROFILE, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.code).toBe("AUTH_REQUIRED"); + expect(result.status).toBe(401); + } + }); + + it("nclc_cible=10 propagé jusqu'au prompt et au rapport persisté", async () => { + const { mock, updates } = createSupabaseMock({ + id: "sim-7", + user_id: "user-1", + tache: "EO_T1", + sujet_id: null, + }); + vi.doMock("../../lib/supabase", () => ({ supabase: mock })); + + const correctEOSpy = vi + .fn() + .mockResolvedValue({ ...VALID_RAPPORT_EO, nclc_cible: 10 }); + vi.doMock("../../lib/deepseek", () => ({ + correctEE: vi.fn(), + correctEO: correctEOSpy, + generateProductionModele: vi.fn().mockResolvedValue({ + production_modele_propre: "t", + notes_pedagogiques: [], + transformations: [], + message: "", + nclc_modele: 9, + nclc_obtenu: 9, + score_cible: 14, + tcf_word_count: 1, + tcf_word_min: 200, + tcf_word_max: 300, + tcf_truncated: false, + }), + generateExercices: vi.fn().mockResolvedValue([]), + })); + + const { correctEO } = await import("../correctionController"); + await correctEO( + { + simulationId: "sim-7", + tache: "EO_T1", + nclcCible: 10, + transcript: "t", + }, + PROFILE, + ); + + expect(correctEOSpy).toHaveBeenCalledWith("t", "EO_T1", 10, null); + const persisted = updates.find( + (u) => u.table === "productions" && u.data.nclc_cible !== undefined, + ); + expect(persisted!.data.nclc_cible).toBe(10); + }); +}); diff --git a/src/controllers/__tests__/presentationGenerate.test.ts b/src/controllers/__tests__/presentationGenerate.test.ts new file mode 100644 index 0000000..4d2e8e1 --- /dev/null +++ b/src/controllers/__tests__/presentationGenerate.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Validation pure (pas de fetch). + +describe("presentationController.validateReponses", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("accepte les 5 champs requis non vides", async () => { + const { validateReponses } = await import("../presentationController"); + const result = validateReponses({ + prenom_age_ville: "Pierre, 30 ans, Alger", + formation_metier: "Ingénieur", + situation_familiale: "Marié, deux enfants", + loisirs: "Lecture, randonnée", + motivation_canada: "Opportunités professionnelles", + }); + expect("ok" in result && result.ok).toBe(true); + }); + + it("rejette si reponses non objet", async () => { + const { validateReponses } = await import("../presentationController"); + const result = validateReponses("string"); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.code).toBe("VALIDATION_ERROR"); + }); + + it.each([ + "prenom_age_ville", + "formation_metier", + "situation_familiale", + "loisirs", + "motivation_canada", + ])("rejette si %s manquant", async (field) => { + const { validateReponses } = await import("../presentationController"); + const all: Record = { + prenom_age_ville: "a", + formation_metier: "b", + situation_familiale: "c", + loisirs: "d", + motivation_canada: "e", + }; + delete all[field]; + const result = validateReponses(all); + expect("error" in result).toBe(true); + }); + + it("rejette les champs vides ou whitespace", async () => { + const { validateReponses } = await import("../presentationController"); + const result = validateReponses({ + prenom_age_ville: " ", + formation_metier: "b", + situation_familiale: "c", + loisirs: "d", + motivation_canada: "e", + }); + expect("error" in result).toBe(true); + }); +}); + +// Pipeline complet — fetch mocké. + +describe("presentationController.generate", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + const VALID_REPONSES = { + prenom_age_ville: "Pierre, 30 ans, Alger", + formation_metier: "Ingénieur", + situation_familiale: "Marié", + loisirs: "Lecture", + motivation_canada: "Travail", + }; + + it("succès → renvoie { presentation: string }", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [ + { message: { content: "Bonjour, je m'appelle Pierre. Voilà." } }, + ], + }), + }), + ); + + const { generate } = await import("../presentationController"); + const result = await generate(VALID_REPONSES); + + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.presentation).toContain("Pierre"); + } + }); + + it("DeepSeek non-OK → INTERNAL_ERROR 500", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "I" }), + ); + + const { generate } = await import("../presentationController"); + const result = await generate(VALID_REPONSES); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.code).toBe("INTERNAL_ERROR"); + expect(result.status).toBe(500); + } + }); + + it("réponse vide → INTERNAL_ERROR 500", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: "" } }] }), + }), + ); + const { generate } = await import("../presentationController"); + const result = await generate(VALID_REPONSES); + expect("error" in result).toBe(true); + }); + + it("fetch throw (timeout) → INTERNAL_ERROR 500", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("network down")), + ); + const { generate } = await import("../presentationController"); + const result = await generate(VALID_REPONSES); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.code).toBe("INTERNAL_ERROR"); + }); + + it("rejette les body invalides en court-circuitant fetch", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { generate } = await import("../presentationController"); + const result = await generate({ prenom_age_ville: "" }); + expect("error" in result).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/controllers/correctionController.ts b/src/controllers/correctionController.ts index 37d3482..3107be9 100644 --- a/src/controllers/correctionController.ts +++ b/src/controllers/correctionController.ts @@ -17,79 +17,82 @@ * les colonnes `*_status` restent en 'pending' indéfiniment. */ -import { supabase } from '../lib/supabase.js' +import { supabase } from "../lib/supabase.js"; import { correctEE as deepseekCorrectEE, correctEO as deepseekCorrectEO, generateProductionModele, generateExercices, type CorrectionRapport, - type EORapport, type NclcCible, type TacheEE, -} from '../lib/deepseek.js' -import { PLANS, type Plan } from '../lib/access.js' -import type { AuthProfile } from '../middleware/auth.js' + type TacheEO, + type TacheCorrection, +} from "../lib/deepseek.js"; +import { PLANS, type Plan } from "../lib/access.js"; +import type { AuthProfile } from "../middleware/auth.js"; type CorrectionError = { - error: true - code: string - message: string - status: number -} + error: true; + code: string; + message: string; + status: number; +}; export interface CorrectEEInput { - simulationId: string - contenu: string - tache: TacheEE - nclcCible: NclcCible + simulationId: string; + contenu: string; + tache: TacheEE; + nclcCible: NclcCible; } export async function correctEE( input: CorrectEEInput, profile: AuthProfile, -): Promise<{ data: CorrectionRapport & { simulation_id: string } } | CorrectionError> { - const { simulationId, contenu, tache, nclcCible } = input +): Promise< + { data: CorrectionRapport & { simulation_id: string } } | CorrectionError +> { + const { simulationId, contenu, tache, nclcCible } = input; // 1. Vérifier que la production existe et appartient à l'utilisateur const { data: production, error: fetchError } = await supabase - .from('productions') - .select('id, user_id, tache, sujet_id, rapport') - .eq('id', simulationId) - .single() + .from("productions") + .select("id, user_id, tache, sujet_id, rapport") + .eq("id", simulationId) + .single(); if (fetchError || !production) { return { error: true, - code: 'SIMULATION_NOT_FOUND', - message: 'Simulation introuvable.', + code: "SIMULATION_NOT_FOUND", + message: "Simulation introuvable.", status: 404, - } + }; } if (production.user_id !== profile.id) { return { error: true, - code: 'AUTH_REQUIRED', - message: 'Cette simulation ne vous appartient pas.', + code: "AUTH_REQUIRED", + message: "Cette simulation ne vous appartient pas.", status: 401, - } + }; } // 2. Charger le sujet pour alimenter le prompt maître (consigne + docs T3) - let sujetConsigne: string | null = null - let sourceDoc1: string | null = null - let sourceDoc2: string | null = null + let sujetConsigne: string | null = null; + let sourceDoc1: string | null = null; + let sourceDoc2: string | null = null; if (production.sujet_id) { const { data: sujetRow } = await supabase - .from('sujets') - .select('consigne, doc1_texte, doc2_texte') - .eq('id', production.sujet_id) - .single() + .from("sujets") + .select("consigne, doc1_texte, doc2_texte") + .eq("id", production.sujet_id) + .single(); if (sujetRow) { - sujetConsigne = (sujetRow.consigne as string | null) ?? null - sourceDoc1 = (sujetRow.doc1_texte as string | null) ?? null - sourceDoc2 = (sujetRow.doc2_texte as string | null) ?? null + sujetConsigne = (sujetRow.consigne as string | null) ?? null; + sourceDoc1 = (sujetRow.doc1_texte as string | null) ?? null; + sourceDoc2 = (sujetRow.doc2_texte as string | null) ?? null; } } @@ -105,36 +108,37 @@ export async function correctEE( sourceDoc1, sourceDoc2, nclcCible, - }) + }); - const nclcObtenuEstime = nclcCible - 1 + const nclcObtenuEstime = nclcCible - 1; void runModeleJob({ simulationId, tache, sujet: sujetConsigne, texte: contenu, nclcObtenu: nclcObtenuEstime, - }) + }); - let rapport: CorrectionRapport + let rapport: CorrectionRapport; try { - rapport = await correctionPromise + rapport = await correctionPromise; } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const stack = err instanceof Error ? err.stack : undefined - console.error('[correctionController.correctEE] correction failed', { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.error("[correctionController.correctEE] correction failed", { simulationId, tache, nclcCible, message, stack, - }) + }); return { error: true, - code: 'INTERNAL_ERROR', - message: 'Erreur lors de la correction. Veuillez réessayer dans quelques instants.', + code: "INTERNAL_ERROR", + message: + "Erreur lors de la correction. Veuillez réessayer dans quelques instants.", status: 500, - } + }; } // 4. Persister la correction. @@ -145,7 +149,7 @@ export async function correctEE( // Les colonnes *_status sont initialisées à 'pending' par la migration // (DEFAULT) et gérées exclusivement par runModeleJob / runExercicesJob. const { error: updateError } = await supabase - .from('productions') + .from("productions") .update({ score: rapport.score, nclc: rapport.nclc, @@ -156,193 +160,300 @@ export async function correctEE( erreurs_codes: rapport.erreurs_codes, rapport: JSON.stringify(rapport), }) - .eq('id', simulationId) + .eq("id", simulationId); if (updateError) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Erreur lors de la sauvegarde du rapport. Veuillez réessayer.', + code: "INTERNAL_ERROR", + message: "Erreur lors de la sauvegarde du rapport. Veuillez réessayer.", status: 500, - } + }; } // 5. Lancer les exercices maintenant qu'on a rapport.erreurs_codes + criteres. // Ne JAMAIS await — cette promesse vit après la réponse HTTP. - void runExercicesJob({ simulationId, tache, rapport }) + void runExercicesJob({ simulationId, tache, rapport }); // 6. Incrémenter simulations_used si le plan a une limite (non bloquant). if (PLANS[profile.plan as Plan].simulations_lifetime !== null) { await supabase - .from('profiles') + .from("profiles") .update({ simulations_used: profile.simulations_used + 1 }) - .eq('id', profile.id) + .eq("id", profile.id); } - return { data: { ...rapport, simulation_id: simulationId } } + return { data: { ...rapport, simulation_id: simulationId } }; } // ── Jobs asynchrones — modèle + exercices ─────────────────────────────── interface ModeleJobInput { - simulationId: string - tache: TacheEE - sujet: string | null - texte: string - nclcObtenu: number + simulationId: string; + tache: TacheCorrection; + sujet: string | null; + texte: string; + nclcObtenu: number; } async function runModeleJob(input: ModeleJobInput): Promise { - const { simulationId, tache, sujet, texte, nclcObtenu } = input - console.log('[runModeleJob] START', { simulationId, tache, nclcObtenu }) + const { simulationId, tache, sujet, texte, nclcObtenu } = input; + console.log("[runModeleJob] START", { simulationId, tache, nclcObtenu }); try { - const modele = await generateProductionModele({ tache, sujet, texte, nclcObtenu }) - console.log('[runModeleJob] DeepSeek OK, updating productions', { + const modele = await generateProductionModele({ + tache, + sujet, + texte, + nclcObtenu, + }); + console.log("[runModeleJob] DeepSeek OK, updating productions", { simulationId, modeleWordCount: modele.tcf_word_count, - }) + }); const { error: updateErr, data: updateData } = await supabase - .from('productions') - .update({ modele, modele_status: 'ready' }) - .eq('id', simulationId) - .select('id, modele_status') - console.log('[runModeleJob] update result', { simulationId, updateErr, updateData }) + .from("productions") + .update({ modele, modele_status: "ready" }) + .eq("id", simulationId) + .select("id, modele_status"); + console.log("[runModeleJob] update result", { + simulationId, + updateErr, + updateData, + }); } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const stack = err instanceof Error ? err.stack : undefined - console.error('[runModeleJob] CAUGHT ERROR', { simulationId, message, stack }) + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.error("[runModeleJob] CAUGHT ERROR", { + simulationId, + message, + stack, + }); try { const { error: fallbackErr } = await supabase - .from('productions') - .update({ modele_status: 'error' }) - .eq('id', simulationId) - console.log('[runModeleJob] fallback update result', { simulationId, fallbackErr }) - } catch (fallbackExc) { - console.error('[runModeleJob] FALLBACK UPDATE THREW', { + .from("productions") + .update({ modele_status: "error" }) + .eq("id", simulationId); + console.log("[runModeleJob] fallback update result", { simulationId, - message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), - }) + fallbackErr, + }); + } catch (fallbackExc) { + console.error("[runModeleJob] FALLBACK UPDATE THREW", { + simulationId, + message: + fallbackExc instanceof Error + ? fallbackExc.message + : String(fallbackExc), + }); } } } interface ExercicesJobInput { - simulationId: string - tache: TacheEE - rapport: CorrectionRapport + simulationId: string; + tache: TacheCorrection; + rapport: CorrectionRapport; } async function runExercicesJob(input: ExercicesJobInput): Promise { - const { simulationId, tache, rapport } = input - console.log('[runExercicesJob] START', { + const { simulationId, tache, rapport } = input; + console.log("[runExercicesJob] START", { simulationId, tache, erreursCodesCount: rapport.erreurs_codes.length, - }) + }); try { const exercices = await generateExercices({ tache, erreursCodes: rapport.erreurs_codes, criteres: rapport.criteres, - }) - console.log('[runExercicesJob] DeepSeek OK, updating productions', { + }); + console.log("[runExercicesJob] DeepSeek OK, updating productions", { simulationId, exercicesCount: exercices.length, - }) + }); const { error: updateErr, data: updateData } = await supabase - .from('productions') - .update({ exercices, exercices_status: 'ready' }) - .eq('id', simulationId) - .select('id, exercices_status') - console.log('[runExercicesJob] update result', { simulationId, updateErr, updateData }) + .from("productions") + .update({ exercices, exercices_status: "ready" }) + .eq("id", simulationId) + .select("id, exercices_status"); + console.log("[runExercicesJob] update result", { + simulationId, + updateErr, + updateData, + }); } catch (err) { - const message = err instanceof Error ? err.message : String(err) - const stack = err instanceof Error ? err.stack : undefined - console.error('[runExercicesJob] CAUGHT ERROR', { simulationId, message, stack }) + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + console.error("[runExercicesJob] CAUGHT ERROR", { + simulationId, + message, + stack, + }); try { const { error: fallbackErr } = await supabase - .from('productions') - .update({ exercices_status: 'error' }) - .eq('id', simulationId) - console.log('[runExercicesJob] fallback update result', { simulationId, fallbackErr }) - } catch (fallbackExc) { - console.error('[runExercicesJob] FALLBACK UPDATE THREW', { + .from("productions") + .update({ exercices_status: "error" }) + .eq("id", simulationId); + console.log("[runExercicesJob] fallback update result", { simulationId, - message: fallbackExc instanceof Error ? fallbackExc.message : String(fallbackExc), - }) + fallbackErr, + }); + } catch (fallbackExc) { + console.error("[runExercicesJob] FALLBACK UPDATE THREW", { + simulationId, + message: + fallbackExc instanceof Error + ? fallbackExc.message + : String(fallbackExc), + }); } } } -// ── EO — inchangé par Sprint 3.6a ─────────────────────────────────────── +// ── EO — Sprint 4b : transcript-only (audio géré côté frontend) ───────── +// +// Décision Sprint 4b : Deepgram en connexion directe navigateur ↔ Deepgram via +// token éphémère (cf. /transcriptions/token). Le backend reçoit uniquement le +// transcript final ; aucun audio n'est stocké côté serveur. +// +// Flux POST /corrections/eo : +// 1. Vérifier que la production existe, appartient à l'utilisateur. +// 2. Charger la consigne (utile au prompt EO). +// 3. Lancer correction EO + modèle EO en parallèle (mêmes patterns que EE). +// 4. Persister le rapport (revelation, diagnostic, conseil_nclc, erreurs_codes, +// contenu = transcript). +// 5. Lancer les exercices fire-and-forget. +// 6. Incrémenter le quota. +// +// Le risque race-condition décrit dans correctEE s'applique aussi ici : on ne +// touche PAS aux colonnes *_status dans l'update final. + +export interface CorrectEOInput { + simulationId: string; + tache: TacheEO; + nclcCible: NclcCible; + transcript: string; +} export async function correctEO( - simulationId: string, - transcript: string, - tache: string, + input: CorrectEOInput, profile: AuthProfile, -): Promise<{ data: EORapport & { simulation_id: string } } | CorrectionError> { +): Promise< + { data: CorrectionRapport & { simulation_id: string } } | CorrectionError +> { + const { simulationId, tache, nclcCible, transcript } = input; + + // 1. Vérifier la production + ownership. const { data: production, error: fetchError } = await supabase - .from('productions') - .select('id, user_id, tache') - .eq('id', simulationId) - .single() + .from("productions") + .select("id, user_id, tache, sujet_id") + .eq("id", simulationId) + .single(); if (fetchError || !production) { return { error: true, - code: 'SIMULATION_NOT_FOUND', - message: 'Simulation introuvable.', + code: "SIMULATION_NOT_FOUND", + message: "Simulation introuvable.", status: 404, - } + }; } if (production.user_id !== profile.id) { return { error: true, - code: 'AUTH_REQUIRED', - message: 'Cette simulation ne vous appartient pas.', + code: "AUTH_REQUIRED", + message: "Cette simulation ne vous appartient pas.", status: 401, + }; + } + + // 2. Charger la consigne (utile au prompt EO). + let sujetConsigne: string | null = null; + if (production.sujet_id) { + const { data: sujetRow } = await supabase + .from("sujets") + .select("consigne") + .eq("id", production.sujet_id) + .single(); + if (sujetRow) { + sujetConsigne = (sujetRow.consigne as string | null) ?? null; } } - let rapport: EORapport + // 3. Lancer correction EO + modèle EO en parallèle. + const correctionPromise = deepseekCorrectEO( + transcript, + tache, + nclcCible, + sujetConsigne, + ); + + const nclcObtenuEstime = nclcCible - 1; + void runModeleJob({ + simulationId, + tache, + sujet: sujetConsigne, + texte: transcript, + nclcObtenu: nclcObtenuEstime, + }); + + let rapport: CorrectionRapport; try { - rapport = await deepseekCorrectEO(transcript, tache) - } catch { + rapport = await correctionPromise; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[correctionController.correctEO] correction failed", { + simulationId, + tache, + nclcCible, + message, + }); return { error: true, - code: 'INTERNAL_ERROR', - message: 'Erreur lors de la correction. Veuillez réessayer dans quelques instants.', + code: "INTERNAL_ERROR", + message: + "Erreur lors de la correction. Veuillez réessayer dans quelques instants.", status: 500, - } + }; } + // 5. Persister le rapport. Pas de *_status (race condition — cf. correctEE). const { error: updateError } = await supabase - .from('productions') + .from("productions") .update({ contenu: transcript, score: rapport.score, nclc: rapport.nclc, + nclc_cible: rapport.nclc_cible, + revelation: rapport.revelation, + diagnostic: rapport.diagnostic, + conseil_nclc: rapport.conseil_nclc, + erreurs_codes: rapport.erreurs_codes, rapport: JSON.stringify(rapport), }) - .eq('id', simulationId) + .eq("id", simulationId); if (updateError) { return { error: true, - code: 'INTERNAL_ERROR', - message: 'Erreur lors de la sauvegarde du rapport. Veuillez réessayer.', + code: "INTERNAL_ERROR", + message: "Erreur lors de la sauvegarde du rapport. Veuillez réessayer.", status: 500, - } + }; } + // 6. Exercices fire-and-forget. + void runExercicesJob({ simulationId, tache, rapport }); + + // 7. Quota. if (PLANS[profile.plan as Plan].simulations_lifetime !== null) { await supabase - .from('profiles') + .from("profiles") .update({ simulations_used: profile.simulations_used + 1 }) - .eq('id', profile.id) + .eq("id", profile.id); } - return { data: { ...rapport, simulation_id: simulationId } } + return { data: { ...rapport, simulation_id: simulationId } }; } diff --git a/src/controllers/presentationController.ts b/src/controllers/presentationController.ts new file mode 100644 index 0000000..0e0f035 --- /dev/null +++ b/src/controllers/presentationController.ts @@ -0,0 +1,178 @@ +/** + * Contrôleur génération de présentation T1 — Sprint 4a. + * + * Génère un texte de présentation personnelle (Tâche 1 EO) à partir des + * 5 réponses fournies par le candidat. Pas de stockage en base (le frontend + * gère la persistance locale pour le MVP). + * + * Paramètres DeepSeek : temperature 0.7, max_tokens 600, timeout 20s. + * Pas de response_format json — on récupère du texte brut. + */ + +const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? ""; +const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; + +export interface PresentationReponses { + prenom_age_ville: string; + formation_metier: string; + situation_familiale: string; + loisirs: string; + motivation_canada: string; +} + +export type PresentationError = { + error: true; + code: string; + message: string; + status: number; +}; + +const REQUIRED_FIELDS: (keyof PresentationReponses)[] = [ + "prenom_age_ville", + "formation_metier", + "situation_familiale", + "loisirs", + "motivation_canada", +]; + +export function validateReponses( + raw: unknown, +): { ok: true; reponses: PresentationReponses } | PresentationError { + if (typeof raw !== "object" || raw === null) { + return { + error: true, + code: "VALIDATION_ERROR", + message: "`reponses` est requis et doit être un objet.", + status: 400, + }; + } + const r = raw as Record; + for (const field of REQUIRED_FIELDS) { + const v = r[field]; + if (typeof v !== "string" || v.trim().length === 0) { + return { + error: true, + code: "VALIDATION_ERROR", + message: `Le champ \`reponses.${field}\` est requis et ne doit pas être vide.`, + status: 400, + }; + } + } + return { + ok: true, + reponses: { + prenom_age_ville: (r.prenom_age_ville as string).trim(), + formation_metier: (r.formation_metier as string).trim(), + situation_familiale: (r.situation_familiale as string).trim(), + loisirs: (r.loisirs as string).trim(), + motivation_canada: (r.motivation_canada as string).trim(), + }, + }; +} + +export function buildPresentationPrompt( + reponses: PresentationReponses, +): string { + return `Tu es un coach TCF Canada spécialisé en Expression Orale. Tu rédiges des textes que le candidat va LIRE À VOIX HAUTE devant un examinateur (entretien dirigé, ~2 minutes). + +Informations à intégrer fidèlement (ne rien inventer) : +- Identité : ${reponses.prenom_age_ville} +- Formation / métier : ${reponses.formation_metier} +- Famille : ${reponses.situation_familiale} +- Loisirs : ${reponses.loisirs} +- Projet Canada : ${reponses.motivation_canada} + +OBJECTIF : produire une présentation personnelle pour la Tâche 1 TCF Canada, longueur cible **220 à 260 mots** (durée réaliste à l'oral, ni trop courte ni trop longue). + +STRUCTURE À RESPECTER (dans cet ordre) : +1) Identité et cadre (qui vous êtes, où vous vivez si pertinent) +2) Formation / parcours professionnel +3) Situation familiale +4) Loisirs ou passions +5) Projet d'immigration au Canada +6) Une **courte** phrase de transition finale vers l'examinateur (ex. proposer de développer un point), **sans** être familière ni utiliser « tu » + +STYLE ORAL (prioritaire) : +- Phrases **courtes à moyennes**, faciles à dire d'un seul souffle ; éviter les phrases alambiquées ou les subordonnées empilées. +- **Enchaînements parlés** : alterner des liens simples (« Ensuite », « Côté famille », « Pour les loisirs », « Concernant mon projet… », « Voilà, en résumé… ») plutôt qu'un style dissertation. +- Vocabulaire **correct mais accessible** ; privilégier les mots usuels. Pas de jargon inutile ni de tournures trop littéraires (« Il convient de », « En outre », « Néanmoins », « Ainsi donc »). +- **Éviter le style écrit** : pas de listes à puces, pas de titres, pas d'introduction type « Je vais vous parler de… en trois parties ». +- **Fluidité à prononcer** : éviter les enchaînements de voyelles ou de consonnes lourdes quand c'est simple à reformuler ; favoriser la respiration naturelle (points, virgules logiques à l'oral). +- Registre **semi-formel** : poli, respectueux, comme face à un examinateur ; pas de slang, pas de tutoiement de l'examinateur, pas d'excès de familiarité. + +Ce qu'il faut éviter : +- Ton académique, catalogué ou « corrigé de dissertation » +- Répétitions mécaniques du même connecteur (ex. « En ce qui concerne » à chaque paragraphe) +- Phrases trop longues ou trop complexes à mémoriser + +Réponds **UNIQUEMENT** avec le texte continu de la présentation (première personne), sans titre, sans guillemets, sans commentaire ni note.`; +} + +export async function generate( + rawReponses: unknown, +): Promise<{ data: { presentation: string } } | PresentationError> { + const validation = validateReponses(rawReponses); + if ("error" in validation) return validation; + + const systemPrompt = buildPresentationPrompt(validation.reponses); + + let response: Response; + try { + response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${DEEPSEEK_API_KEY}`, + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [{ role: "system", content: systemPrompt }], + temperature: 0.7, + max_tokens: 600, + }), + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[presentationController.generate] fetch failed", { + message, + }); + return { + error: true, + code: "INTERNAL_ERROR", + message: + "Impossible de générer la présentation. Veuillez réessayer dans quelques instants.", + status: 500, + }; + } + + if (!response.ok) { + console.error("[presentationController.generate] DeepSeek non-OK", { + status: response.status, + statusText: response.statusText, + }); + return { + error: true, + code: "INTERNAL_ERROR", + message: + "Impossible de générer la présentation. Veuillez réessayer dans quelques instants.", + status: 500, + }; + } + + const data = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const presentation = data.choices?.[0]?.message?.content?.trim(); + + if (!presentation || presentation.length === 0) { + return { + error: true, + code: "INTERNAL_ERROR", + message: "Réponse de génération vide. Veuillez réessayer.", + status: 500, + }; + } + + return { data: { presentation } }; +} diff --git a/src/index.ts b/src/index.ts index 0d4e5ef..6a98ca7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import plansRoutes from "./routes/plans.js"; import simulationsRoutes from "./routes/simulations.js"; import sujetsRoutes from "./routes/sujets.js"; import correctionsRoutes from "./routes/corrections.js"; +import presentationsRoutes from "./routes/presentations.js"; +import transcriptionsRoutes from "./routes/transcriptions.js"; import stripeRoutes from "./routes/stripe.js"; import createT2LiveRoutes from "./routes/t2live.js"; import usersRoutes from "./routes/users.js"; @@ -58,6 +60,8 @@ app.route("/plans", plansRoutes); app.route("/simulations", simulationsRoutes); app.route("/sujets", sujetsRoutes); app.route("/corrections", correctionsRoutes); +app.route("/presentations", presentationsRoutes); +app.route("/transcriptions", transcriptionsRoutes); app.route("/stripe", stripeRoutes); app.route("/t2", createT2LiveRoutes(upgradeWebSocket)); app.route("/users", usersRoutes); diff --git a/src/lib/__tests__/deepseek.test.ts b/src/lib/__tests__/deepseek.test.ts index 55a0aab..d731e5a 100644 --- a/src/lib/__tests__/deepseek.test.ts +++ b/src/lib/__tests__/deepseek.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { CorrectionRapport } from '../deepseek' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { CorrectionRapport } from "../deepseek"; // ── Fixture correction — Sprint 3.6a, forme nouvelle ────────────────── @@ -7,495 +7,621 @@ const VALID_RAPPORT = { score: 14, nclc: 9, revelation: { - croyance: 'Le candidat pense avoir bien respecté la consigne.', - realite: 'Certains éléments de la consigne sont ignorés.', - consequence: 'Perte d\'un point en adéquation à la tâche.', + croyance: "Le candidat pense avoir bien respecté la consigne.", + realite: "Certains éléments de la consigne sont ignorés.", + consequence: "Perte d'un point en adéquation à la tâche.", }, - diagnostic: 'Frein principal : pauvreté du lexique et connecteurs répétés.', + diagnostic: "Frein principal : pauvreté du lexique et connecteurs répétés.", criteres: [ { - nom: 'Adéquation à la tâche et au registre', + nom: "Adéquation à la tâche et au registre", score: 4, - commentaire: 'Tâche globalement respectée.', - exemple: 'Je vous écris pour demander', - suggestion: 'Je sollicite votre attention afin de demander', - astuce: 'Varier les formules d\'appel.', + commentaire: "Tâche globalement respectée.", + exemple: "Je vous écris pour demander", + suggestion: "Je sollicite votre attention afin de demander", + astuce: "Varier les formules d'appel.", }, { - nom: 'Cohérence et cohésion du discours', + nom: "Cohérence et cohésion du discours", score: 3, - commentaire: 'Connecteurs peu variés.', - exemple: 'Et aussi, et puis', - suggestion: 'De plus, par ailleurs', + commentaire: "Connecteurs peu variés.", + exemple: "Et aussi, et puis", + suggestion: "De plus, par ailleurs", astuce: 'Bannir "et" comme connecteur unique.', }, { - nom: 'Compétence lexicale', + nom: "Compétence lexicale", score: 3, - commentaire: 'Vocabulaire basique.', - exemple: 'faire un travail', - suggestion: 'effectuer une mission', + commentaire: "Vocabulaire basique.", + exemple: "faire un travail", + suggestion: "effectuer une mission", astuce: 'Substituer "faire" par un verbe précis.', }, { - nom: 'Compétence grammaticale', + nom: "Compétence grammaticale", score: 4, - commentaire: 'Accords globalement corrects.', - exemple: 'les enfants joue', - suggestion: 'les enfants jouent', - astuce: 'Vérifier la terminaison verbale au pluriel.', + commentaire: "Accords globalement corrects.", + exemple: "les enfants joue", + suggestion: "les enfants jouent", + astuce: "Vérifier la terminaison verbale au pluriel.", }, ], conseil_nclc: { - nclc_cible: 'NCLC 9', - ecart: 'objectif atteint', - action_prioritaire: 'Enrichir le lexique par thématique.', + nclc_cible: "NCLC 9", + ecart: "objectif atteint", + action_prioritaire: "Enrichir le lexique par thématique.", }, erreurs_codes: [ - { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }, - { code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null }, - { code: 'vocabulaire_basique', critere: 'competence_lexicale', description: null }, + { + code: "accord_sujet_verbe", + critere: "competence_grammaticale", + description: null, + }, + { + code: "connecteurs_repetes", + critere: "coherence_cohesion", + description: null, + }, + { + code: "vocabulaire_basique", + critere: "competence_lexicale", + description: null, + }, ], -} satisfies Omit & { erreurs_codes: unknown[] } +} satisfies Omit & { + erreurs_codes: unknown[]; +}; function mockFetchSuccess(payload: unknown) { vi.stubGlobal( - 'fetch', + "fetch", vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ choices: [{ message: { content: JSON.stringify(payload) } }] }), + json: async () => ({ + choices: [{ message: { content: JSON.stringify(payload) } }], + }), }), - ) + ); } // ── correctEE (nouvelle signature) ────────────────────────────────────── -describe('deepseek.correctEE — Sprint 3.6a', () => { +describe("deepseek.correctEE — Sprint 3.6a", () => { beforeEach(() => { - vi.resetModules() - vi.restoreAllMocks() - }) + vi.resetModules(); + vi.restoreAllMocks(); + }); - it('retourne un rapport avec la nouvelle structure (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)', async () => { - mockFetchSuccess(VALID_RAPPORT) - const { correctEE } = await import('../deepseek') + it("retourne un rapport avec la nouvelle structure (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)", async () => { + mockFetchSuccess(VALID_RAPPORT); + const { correctEE } = await import("../deepseek"); const rapport = await correctEE({ - tache: 'EE_T1', - contenu: 'Mon texte de test', - sujet: 'Écrivez un message', + tache: "EE_T1", + contenu: "Mon texte de test", + sujet: "Écrivez un message", nclcCible: 9, - }) + }); - expect(rapport.score).toBe(14) - expect(rapport.nclc).toBe(9) - expect(rapport.nclc_cible).toBe(9) + expect(rapport.score).toBe(14); + expect(rapport.nclc).toBe(9); + expect(rapport.nclc_cible).toBe(9); expect(rapport.revelation).toMatchObject({ croyance: expect.any(String), realite: expect.any(String), consequence: expect.any(String), - }) - expect(rapport.diagnostic).toBeTypeOf('string') - expect(rapport.criteres).toHaveLength(4) - expect(rapport.conseil_nclc.nclc_cible).toBe('NCLC 9') - expect(rapport.erreurs_codes.length).toBeGreaterThan(0) - }) + }); + expect(rapport.diagnostic).toBeTypeOf("string"); + expect(rapport.criteres).toHaveLength(4); + expect(rapport.conseil_nclc.nclc_cible).toBe("NCLC 9"); + expect(rapport.erreurs_codes.length).toBeGreaterThan(0); + }); - it('nclc_cible=10 est propagé dans le rapport', async () => { - mockFetchSuccess({ ...VALID_RAPPORT, score: 18 }) - const { correctEE } = await import('../deepseek') + it("nclc_cible=10 est propagé dans le rapport", async () => { + mockFetchSuccess({ ...VALID_RAPPORT, score: 18 }); + const { correctEE } = await import("../deepseek"); const rapport = await correctEE({ - tache: 'EE_T1', - contenu: 'Texte', + tache: "EE_T1", + contenu: "Texte", sujet: null, nclcCible: 10, - }) + }); - expect(rapport.nclc_cible).toBe(10) - }) + expect(rapport.nclc_cible).toBe(10); + }); - it('score hors bornes → throw', async () => { - mockFetchSuccess({ ...VALID_RAPPORT, score: 25 }) - const { correctEE } = await import('../deepseek') + it("score hors bornes → throw", async () => { + mockFetchSuccess({ ...VALID_RAPPORT, score: 25 }); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('Score invalide') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("Score invalide"); + }); - it('nclc hors bornes → throw', async () => { - mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 }) - const { correctEE } = await import('../deepseek') + it("nclc hors bornes → throw", async () => { + mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 }); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('NCLC invalide') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("NCLC invalide"); + }); - it('revelation absente → throw', async () => { - const bad = { ...VALID_RAPPORT, revelation: undefined } - mockFetchSuccess(bad) - const { correctEE } = await import('../deepseek') + it("revelation absente → throw", async () => { + const bad = { ...VALID_RAPPORT, revelation: undefined }; + mockFetchSuccess(bad); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('revelation invalide') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("revelation invalide"); + }); - it('diagnostic vide → throw', async () => { - mockFetchSuccess({ ...VALID_RAPPORT, diagnostic: ' ' }) - const { correctEE } = await import('../deepseek') + it("diagnostic vide → throw", async () => { + mockFetchSuccess({ ...VALID_RAPPORT, diagnostic: " " }); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('diagnostic invalide') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("diagnostic invalide"); + }); - it('criteres doit avoir exactement 4 entrées', async () => { - mockFetchSuccess({ ...VALID_RAPPORT, criteres: VALID_RAPPORT.criteres.slice(0, 3) }) - const { correctEE } = await import('../deepseek') + it("criteres doit avoir exactement 4 entrées", async () => { + mockFetchSuccess({ + ...VALID_RAPPORT, + criteres: VALID_RAPPORT.criteres.slice(0, 3), + }); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('criteres invalide') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("criteres invalide"); + }); - it('erreurs_codes : codes hors taxonomie sont filtrés', async () => { + it("erreurs_codes : codes hors taxonomie sont filtrés", async () => { const bad = { ...VALID_RAPPORT, erreurs_codes: [ - { code: 'code_inexistant_xyz', critere: 'competence_grammaticale', description: null }, - { code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }, + { + code: "code_inexistant_xyz", + critere: "competence_grammaticale", + description: null, + }, + { + code: "accord_sujet_verbe", + critere: "competence_grammaticale", + description: null, + }, ], - } - mockFetchSuccess(bad) - const { correctEE } = await import('../deepseek') + }; + mockFetchSuccess(bad); + const { correctEE } = await import("../deepseek"); const rapport = await correctEE({ - tache: 'EE_T1', - contenu: 'T', + tache: "EE_T1", + contenu: "T", sujet: null, nclcCible: 9, - }) - expect(rapport.erreurs_codes).toHaveLength(1) - expect(rapport.erreurs_codes[0]?.code).toBe('accord_sujet_verbe') - }) + }); + expect(rapport.erreurs_codes).toHaveLength(1); + expect(rapport.erreurs_codes[0]?.code).toBe("accord_sujet_verbe"); + }); it('erreurs_codes : code "autre" sans description est rejeté', async () => { const bad = { ...VALID_RAPPORT, erreurs_codes: [ - { code: 'autre', critere: 'coherence_cohesion', description: null }, - { code: 'autre', critere: 'coherence_cohesion', description: 'erreur spécifique' }, + { code: "autre", critere: "coherence_cohesion", description: null }, + { + code: "autre", + critere: "coherence_cohesion", + description: "erreur spécifique", + }, ], - } - mockFetchSuccess(bad) - const { correctEE } = await import('../deepseek') + }; + mockFetchSuccess(bad); + const { correctEE } = await import("../deepseek"); const rapport = await correctEE({ - tache: 'EE_T1', - contenu: 'T', + tache: "EE_T1", + contenu: "T", sujet: null, nclcCible: 9, - }) - expect(rapport.erreurs_codes).toHaveLength(1) + }); + expect(rapport.erreurs_codes).toHaveLength(1); expect(rapport.erreurs_codes[0]).toMatchObject({ - code: 'autre', - description: 'erreur spécifique', - }) - }) + code: "autre", + description: "erreur spécifique", + }); + }); - it('critère inconnu → entrée filtrée', async () => { + it("critère inconnu → entrée filtrée", async () => { const bad = { ...VALID_RAPPORT, erreurs_codes: [ - { code: 'accord_sujet_verbe', critere: 'critere_inventé', description: null }, + { + code: "accord_sujet_verbe", + critere: "critere_inventé", + description: null, + }, ], - } - mockFetchSuccess(bad) - const { correctEE } = await import('../deepseek') + }; + mockFetchSuccess(bad); + const { correctEE } = await import("../deepseek"); const rapport = await correctEE({ - tache: 'EE_T1', - contenu: 'T', + tache: "EE_T1", + contenu: "T", sujet: null, nclcCible: 9, - }) - expect(rapport.erreurs_codes).toHaveLength(0) - }) + }); + expect(rapport.erreurs_codes).toHaveLength(0); + }); - it('erreur HTTP DeepSeek → throw', async () => { + it("erreur HTTP DeepSeek → throw", async () => { vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal' }), - ) - const { correctEE } = await import('../deepseek') + "fetch", + vi + .fn() + .mockResolvedValue({ ok: false, status: 500, statusText: "Internal" }), + ); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow('DeepSeek API error') - }) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow("DeepSeek API error"); + }); - it('JSON invalide → throw', async () => { + it("JSON invalide → throw", async () => { vi.stubGlobal( - 'fetch', + "fetch", vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ choices: [{ message: { content: 'pas du json' } }] }), + json: async () => ({ + choices: [{ message: { content: "pas du json" } }], + }), }), - ) - const { correctEE } = await import('../deepseek') + ); + const { correctEE } = await import("../deepseek"); await expect( - correctEE({ tache: 'EE_T1', contenu: 'T', sujet: null, nclcCible: 9 }), - ).rejects.toThrow() - }) -}) + correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }), + ).rejects.toThrow(); + }); +}); // ── generateProductionModele — cible fixe NCLC 9 ─────────────────────── const VALID_MODELE = { - production_modele_propre: 'Texte modèle réécrit. '.repeat(10).trim(), + production_modele_propre: "Texte modèle réécrit. ".repeat(10).trim(), notes_pedagogiques: [ - { passage: 'extrait 1', explication: 'efficace car…' }, - { passage: 'extrait 2', explication: 'efficace car…' }, - { passage: 'extrait 3', explication: 'efficace car…' }, + { passage: "extrait 1", explication: "efficace car…" }, + { passage: "extrait 2", explication: "efficace car…" }, + { passage: "extrait 3", explication: "efficace car…" }, ], transformations: [ - { original: 'je fais', ameliore: 'j\'effectue', explication: 'plus précis' }, + { original: "je fais", ameliore: "j'effectue", explication: "plus précis" }, ], - message: 'Vos idées sont solides, continuez.', -} + message: "Vos idées sont solides, continuez.", +}; -describe('deepseek.generateProductionModele', () => { +describe("deepseek.generateProductionModele", () => { beforeEach(() => { - vi.resetModules() - vi.restoreAllMocks() - }) + vi.resetModules(); + vi.restoreAllMocks(); + }); - it('renvoie métadonnées avec nclc_modele=9 (fixe)', async () => { - mockFetchSuccess(VALID_MODELE) - const { generateProductionModele } = await import('../deepseek') + it("renvoie métadonnées avec nclc_modele=9 (fixe)", async () => { + mockFetchSuccess(VALID_MODELE); + const { generateProductionModele } = await import("../deepseek"); const result = await generateProductionModele({ - tache: 'EE_T2', - sujet: 'Un article de blog', - texte: 'production du candidat', + tache: "EE_T2", + sujet: "Un article de blog", + texte: "production du candidat", nclcObtenu: 7, - }) + }); - expect(result.nclc_modele).toBe(9) - expect(result.nclc_obtenu).toBe(7) - expect(result.score_cible).toBe(14) - expect(result.tcf_word_min).toBe(120) - expect(result.tcf_word_max).toBe(150) - }) + expect(result.nclc_modele).toBe(9); + expect(result.nclc_obtenu).toBe(7); + expect(result.score_cible).toBe(14); + expect(result.tcf_word_min).toBe(120); + expect(result.tcf_word_max).toBe(150); + }); - it('tronque à max mots et renseigne tcf_truncated=true', async () => { - const longText = 'mot '.repeat(200).trim() // 200 mots - mockFetchSuccess({ ...VALID_MODELE, production_modele_propre: longText }) - const { generateProductionModele } = await import('../deepseek') + it("tronque à max mots et renseigne tcf_truncated=true", async () => { + const longText = "mot ".repeat(200).trim(); // 200 mots + mockFetchSuccess({ ...VALID_MODELE, production_modele_propre: longText }); + const { generateProductionModele } = await import("../deepseek"); const result = await generateProductionModele({ - tache: 'EE_T1', // max 120 + tache: "EE_T1", // max 120 sujet: null, - texte: 'production', + texte: "production", nclcObtenu: 8, - }) + }); - expect(result.tcf_truncated).toBe(true) - expect(result.tcf_word_count).toBe(120) - }) + expect(result.tcf_truncated).toBe(true); + expect(result.tcf_word_count).toBe(120); + }); - it('supprime les annotations [NOTE: ...] de production_modele_propre', async () => { + it("supprime les annotations [NOTE: ...] de production_modele_propre", async () => { mockFetchSuccess({ ...VALID_MODELE, - production_modele_propre: 'Bonjour [NOTE: salutation formelle] je vous écris.', - }) - const { generateProductionModele } = await import('../deepseek') + production_modele_propre: + "Bonjour [NOTE: salutation formelle] je vous écris.", + }); + const { generateProductionModele } = await import("../deepseek"); const result = await generateProductionModele({ - tache: 'EE_T1', + tache: "EE_T1", sujet: null, - texte: 'p', + texte: "p", nclcObtenu: 8, - }) + }); - expect(result.production_modele_propre).not.toContain('[NOTE:') - expect(result.production_modele_propre).toContain('Bonjour') - }) -}) + expect(result.production_modele_propre).not.toContain("[NOTE:"); + expect(result.production_modele_propre).toContain("Bonjour"); + }); +}); // ── generateExercices ─────────────────────────────────────────────────── -describe('deepseek.generateExercices', () => { +describe("deepseek.generateExercices", () => { beforeEach(() => { - vi.resetModules() - vi.restoreAllMocks() - }) + vi.resetModules(); + vi.restoreAllMocks(); + }); - it('renvoie une liste d\'exercices avec le format attendu', async () => { + it("renvoie une liste d'exercices avec le format attendu", async () => { mockFetchSuccess({ exercices: [ { - difficulte: 'facile', - theme: 'accord_sujet_verbe', - diagnostic: 'Erreurs d\'accord verbe-sujet.', - consigne: 'Corrigez les accords.', - extrait: 'les enfants joue', - indice: 'Pluriel du sujet ?', - correction: 'les enfants jouent', - explication: 'Le verbe s\'accorde en nombre avec le sujet.', + difficulte: "facile", + theme: "accord_sujet_verbe", + diagnostic: "Erreurs d'accord verbe-sujet.", + consigne: "Corrigez les accords.", + extrait: "les enfants joue", + indice: "Pluriel du sujet ?", + correction: "les enfants jouent", + explication: "Le verbe s'accorde en nombre avec le sujet.", }, { - difficulte: 'intermediaire', - theme: 'connecteurs_repetes', - diagnostic: 'Même connecteur répété.', - consigne: 'Variez les connecteurs.', - extrait: 'Et puis et aussi', + difficulte: "intermediaire", + theme: "connecteurs_repetes", + diagnostic: "Même connecteur répété.", + consigne: "Variez les connecteurs.", + extrait: "Et puis et aussi", indice: 'Synonymes de "et" ?', - correction: 'De plus, par ailleurs', - explication: 'Varier lexicalement les connecteurs améliore la cohésion.', + correction: "De plus, par ailleurs", + explication: + "Varier lexicalement les connecteurs améliore la cohésion.", }, { - difficulte: 'difficile', - theme: 'vocabulaire_basique', + difficulte: "difficile", + theme: "vocabulaire_basique", diagnostic: 'Verbe "faire" imprécis.', consigne: 'Remplacez "faire" par un verbe précis.', - extrait: 'faire un travail', - indice: 'Un verbe de réalisation ?', - correction: 'effectuer une mission', + extrait: "faire un travail", + indice: "Un verbe de réalisation ?", + correction: "effectuer une mission", explication: '"Effectuer" précise l\'action.', }, ], - }) - const { generateExercices } = await import('../deepseek') + }); + const { generateExercices } = await import("../deepseek"); const exercices = await generateExercices({ - tache: 'EE_T1', + tache: "EE_T1", erreursCodes: VALID_RAPPORT.erreurs_codes as never, criteres: VALID_RAPPORT.criteres, - }) + }); - expect(exercices).toHaveLength(3) + expect(exercices).toHaveLength(3); expect(exercices[0]).toMatchObject({ - difficulte: 'facile', - theme: 'accord_sujet_verbe', + difficulte: "facile", + theme: "accord_sujet_verbe", consigne: expect.any(String), correction: expect.any(String), - }) - }) + }); + }); it('difficulte inconnue → fallback "intermediaire"', async () => { mockFetchSuccess({ exercices: [ { - difficulte: 'epique', - theme: 't', - consigne: 'c', - correction: 'r', + difficulte: "epique", + theme: "t", + consigne: "c", + correction: "r", }, ], - }) - const { generateExercices } = await import('../deepseek') + }); + const { generateExercices } = await import("../deepseek"); const exercices = await generateExercices({ - tache: 'EE_T1', + tache: "EE_T1", erreursCodes: [], criteres: [], - }) + }); - expect(exercices[0]?.difficulte).toBe('intermediaire') - }) + expect(exercices[0]?.difficulte).toBe("intermediaire"); + }); - it('exercices sans consigne/correction sont filtrés', async () => { + it("exercices sans consigne/correction sont filtrés", async () => { mockFetchSuccess({ exercices: [ - { difficulte: 'facile', theme: 't' }, // manque consigne + correction - { difficulte: 'facile', theme: 't', consigne: 'c', correction: 'r' }, + { difficulte: "facile", theme: "t" }, // manque consigne + correction + { difficulte: "facile", theme: "t", consigne: "c", correction: "r" }, ], - }) - const { generateExercices } = await import('../deepseek') + }); + const { generateExercices } = await import("../deepseek"); const exercices = await generateExercices({ - tache: 'EE_T1', + tache: "EE_T1", erreursCodes: [], criteres: [], - }) + }); - expect(exercices).toHaveLength(1) - }) -}) + expect(exercices).toHaveLength(1); + }); +}); -// ── EO — inchangé par Sprint 3.6a ────────────────────────────────────── +// ── EO — Sprint 4a : aligné sur le format 3.6a ───────────────────────── const VALID_RAPPORT_EO = { - score: 12, - nclc: 7, - feedback_court: - 'Bonne production générale. Quelques points à améliorer sur le lexique et la morphosyntaxe.', + score: 14, + nclc: 9, + revelation: { + croyance: "Le candidat pense parler avec fluidité.", + realite: "Le discours présente plusieurs ruptures et hésitations marquées.", + consequence: "Perte d'un point en cohérence et fluidité.", + }, + diagnostic: "Frein principal : ruptures discursives et lexique répétitif.", + transcription_affichee: + "Bonjour, je vais me présenter. Je m'appelle Pierre. Je travaille comme ingénieur.", criteres: [ - { nom: 'Coherence et cohesion', score: 4, commentaire: 'Discours structure.' }, - { nom: 'Lexique', score: 4, commentaire: 'Vocabulaire varie.' }, - { nom: 'Morphosyntaxe', score: 4, commentaire: 'Syntaxe correcte.' }, - { nom: 'Phonologie', score: 0, commentaire: 'Non evalue sur transcription textuelle.' }, + { + nom: "Réalisation de la tâche", + score: 4, + commentaire: "Tâche globalement respectée.", + exemple: "Je vais me présenter", + suggestion: "Permettez-moi de me présenter", + astuce: "Soigner les ouvertures.", + }, + { + nom: "Cohérence et fluidité", + score: 3, + commentaire: "Ruptures fréquentes.", + exemple: "euh euh", + suggestion: "Marquer une pause silencieuse", + astuce: "Limiter les hésitations vocalisées.", + }, + { + nom: "Étendue du lexique", + score: 3, + commentaire: "Vocabulaire basique.", + exemple: "mon travail", + suggestion: "mon métier / ma profession", + astuce: "Varier les mots du même champ.", + }, + { + nom: "Maîtrise grammaticale orale", + score: 4, + commentaire: "Accords globalement corrects.", + exemple: "les gens travaille", + suggestion: "les gens travaillent", + astuce: "Vérifier la terminaison verbale au pluriel.", + }, ], - erreurs: ['Hesitations frequentes', 'Registre parfois familier'], - modele: 'Transcription corrigee ici.', - idees: ['Structurer les reponses', 'Enrichir le vocabulaire'], - exercices: ['Exercice fluidite orale', 'Exercice registre formel'], -} + conseil_nclc: { + nclc_cible: "NCLC 9", + ecart: "objectif atteint", + action_prioritaire: + "Réduire les hésitations en préparant un fil narratif court.", + }, + erreurs_codes: [ + { + code: "connecteurs_repetes", + critere: "coherence_cohesion", + description: null, + }, + { + code: "vocabulaire_basique", + critere: "competence_lexicale", + description: null, + }, + ], +}; -describe('deepseek.correctEO', () => { +describe("deepseek.correctEO", () => { beforeEach(() => { - vi.resetModules() - vi.restoreAllMocks() - }) + vi.resetModules(); + vi.restoreAllMocks(); + }); - it('retourne un rapport EO avec la structure V1', async () => { - mockFetchSuccess(VALID_RAPPORT_EO) - const { correctEO } = await import('../deepseek') - const rapport = await correctEO('transcription', 'EO_T1') + it("retourne un rapport EO aligné sur CorrectionRapport (3.6a) + champs EO", async () => { + mockFetchSuccess(VALID_RAPPORT_EO); + const { correctEO } = await import("../deepseek"); + const rapport = await correctEO("transcription brute", "EO_T1", 9); - expect(rapport).toHaveProperty('feedback_court') - expect(rapport.criteres).toHaveLength(4) - expect(rapport.criteres.find((c) => c.nom === 'Phonologie')?.score).toBe(0) - }) + expect(rapport.score).toBe(14); + expect(rapport.nclc_cible).toBe(9); + 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.", + ); + expect(rapport.erreurs_codes.length).toBeGreaterThan(0); + }); - it('score hors bornes → throw', async () => { - mockFetchSuccess({ ...VALID_RAPPORT_EO, score: 25 }) - const { correctEO } = await import('../deepseek') - await expect(correctEO('t', 'EO_T1')).rejects.toThrow('Score invalide') - }) + 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é. + 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[2], score: 3 }, + { ...VALID_RAPPORT_EO.criteres[3], score: 4 }, + ], + }); + const { correctEO } = await import("../deepseek"); + const rapport = await correctEO("t", "EO_T1", 9); - it('nclc hors bornes → throw', async () => { - mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 }) - const { correctEO } = await import('../deepseek') - await expect(correctEO('t', 'EO_T1')).rejects.toThrow('NCLC invalide') - }) + expect(rapport.criteres.every((c) => c.score <= 5)).toBe(true); + // 5 (cappé) + 5 + 3 + 4 = 17 (et non 99) + expect(rapport.score).toBe(17); + }); - it('HTTP error → throw', async () => { + it("transcription_affichee absente → fallback sur le transcript brut", async () => { + const { transcription_affichee, ...withoutTranscription } = + VALID_RAPPORT_EO; + void transcription_affichee; + mockFetchSuccess(withoutTranscription); + const { correctEO } = await import("../deepseek"); + const rapport = await correctEO("TRANSCRIPT BRUT FALLBACK", "EO_T1", 9); + + expect(rapport.transcription_affichee).toBe("TRANSCRIPT BRUT FALLBACK"); + }); + + it("nclc hors bornes → throw", async () => { + mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 }); + const { correctEO } = await import("../deepseek"); + await expect(correctEO("t", "EO_T1", 9)).rejects.toThrow("NCLC invalide"); + }); + + it("HTTP error → throw", async () => { vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'I' }), - ) - const { correctEO } = await import('../deepseek') - await expect(correctEO('t', 'EO_T1')).rejects.toThrow('DeepSeek API error') - }) -}) + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "I" }), + ); + const { correctEO } = await import("../deepseek"); + await expect(correctEO("t", "EO_T1", 9)).rejects.toThrow( + "DeepSeek API error", + ); + }); +}); // ── Post-traitement unitaire ──────────────────────────────────────────── -describe('deepseek — helpers de post-traitement', () => { - it('wordCountTCF : apostrophes et tirets ne créent pas de mot', async () => { - const { wordCountTCF } = await import('../deepseek') - expect(wordCountTCF("c'est")).toBe(1) - expect(wordCountTCF("aujourd'hui")).toBe(1) - expect(wordCountTCF("c'est-à-dire")).toBe(1) - expect(wordCountTCF('il va bien')).toBe(3) - expect(wordCountTCF('')).toBe(0) - }) +describe("deepseek — helpers de post-traitement", () => { + it("wordCountTCF : apostrophes et tirets ne créent pas de mot", async () => { + const { wordCountTCF } = await import("../deepseek"); + expect(wordCountTCF("c'est")).toBe(1); + expect(wordCountTCF("aujourd'hui")).toBe(1); + expect(wordCountTCF("c'est-à-dire")).toBe(1); + expect(wordCountTCF("il va bien")).toBe(3); + expect(wordCountTCF("")).toBe(0); + }); - it('stripModelAnnotations retire [NOTE:…]', async () => { - const { stripModelAnnotations } = await import('../deepseek') - expect(stripModelAnnotations('Bonjour [NOTE: formel] Madame')).toBe('Bonjour Madame') - }) + it("stripModelAnnotations retire [NOTE:…]", async () => { + const { stripModelAnnotations } = await import("../deepseek"); + expect(stripModelAnnotations("Bonjour [NOTE: formel] Madame")).toBe( + "Bonjour Madame", + ); + }); - it('truncateToMaxWords tronque au-delà du seuil', async () => { - const { truncateToMaxWords } = await import('../deepseek') - const { text, truncated } = truncateToMaxWords('a b c d e f', 3) - expect(text).toBe('a b c') - expect(truncated).toBe(true) - }) -}) + it("truncateToMaxWords tronque au-delà du seuil", async () => { + const { truncateToMaxWords } = await import("../deepseek"); + const { text, truncated } = truncateToMaxWords("a b c d e f", 3); + expect(text).toBe("a b c"); + expect(truncated).toBe(true); + }); +}); diff --git a/src/lib/deepgram.ts b/src/lib/deepgram.ts new file mode 100644 index 0000000..be6fb34 --- /dev/null +++ b/src/lib/deepgram.ts @@ -0,0 +1,53 @@ +/** + * Client Deepgram — Sprint 4b. + * + * Génère un token éphémère que le frontend utilise pour ouvrir une connexion + * directe à Deepgram (transcription live). Le token a une durée de vie courte + * (par défaut 600 s) et n'expose pas la clé maître `DEEPGRAM_API_KEY`. + * + * Endpoint : POST https://api.deepgram.com/v1/auth/grant + * Doc : https://developers.deepgram.com/docs/create-temporary-api-key + */ + +const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY ?? ""; +const DEEPGRAM_BASE_URL = "https://api.deepgram.com"; +const DEEPGRAM_TIMEOUT_MS = 10_000; + +export interface DeepgramToken { + token: string; + expires_in: number; +} + +export async function createTemporaryToken( + ttlSeconds: number, +): Promise { + const response = await fetch(`${DEEPGRAM_BASE_URL}/v1/auth/grant`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Token ${DEEPGRAM_API_KEY}`, + }, + body: JSON.stringify({ ttl_seconds: ttlSeconds }), + signal: AbortSignal.timeout(DEEPGRAM_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error( + `Deepgram API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + access_token?: string; + expires_in?: number; + }; + + if (typeof data.access_token !== "string" || data.access_token.length === 0) { + throw new Error("Deepgram API: access_token manquant dans la réponse"); + } + + const expiresIn = + typeof data.expires_in === "number" ? data.expires_in : ttlSeconds; + + return { token: data.access_token, expires_in: expiresIn }; +} diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index faeb616..f7341a1 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -21,124 +21,153 @@ import { isValidCode, isValidCritere, type Critere, -} from './taxonomieErreurs.js' +} from "./taxonomieErreurs.js"; -const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? '' -const DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? ""; +const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; // ── Types — Sprint 3.6a ────────────────────────────────────────────────── -export type TacheEE = 'EE_T1' | 'EE_T2' | 'EE_T3' -export type NclcCible = 9 | 10 +export type TacheEE = "EE_T1" | "EE_T2" | "EE_T3"; +export type TacheEO = "EO_T1" | "EO_T3"; +export type TacheCorrection = TacheEE | TacheEO; +export type NclcCible = 9 | 10; export interface CorrectionInput { - tache: TacheEE - contenu: string - sujet: string | null - sourceDoc1?: string | null - sourceDoc2?: string | null - nclcCible: NclcCible + tache: TacheEE; + contenu: string; + sujet: string | null; + sourceDoc1?: string | null; + sourceDoc2?: string | null; + nclcCible: NclcCible; } export interface CorrectionCritereDetail { - nom: string - score: number - commentaire: string - exemple: string - suggestion: string - astuce: string + nom: string; + score: number; + commentaire: string; + exemple: string; + suggestion: string; + astuce: string; } export interface ErreurCode { - code: string - critere: Critere - description: string | null + code: string; + critere: Critere; + description: string | null; } export interface CorrectionRapport { - score: number - nclc: number - nclc_cible: NclcCible + score: number; + nclc: number; + nclc_cible: NclcCible; revelation: { - croyance: string - realite: string - consequence: string - } - diagnostic: string - criteres: CorrectionCritereDetail[] + croyance: string; + realite: string; + consequence: string; + }; + diagnostic: string; + criteres: CorrectionCritereDetail[]; conseil_nclc: { - nclc_cible: string - ecart: string - action_prioritaire: string - } - erreurs_codes: ErreurCode[] + nclc_cible: string; + ecart: string; + action_prioritaire: string; + }; + erreurs_codes: ErreurCode[]; + // Sprint 4a — champs EO uniquement (présents si tache ∈ EO_T*). + transcription_affichee?: string; + note_phonologie?: string; } +/** + * Sprint 4a — Labels officiels TCF Canada pour les 4 critères Expression Orale. + * Distincts des labels EE bien que la taxonomie d'erreurs sous-jacente reste + * identique (mappage via le champ `critere` interne adequation_tache, etc.). + */ +export const CRITERE_LABELS_EO: Record = { + 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", +}; + +const EO_NOTE_PHONOLOGIE_DEFAULT = + "Analyse phonologique non disponible pour cette session."; + export interface ProductionModeleInput { - tache: TacheEE - sujet: string | null - texte: string - nclcObtenu: number + tache: TacheCorrection; + sujet: string | null; + texte: string; + nclcObtenu: number; } export interface TransformationItem { - original: string - ameliore: string - explication: string + original: string; + ameliore: string; + explication: string; } export interface NotePedagogique { - passage: string - explication: string + passage: string; + explication: string; } export interface ProductionModele { - production_modele_propre: string - notes_pedagogiques: NotePedagogique[] - transformations: TransformationItem[] - message: string + production_modele_propre: string; + notes_pedagogiques: NotePedagogique[]; + transformations: TransformationItem[]; + message: string; // Métadonnées ajoutées par le post-traitement serveur - nclc_modele: 9 - nclc_obtenu: number - score_cible: number - tcf_word_count: number - tcf_word_min: number - tcf_word_max: number - tcf_truncated: boolean + nclc_modele: 9; + nclc_obtenu: number; + score_cible: number; + tcf_word_count: number; + tcf_word_min: number; + tcf_word_max: number; + tcf_truncated: boolean; } export interface ExercicesInput { - tache: TacheEE - erreursCodes: ErreurCode[] - criteres: CorrectionCritereDetail[] + tache: TacheCorrection; + erreursCodes: ErreurCode[]; + criteres: CorrectionCritereDetail[]; } export interface ExerciceItem { - difficulte: 'facile' | 'intermediaire' | 'difficile' - theme: string - diagnostic: string - consigne: string - extrait: string - indice: string - correction: string - explication: string + difficulte: "facile" | "intermediaire" | "difficile"; + theme: string; + diagnostic: string; + consigne: string; + extrait: string; + indice: string; + correction: string; + explication: string; } -// Longueurs TCF Canada par tâche (docs/Prompt_production_modèle.md §LONGUEUR) -const WORD_LIMITS: Record = { +// Longueurs TCF Canada par tâche. +// EE : docs/Prompt_production_modèle.md §LONGUEUR. +// EO : équivalent transcript pour un monologue fluide aux durées TCF +// (T1 ~3 min, T3 ~5 min) — sert au gabarit de la production modèle EO. +const WORD_LIMITS: Record = { EE_T1: { min: 60, max: 120 }, EE_T2: { min: 120, max: 150 }, EE_T3: { min: 120, max: 180 }, -} + EO_T1: { min: 200, max: 300 }, + EO_T3: { min: 450, max: 620 }, +}; -const TASK_DESCRIPTIONS: Record = { +const TASK_DESCRIPTIONS: Record = { EE_T1: - 'Tâche 1 — Message / mail / annonce (60-120 mots) : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne.', + "Tâche 1 — Message / mail / annonce (60-120 mots) : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne.", EE_T2: - 'Tâche 2 — Article de blog / forum (120-150 mots) : compte rendu d\'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif.', + "Tâche 2 — Article de blog / forum (120-150 mots) : compte rendu d'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif.", EE_T3: - 'Tâche 3 — Texte comparatif (120-180 mots) : Partie 1 (40-60 mots) synthèse des deux points de vue des documents sources ; Partie 2 (80-120 mots) prise de position personnelle argumentée.', -} + "Tâche 3 — Texte comparatif (120-180 mots) : Partie 1 (40-60 mots) synthèse des deux points de vue des documents sources ; Partie 2 (80-120 mots) prise de position personnelle argumentée.", + EO_T1: + "T1 — Présentation personnelle (entretien dirigé, 2 minutes) : se présenter, parler de son parcours, de ses projets, de sa motivation. Registre courant, discours fluide et structuré.", + EO_T3: + "T3 — Expression d'un point de vue spontané (4 minutes 30) : exprimer et défendre un point de vue sur une question, illustrer par des exemples concrets, organiser l'argumentation, conclure. Registre courant à standard.", +}; // ── Prompts builders ───────────────────────────────────────────────────── @@ -147,13 +176,13 @@ const TASK_DESCRIPTIONS: Record = { * Retourne le couple (system, user) à envoyer à DeepSeek. */ export function buildCorrectionPrompt(input: CorrectionInput): { - system: string - user: string + system: string; + user: string; } { - const { tache, contenu, sujet, sourceDoc1, sourceDoc2, nclcCible } = input - const minScore = NCLC_MIN_SCORE[nclcCible] + const { tache, contenu, sujet, sourceDoc1, sourceDoc2, nclcCible } = input; + const minScore = NCLC_MIN_SCORE[nclcCible]; - const taxonomySection = buildTaxonomyPromptSection() + const taxonomySection = buildTaxonomyPromptSection(); const system = `Tu es un correcteur TCF Canada certifié par France Éducation International. Tu corriges avec précision et bienveillance. @@ -196,40 +225,40 @@ FORMAT DE RÉPONSE (JSON strict, aucun autre texte) : "erreurs_codes": [ { "code": "", "critere": "", "description": } ] -}` +}`; const docsBlock = - tache === 'EE_T3' && (sourceDoc1 || sourceDoc2) + tache === "EE_T3" && (sourceDoc1 || sourceDoc2) ? `\n\nDOCUMENTS SOURCES : -Document 1 (point de vue POUR) : ${sourceDoc1 ?? 'Non précisé'} -Document 2 (point de vue CONTRE) : ${sourceDoc2 ?? 'Non précisé'}` - : '' +Document 1 (point de vue POUR) : ${sourceDoc1 ?? "Non précisé"} +Document 2 (point de vue CONTRE) : ${sourceDoc2 ?? "Non précisé"}` + : ""; const user = `OBJECTIF DU CANDIDAT : NCLC ${nclcCible} — score minimum requis : ${minScore}/20. TÂCHE : ${TASK_DESCRIPTIONS[tache]}${docsBlock} -CONSIGNE / SUJET : ${sujet ?? 'Non précisé'} +CONSIGNE / SUJET : ${sujet ?? "Non précisé"} PRODUCTION DU CANDIDAT : """ ${contenu} -"""` +"""`; - return { system, user } + return { system, user }; } /** * Prompt production modèle — cible fixe NCLC 9 (cf. consigne Sprint 3.6a). */ export function buildModelPrompt(input: ProductionModeleInput): { - system: string - user: string + system: string; + user: string; } { - const { tache, sujet, texte, nclcObtenu } = input - const nclcModele: 9 = 9 - const scoreCible = NCLC_MIN_SCORE[nclcModele] - const { min, max } = WORD_LIMITS[tache] + const { tache, sujet, texte, nclcObtenu } = input; + const nclcModele: 9 = 9; + const scoreCible = NCLC_MIN_SCORE[nclcModele]; + const { min, max } = WORD_LIMITS[tache]; const system = `Tu es un correcteur expert TCF Canada. @@ -263,18 +292,18 @@ FORMAT JSON (strict) : { "original": "", "ameliore": "", "explication": "" } ], "message": "" -}` +}`; - const user = `SUJET : ${sujet ?? 'Non précisé'} + const user = `SUJET : ${sujet ?? "Non précisé"} TÂCHE : ${TASK_DESCRIPTIONS[tache]} PRODUCTION DU CANDIDAT : ${texte} -Le candidat a obtenu NCLC ${nclcObtenu}. Montre-lui comment atteindre NCLC ${nclcModele}.` +Le candidat a obtenu NCLC ${nclcObtenu}. Montre-lui comment atteindre NCLC ${nclcModele}.`; - return { system, user } + return { system, user }; } /** @@ -282,10 +311,10 @@ Le candidat a obtenu NCLC ${nclcObtenu}. Montre-lui comment atteindre NCLC ${ncl * Format aligné sur les captures d'écran (cf. plan session). */ export function buildExercicesPrompt(input: ExercicesInput): { - system: string - user: string + system: string; + user: string; } { - const { tache, erreursCodes, criteres } = input + const { tache, erreursCodes, criteres } = input; const system = `Tu es un coach TCF Canada. Tu produis des micro-exercices ciblés pour faire travailler un candidat sur ses erreurs réelles. @@ -310,27 +339,30 @@ FORMAT JSON : "explication": "" } ] -}` +}`; const erreursBlock = erreursCodes - .map((e) => `- ${e.code} (${e.critere})${e.description ? ` : ${e.description}` : ''}`) - .join('\n') + .map( + (e) => + `- ${e.code} (${e.critere})${e.description ? ` : ${e.description}` : ""}`, + ) + .join("\n"); const criteresBlock = criteres .map((c) => `- ${c.nom} (score ${c.score}/5) — exemple : « ${c.exemple} »`) - .join('\n') + .join("\n"); const user = `TÂCHE : ${TASK_DESCRIPTIONS[tache]} ERREURS DÉTECTÉES DANS LA PRODUCTION : -${erreursBlock || '(aucune erreur listée)'} +${erreursBlock || "(aucune erreur listée)"} EXTRAITS PAR CRITÈRE (pour alimenter "extrait") : ${criteresBlock} -Produis 3 exercices ciblés. Privilégie les codes d'erreurs qui apparaissent le plus souvent, puis les plus impactants pour l'objectif NCLC.` +Produis 3 exercices ciblés. Privilégie les codes d'erreurs qui apparaissent le plus souvent, puis les plus impactants pour l'objectif NCLC.`; - return { system, user } + return { system, user }; } // ── Post-traitement production modèle ─────────────────────────────────── @@ -340,9 +372,9 @@ Produis 3 exercices ciblés. Privilégie les codes d'erreurs qui apparaissent le * Apostrophes et tirets ne créent pas de mot supplémentaire. */ export function wordCountTCF(text: string): number { - const trimmed = text.trim() - if (trimmed.length === 0) return 0 - return trimmed.split(/\s+/).length + const trimmed = text.trim(); + if (trimmed.length === 0) return 0; + return trimmed.split(/\s+/).length; } /** @@ -351,154 +383,179 @@ export function wordCountTCF(text: string): number { */ export function stripModelAnnotations(text: string): string { return text - .replace(/\[NOTE:[^\]]*\]/gi, '') - .replace(/\s{2,}/g, ' ') - .trim() + .replace(/\[NOTE:[^\]]*\]/gi, "") + .replace(/\s{2,}/g, " ") + .trim(); } /** * Tronque à `maxWords` mots TCF. Retourne {text, truncated}. */ -export function truncateToMaxWords(text: string, maxWords: number): { text: string; truncated: boolean } { - const words = text.trim().split(/\s+/) - if (words.length <= maxWords) return { text, truncated: false } - return { text: words.slice(0, maxWords).join(' '), truncated: true } +export function truncateToMaxWords( + text: string, + maxWords: number, +): { text: string; truncated: boolean } { + const words = text.trim().split(/\s+/); + if (words.length <= maxWords) return { text, truncated: false }; + return { text: words.slice(0, maxWords).join(" "), truncated: true }; } // ── Appels DeepSeek ────────────────────────────────────────────────────── -async function callDeepSeek(system: string, user: string, temperature: number): Promise { +async function callDeepSeek( + system: string, + user: string, + temperature: number, +): Promise { try { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ - model: 'deepseek-chat', + model: "deepseek-chat", messages: [ - { role: 'system', content: system }, - { role: 'user', content: user }, + { role: "system", content: system }, + { role: "user", content: user }, ], temperature, - response_format: { type: 'json_object' }, + response_format: { type: "json_object" }, }), // Le prompt maître + taxonomie produit une réponse JSON longue : DeepSeek // peut prendre 20-40 s. Le frontend abort à 60 s (CORRECTION_TIMEOUT_MS) // → on abort ici à 55 s pour laisser une marge côté client. signal: AbortSignal.timeout(55_000), - }) + }); if (!response.ok) { - throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) + throw new Error( + `DeepSeek API error: ${response.status} ${response.statusText}`, + ); } - const data = (await response.json()) as { choices?: { message?: { content?: string } }[] } - const content = data.choices?.[0]?.message?.content + const data = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const content = data.choices?.[0]?.message?.content; if (!content) { - throw new Error('DeepSeek API: réponse vide') + throw new Error("DeepSeek API: réponse vide"); } - return content + return content; } catch (err) { const kind = - err instanceof Error && err.name === 'TimeoutError' - ? 'TIMEOUT' - : err instanceof Error && err.name === 'AbortError' - ? 'ABORT' + err instanceof Error && err.name === "TimeoutError" + ? "TIMEOUT" + : err instanceof Error && err.name === "AbortError" + ? "ABORT" : err instanceof SyntaxError - ? 'JSON_PARSE' + ? "JSON_PARSE" : err instanceof TypeError - ? 'NETWORK' - : 'OTHER' - const message = err instanceof Error ? err.message : String(err) - console.error(`[deepseek.callDeepSeek] ${kind} — ${message}`) - throw err + ? "NETWORK" + : "OTHER"; + const message = err instanceof Error ? err.message : String(err); + console.error(`[deepseek.callDeepSeek] ${kind} — ${message}`); + throw err; } } // ── Validation runtime ─────────────────────────────────────────────────── function validateErreursCodes(raw: unknown): ErreurCode[] { - if (!Array.isArray(raw)) return [] - const valid: ErreurCode[] = [] + if (!Array.isArray(raw)) return []; + const valid: ErreurCode[] = []; for (const item of raw) { - if (typeof item !== 'object' || item === null) continue - const o = item as { code?: unknown; critere?: unknown; description?: unknown } - if (typeof o.code !== 'string' || typeof o.critere !== 'string') continue - if (!isValidCritere(o.critere)) continue - if (!isValidCode(o.critere, o.code)) continue + if (typeof item !== "object" || item === null) continue; + const o = item as { + code?: unknown; + critere?: unknown; + description?: unknown; + }; + if (typeof o.code !== "string" || typeof o.critere !== "string") continue; + if (!isValidCritere(o.critere)) continue; + if (!isValidCode(o.critere, o.code)) continue; const description = - typeof o.description === 'string' && o.description.trim().length > 0 ? o.description : null - if (o.code === 'autre' && description === null) continue // autre exige une description - valid.push({ code: o.code, critere: o.critere, description }) + typeof o.description === "string" && o.description.trim().length > 0 + ? o.description + : null; + if (o.code === "autre" && description === null) continue; // autre exige une description + valid.push({ code: o.code, critere: o.critere, description }); } - return valid + return valid; } -function validateCorrectionRapport(raw: unknown, nclcCible: NclcCible): CorrectionRapport { - if (typeof raw !== 'object' || raw === null) { - throw new Error('Réponse DeepSeek invalide : racine non-objet') +function validateCorrectionRapport( + raw: unknown, + nclcCible: NclcCible, +): CorrectionRapport { + if (typeof raw !== "object" || raw === null) { + throw new Error("Réponse DeepSeek invalide : racine non-objet"); } - const r = raw as Record + const r = raw as Record; - const score = typeof r.score === 'number' ? r.score : Number(r.score) + const score = typeof r.score === "number" ? r.score : Number(r.score); if (!Number.isFinite(score) || score < 0 || score > 20) { - throw new Error(`Score invalide: ${String(r.score)} (attendu 0-20)`) + throw new Error(`Score invalide: ${String(r.score)} (attendu 0-20)`); } - const nclc = typeof r.nclc === 'number' ? r.nclc : Number(r.nclc) + const nclc = typeof r.nclc === "number" ? r.nclc : Number(r.nclc); if (!Number.isFinite(nclc) || nclc < 4 || nclc > 12) { - throw new Error(`NCLC invalide: ${String(r.nclc)} (attendu 4-12)`) + throw new Error(`NCLC invalide: ${String(r.nclc)} (attendu 4-12)`); } - const revelation = r.revelation as Record | undefined + const revelation = r.revelation as Record | undefined; if ( !revelation || - typeof revelation.croyance !== 'string' || - typeof revelation.realite !== 'string' || - typeof revelation.consequence !== 'string' + typeof revelation.croyance !== "string" || + typeof revelation.realite !== "string" || + typeof revelation.consequence !== "string" ) { - throw new Error('revelation invalide : attendu { croyance, realite, consequence } en chaînes') + throw new Error( + "revelation invalide : attendu { croyance, realite, consequence } en chaînes", + ); } - if (typeof r.diagnostic !== 'string' || r.diagnostic.trim().length === 0) { - throw new Error('diagnostic invalide : chaîne non vide attendue') + if (typeof r.diagnostic !== "string" || r.diagnostic.trim().length === 0) { + throw new Error("diagnostic invalide : chaîne non vide attendue"); } if (!Array.isArray(r.criteres) || r.criteres.length !== 4) { - throw new Error('criteres invalide : 4 entrées attendues') + throw new Error("criteres invalide : 4 entrées attendues"); } - const criteres: CorrectionCritereDetail[] = r.criteres.map((c: unknown, i: number) => { - const o = c as Record - if (typeof o?.nom !== 'string') throw new Error(`criteres[${i}].nom invalide`) - const cScore = typeof o.score === 'number' ? o.score : Number(o.score) - if (!Number.isFinite(cScore) || cScore < 0 || cScore > 5) { - throw new Error(`criteres[${i}].score invalide`) - } - return { - nom: o.nom, - score: cScore, - commentaire: typeof o.commentaire === 'string' ? o.commentaire : '', - exemple: typeof o.exemple === 'string' ? o.exemple : '', - suggestion: typeof o.suggestion === 'string' ? o.suggestion : '', - astuce: typeof o.astuce === 'string' ? o.astuce : '', - } - }) + const criteres: CorrectionCritereDetail[] = r.criteres.map( + (c: unknown, i: number) => { + const o = c as Record; + if (typeof o?.nom !== "string") + throw new Error(`criteres[${i}].nom invalide`); + const cScore = typeof o.score === "number" ? o.score : Number(o.score); + if (!Number.isFinite(cScore) || cScore < 0 || cScore > 5) { + throw new Error(`criteres[${i}].score invalide`); + } + return { + nom: o.nom, + score: cScore, + commentaire: typeof o.commentaire === "string" ? o.commentaire : "", + exemple: typeof o.exemple === "string" ? o.exemple : "", + suggestion: typeof o.suggestion === "string" ? o.suggestion : "", + astuce: typeof o.astuce === "string" ? o.astuce : "", + }; + }, + ); - const conseil = r.conseil_nclc as Record | undefined + const conseil = r.conseil_nclc as Record | undefined; if ( !conseil || - typeof conseil.nclc_cible !== 'string' || - typeof conseil.ecart !== 'string' || - typeof conseil.action_prioritaire !== 'string' + typeof conseil.nclc_cible !== "string" || + typeof conseil.ecart !== "string" || + typeof conseil.action_prioritaire !== "string" ) { - throw new Error('conseil_nclc invalide') + throw new Error("conseil_nclc invalide"); } - const erreursCodes = validateErreursCodes(r.erreurs_codes) + const erreursCodes = validateErreursCodes(r.erreurs_codes); return { score, @@ -517,60 +574,70 @@ function validateCorrectionRapport(raw: unknown, nclcCible: NclcCible): Correcti action_prioritaire: conseil.action_prioritaire, }, erreurs_codes: erreursCodes, - } + }; } // ── Fonctions exportées — correction + modèle + exercices ─────────────── -export async function correctEE(input: CorrectionInput): Promise { - const { system, user } = buildCorrectionPrompt(input) - const content = await callDeepSeek(system, user, 0.2) - const parsed: unknown = JSON.parse(content) - return validateCorrectionRapport(parsed, input.nclcCible) +export async function correctEE( + input: CorrectionInput, +): Promise { + const { system, user } = buildCorrectionPrompt(input); + const content = await callDeepSeek(system, user, 0.2); + const parsed: unknown = JSON.parse(content); + return validateCorrectionRapport(parsed, input.nclcCible); } -export async function generateProductionModele(input: ProductionModeleInput): Promise { - const { system, user } = buildModelPrompt(input) - const content = await callDeepSeek(system, user, 0.3) - const parsed = JSON.parse(content) as Record +export async function generateProductionModele( + input: ProductionModeleInput, +): Promise { + const { system, user } = buildModelPrompt(input); + const content = await callDeepSeek(system, user, 0.3); + const parsed = JSON.parse(content) as Record; - if (typeof parsed.production_modele_propre !== 'string') { - throw new Error('production_modele_propre invalide : chaîne attendue') + if (typeof parsed.production_modele_propre !== "string") { + throw new Error("production_modele_propre invalide : chaîne attendue"); } - const cleaned = stripModelAnnotations(parsed.production_modele_propre) - const { min, max } = WORD_LIMITS[input.tache] - const { text: final, truncated } = truncateToMaxWords(cleaned, max) - const count = wordCountTCF(final) + const cleaned = stripModelAnnotations(parsed.production_modele_propre); + const { min, max } = WORD_LIMITS[input.tache]; + const { text: final, truncated } = truncateToMaxWords(cleaned, max); + const count = wordCountTCF(final); const notes = Array.isArray(parsed.notes_pedagogiques) ? (parsed.notes_pedagogiques as unknown[]) .map((n) => n as Record) - .filter((n) => typeof n.passage === 'string' && typeof n.explication === 'string') - .map((n) => ({ passage: n.passage as string, explication: n.explication as string })) - : [] + .filter( + (n) => + typeof n.passage === "string" && typeof n.explication === "string", + ) + .map((n) => ({ + passage: n.passage as string, + explication: n.explication as string, + })) + : []; const transformations = Array.isArray(parsed.transformations) ? (parsed.transformations as unknown[]) .map((t) => t as Record) .filter( (t) => - typeof t.original === 'string' && - typeof t.ameliore === 'string' && - typeof t.explication === 'string' + typeof t.original === "string" && + typeof t.ameliore === "string" && + typeof t.explication === "string", ) .map((t) => ({ original: t.original as string, ameliore: t.ameliore as string, explication: t.explication as string, })) - : [] + : []; return { production_modele_propre: final, notes_pedagogiques: notes, transformations, - message: typeof parsed.message === 'string' ? parsed.message : '', + message: typeof parsed.message === "string" ? parsed.message : "", nclc_modele: 9, nclc_obtenu: input.nclcObtenu, score_cible: NCLC_MIN_SCORE[9], @@ -578,56 +645,66 @@ export async function generateProductionModele(input: ProductionModeleInput): Pr tcf_word_min: min, tcf_word_max: max, tcf_truncated: truncated, - } + }; } -export async function generateExercices(input: ExercicesInput): Promise { - const { system, user } = buildExercicesPrompt(input) - const content = await callDeepSeek(system, user, 0.4) - const parsed = JSON.parse(content) as { exercices?: unknown } +export async function generateExercices( + input: ExercicesInput, +): Promise { + const { system, user } = buildExercicesPrompt(input); + const content = await callDeepSeek(system, user, 0.4); + const parsed = JSON.parse(content) as { exercices?: unknown }; if (!Array.isArray(parsed.exercices)) { - throw new Error('exercices invalide : tableau attendu') + throw new Error("exercices invalide : tableau attendu"); } - const DIFFICULTES: ExerciceItem['difficulte'][] = ['facile', 'intermediaire', 'difficile'] + const DIFFICULTES: ExerciceItem["difficulte"][] = [ + "facile", + "intermediaire", + "difficile", + ]; return (parsed.exercices as unknown[]) .map((e) => e as Record) - .filter((e) => typeof e.consigne === 'string' && typeof e.correction === 'string') + .filter( + (e) => typeof e.consigne === "string" && typeof e.correction === "string", + ) .map((e) => ({ - difficulte: DIFFICULTES.includes(e.difficulte as ExerciceItem['difficulte']) - ? (e.difficulte as ExerciceItem['difficulte']) - : 'intermediaire', - theme: typeof e.theme === 'string' ? e.theme : '', - diagnostic: typeof e.diagnostic === 'string' ? e.diagnostic : '', + difficulte: DIFFICULTES.includes( + e.difficulte as ExerciceItem["difficulte"], + ) + ? (e.difficulte as ExerciceItem["difficulte"]) + : "intermediaire", + theme: typeof e.theme === "string" ? e.theme : "", + diagnostic: typeof e.diagnostic === "string" ? e.diagnostic : "", consigne: e.consigne as string, - extrait: typeof e.extrait === 'string' ? e.extrait : '', - indice: typeof e.indice === 'string' ? e.indice : '', + extrait: typeof e.extrait === "string" ? e.extrait : "", + indice: typeof e.indice === "string" ? e.indice : "", correction: e.correction as string, - explication: typeof e.explication === 'string' ? e.explication : '', - })) + explication: typeof e.explication === "string" ? e.explication : "", + })); } // ── Sprint 3.6c — Exercices long terme (patterns Premium) ────────────── export interface PatternInput { - code: string - critere: Critere - frequency: number - description: string | null + code: string; + critere: Critere; + frequency: number; + description: string | null; } export interface PatternExerciceItem { - code: string - critere: Critere - diagnostic: string + code: string; + critere: Critere; + diagnostic: string; exercice: { - consigne: string - exemple: string - correction: string - astuce: string - } + consigne: string; + exemple: string; + correction: string; + astuce: string; + }; } const PATTERN_EXERCICES_SYSTEM = `Tu es un coach spécialisé dans la préparation au TCF Canada (Test de connaissance du français). @@ -668,75 +745,79 @@ FORMAT DE SORTIE — JSON strict, aucun texte avant ni après : } } ] -}` +}`; function buildPatternExercicesUserPrompt(patterns: PatternInput[]): string { const lines = patterns.map((p) => { - const desc = p.description ? ` — « ${p.description} »` : '' - return `- ${p.code} (${p.critere}) — apparu ${p.frequency}/5 fois${desc}` - }) + const desc = p.description ? ` — « ${p.description} »` : ""; + return `- ${p.code} (${p.critere}) — apparu ${p.frequency}/5 fois${desc}`; + }); return `Voici les patterns d'erreurs récurrents détectés sur les 5 dernières productions du candidat : -${lines.join('\n')} +${lines.join("\n")} -Produis un exercice ciblé par pattern. JSON strict uniquement.` +Produis un exercice ciblé par pattern. JSON strict uniquement.`; } export async function generatePatternExercices( patterns: PatternInput[], ): Promise { - if (patterns.length === 0) return [] + if (patterns.length === 0) return []; const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ - model: 'deepseek-chat', + model: "deepseek-chat", messages: [ - { role: 'system', content: PATTERN_EXERCICES_SYSTEM }, - { role: 'user', content: buildPatternExercicesUserPrompt(patterns) }, + { role: "system", content: PATTERN_EXERCICES_SYSTEM }, + { role: "user", content: buildPatternExercicesUserPrompt(patterns) }, ], temperature: 0.4, - response_format: { type: 'json_object' }, + response_format: { type: "json_object" }, }), signal: AbortSignal.timeout(20_000), - }) + }); if (!response.ok) { - throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) + throw new Error( + `DeepSeek API error: ${response.status} ${response.statusText}`, + ); } const data = (await response.json()) as { - choices?: { message?: { content?: string } }[] - } - const content = data.choices?.[0]?.message?.content - if (!content) throw new Error('DeepSeek API: réponse vide') + choices?: { message?: { content?: string } }[]; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error("DeepSeek API: réponse vide"); - const parsed = JSON.parse(content) as { exercises?: unknown } + const parsed = JSON.parse(content) as { exercises?: unknown }; if (!Array.isArray(parsed.exercises)) { - throw new Error('Réponse DeepSeek invalide : exercises doit être un tableau') + throw new Error( + "Réponse DeepSeek invalide : exercises doit être un tableau", + ); } - const out: PatternExerciceItem[] = [] + const out: PatternExerciceItem[] = []; for (const raw of parsed.exercises as unknown[]) { - const item = raw as Record - const ex = item.exercice as Record | undefined + const item = raw as Record; + const ex = item.exercice as Record | undefined; if ( - typeof item.code !== 'string' || - typeof item.critere !== 'string' || - typeof item.diagnostic !== 'string' || + typeof item.code !== "string" || + typeof item.critere !== "string" || + typeof item.diagnostic !== "string" || !ex || - typeof ex.consigne !== 'string' || - typeof ex.exemple !== 'string' || - typeof ex.correction !== 'string' || - typeof ex.astuce !== 'string' + typeof ex.consigne !== "string" || + typeof ex.exemple !== "string" || + typeof ex.correction !== "string" || + typeof ex.astuce !== "string" ) { - continue + continue; } - if (!isValidCritere(item.critere)) continue + if (!isValidCritere(item.critere)) continue; out.push({ code: item.code, critere: item.critere, @@ -747,60 +828,112 @@ export async function generatePatternExercices( correction: ex.correction, astuce: ex.astuce, }, - }) + }); } - return out + return out; } -// ── EO (Expression Orale) — inchangé par Sprint 3.6a ──────────────────── +// ── EO (Expression Orale) — Sprint 4a : aligné sur le format 3.6a ────── -export interface EOCritere { - nom: string - score: number - commentaire: string +export interface CorrectionEOInput { + tache: TacheEO; + transcript: string; + sujet: string | null; + nclcCible: NclcCible; } -export interface EORapport { - score: number - nclc: number - feedback_court: string - criteres: EOCritere[] - erreurs: string[] - modele: string - idees: string[] - exercices: string[] -} +/** + * Prompt de correction EO — Sprint 4a. + * + * Adapté à un transcript oral (issu de Gemini batch) : tolère les marques + * d'oralité usuelles (hésitations « euh », reformulations, faux départs, élisions + * familières) sans les sanctionner systématiquement, mais évalue la fluidité + * discursive (capacité à enchaîner des idées sans rupture excessive). + * + * La phonologie n'est pas évaluable depuis un transcript textuel : critères + * limités aux 3 axes pertinents pour l'oral retranscrit. Nous reprojetons + * cependant les 4 critères TCF Canada (adéquation tâche, cohérence/cohésion, + * lexique, grammaticale) pour conserver la même structure de rapport que EE. + * + * Cible NCLC, taxonomie d'erreurs, structure (revelation, diagnostic, + * conseil_nclc, erreurs_codes) : strictement identique à correctEE. + */ +export function buildCorrectionPromptEO(input: CorrectionEOInput): { + system: string; + user: string; +} { + const { tache, transcript, sujet, nclcCible } = input; + const minScore = NCLC_MIN_SCORE[nclcCible]; + const taxonomySection = buildTaxonomyPromptSection(); -const SYSTEM_PROMPT_EO = `Tu es un examinateur officiel du TCF Canada (Test de connaissance du français). -Tu évalues une production orale à partir de sa transcription selon les 4 critères officiels de l'Expression Orale : -1. Cohérence et cohésion -2. Lexique (étendue et maîtrise du vocabulaire) -3. Morphosyntaxe (grammaire et structures) -4. Phonologie — NOTE IMPORTANTE : ce critère est fixé à 0 car l'évaluation se fait sur une transcription textuelle, pas sur l'audio original. Mets toujours 0 pour ce critère. + const system = `Tu es un correcteur TCF Canada certifié par France Éducation International, spécialiste de l'Expression Orale. Tu corriges avec précision et bienveillance le TRANSCRIPT TEXTUEL d'une production orale. -Tu dois retourner un JSON strict avec cette structure exacte : +CONTEXTE ORAL — RÈGLES SPÉCIFIQUES : +- Tu évalues un transcript issu d'une transcription audio batch (Gemini). Tu n'as PAS accès à l'audio. +- Les marques d'oralité courantes sont TOLÉRÉES si elles n'entravent pas la communication : hésitations (« euh », « hm »), reformulations, faux départs, élisions familières (« j'ai pas », « y'a »), répétitions de soutien. +- Tu SANCTIONNES en revanche : ruptures discursives répétées, idées non finies, mauvaise organisation argumentative, lexique pauvre, fautes morphosyntaxiques systématiques. +- La phonologie n'est PAS évaluée sur ce transcript : ne la mentionne dans aucun critère ni erreur. +- Évalue la FLUIDITÉ DISCURSIVE dans le critère « Cohérence et fluidité ». +- La taxonomie d'erreurs ci-dessous s'applique aussi à l'oral retranscrit : conserve les codes valides. + +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). +- 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) : +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. +4. ${CRITERE_LABELS_EO.competence_grammaticale} — correction des structures à l'oral, morphologie verbale, syntaxe, accords. Ne sanctionne pas les élisions familières usuelles. + +${taxonomySection} + +FORMAT DE RÉPONSE (JSON strict, aucun autre texte) : { - "score": , - "nclc": , - "feedback_court": "<2 à 3 lignes de feedback global, orientées action>", - "criteres": [ - { "nom": "Cohérence et cohésion", "score": , "commentaire": "" }, - { "nom": "Lexique", "score": , "commentaire": "" }, - { "nom": "Morphosyntaxe", "score": , "commentaire": "" }, - { "nom": "Phonologie", "score": 0, "commentaire": "Non évalué sur transcription textuelle." } + 'score': , + 'nclc': , + 'revelation': { + 'croyance': '', + 'realite': '', + 'consequence': '' + }, + 'diagnostic': '', + 'transcription_affichee': '', + 'criteres': [ + { 'nom': '${CRITERE_LABELS_EO.adequation_tache}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '', 'suggestion': '', 'astuce': '' }, + { 'nom': '${CRITERE_LABELS_EO.coherence_cohesion}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '', 'suggestion': '', 'astuce': '' }, + { 'nom': '${CRITERE_LABELS_EO.competence_lexicale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '', 'suggestion': '', 'astuce': '' }, + { 'nom': '${CRITERE_LABELS_EO.competence_grammaticale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '', 'suggestion': '', 'astuce': '' } ], - "erreurs": ["", "", ...], - "modele": "", - "idees": ["", "", ...], - "exercices": ["", "", ...] -} + 'conseil_nclc': { + 'nclc_cible': 'NCLC ${nclcCible}', + 'ecart': '', + 'action_prioritaire': '' + }, + 'erreurs_codes': [ + { 'code': '', 'critere': '', 'description': } + ] +}`; -Règles : -- score est la note globale sur 20 (basée uniquement sur les 3 critères évalués) -- nclc est le niveau NCLC estimé (entre 4 et 12) -- feedback_court est un résumé de 2 à 3 lignes, toujours renseigné (visible pour tous les plans) -- Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle." -- Retourne UNIQUEMENT le JSON, sans texte avant ni après` + const user = `OBJECTIF DU CANDIDAT : NCLC ${nclcCible} — score minimum requis : ${minScore}/20. + +TÂCHE : ${TASK_DESCRIPTIONS[tache]} + +CONSIGNE / SUJET : ${sujet ?? "Non précisé"} + +TRANSCRIPT DE LA PRODUCTION ORALE DU CANDIDAT : +""" +${transcript} +"""`; + + return { system, user }; +} const SYSTEM_PROMPT_IDEES = `Tu es un coach TCF Canada. Tu aides un étudiant à continuer sa rédaction en cours. Tu dois retourner UNIQUEMENT un JSON strict : { "idees": ["", "", ...] } @@ -809,109 +942,151 @@ Règles : - Exactement 5 idées courtes et concrètes (1 phrase max chacune) - Les idées doivent prolonger ce que l'étudiant a déjà écrit, sans répéter - Rester en français, ton encourageant, orienté action -- Aucun texte avant ni après le JSON` +- Aucun texte avant ni après le JSON`; -export async function generateIdees(consigne: string, contenu: string): Promise { +export async function generateIdees( + consigne: string, + contenu: string, +): Promise { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", Authorization: `Bearer ${DEEPSEEK_API_KEY}`, }, body: JSON.stringify({ - model: 'deepseek-chat', + model: "deepseek-chat", messages: [ - { role: 'system', content: SYSTEM_PROMPT_IDEES }, + { role: "system", content: SYSTEM_PROMPT_IDEES }, { - role: 'user', + role: "user", content: `Sujet : ${consigne}\n\nCe que l'étudiant a écrit jusqu'ici :\n${contenu}`, }, ], temperature: 0.5, - response_format: { type: 'json_object' }, + response_format: { type: "json_object" }, }), signal: AbortSignal.timeout(15_000), - }) + }); if (!response.ok) { - throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) + throw new Error( + `DeepSeek API error: ${response.status} ${response.statusText}`, + ); } const data = (await response.json()) as { - choices?: { message?: { content?: string } }[] - } - const content = data.choices?.[0]?.message?.content + choices?: { message?: { content?: string } }[]; + }; + const content = data.choices?.[0]?.message?.content; if (!content) { - throw new Error('DeepSeek API: réponse vide') + throw new Error("DeepSeek API: réponse vide"); } - const parsed = JSON.parse(content) as { idees?: unknown } + const parsed = JSON.parse(content) as { idees?: unknown }; if (!Array.isArray(parsed.idees) || parsed.idees.length === 0) { - throw new Error('Réponse DeepSeek invalide : idees doit être un tableau non vide') + throw new Error( + "Réponse DeepSeek invalide : idees doit être un tableau non vide", + ); } const idees = parsed.idees.filter( - (i): i is string => typeof i === 'string' && i.trim().length > 0, - ) + (i): i is string => typeof i === "string" && i.trim().length > 0, + ); if (idees.length === 0) { - throw new Error('Réponse DeepSeek invalide : aucune idée exploitable') + throw new Error("Réponse DeepSeek invalide : aucune idée exploitable"); } - return idees + return idees; } -export async function correctEO(transcript: string, tache: string): Promise { - const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${DEEPSEEK_API_KEY}`, - }, - body: JSON.stringify({ - model: 'deepseek-chat', - messages: [ - { role: 'system', content: SYSTEM_PROMPT_EO }, - { - role: 'user', - content: `Tâche : ${tache}\n\nTranscription de la production orale :\n${transcript}`, - }, - ], - temperature: 0.3, - response_format: { type: 'json_object' }, - }), - }) - - if (!response.ok) { - throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) +/** + * Sprint 4a — 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. + * - Lit `transcription_affichee` (chaîne, fallback : transcript brut nettoyé minimalement). + * - Ajoute `note_phonologie` fixe (MVP — TD-08). + */ +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 + // (DeepSeek peut dériver malgré la consigne). + if (typeof raw === "object" && raw !== null) { + const r = raw as Record; + if (Array.isArray(r.criteres)) { + r.criteres = r.criteres.map((c) => { + if (typeof c !== "object" || c === null) return c; + const o = c as Record; + 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))) + : 0; + return { ...o, score: capped }; + }); + const sum = (r.criteres as { score: number }[]).reduce( + (acc, c) => acc + (typeof c.score === "number" ? c.score : 0), + 0, + ); + r.score = sum; + } } - const data = (await response.json()) as { - choices?: { message?: { content?: string } }[] - } - const content = data.choices?.[0]?.message?.content + const baseRapport = validateCorrectionRapport(raw, nclcCible); - if (!content) { - throw new Error('DeepSeek API: réponse vide') - } + const r = raw as Record; + const transcriptionAffichee = + typeof r.transcription_affichee === "string" && + r.transcription_affichee.trim().length > 0 + ? r.transcription_affichee + : transcriptBrut; - const rapport: EORapport = JSON.parse(content) + return { + ...baseRapport, + transcription_affichee: transcriptionAffichee, + note_phonologie: EO_NOTE_PHONOLOGIE_DEFAULT, + }; +} - if (rapport.score < 0 || rapport.score > 20) { - throw new Error(`Score invalide: ${rapport.score} (attendu 0-20)`) - } - if (rapport.nclc < 4 || rapport.nclc > 12) { - throw new Error(`NCLC invalide: ${rapport.nclc} (attendu 4-12)`) - } - if (typeof rapport.feedback_court !== 'string' || rapport.feedback_court.trim().length === 0) { - throw new Error('feedback_court invalide: attendu une chaîne non vide') - } - - return rapport +/** + * Sprint 4a — Correction EO sur transcript textuel. + * + * Retourne un CorrectionRapport aligné sur le format 3.6a (revelation, diagnostic, + * 4 critères enrichis, conseil_nclc, erreurs_codes) + champs EO additionnels : + * `transcription_affichee` (transcript nettoyé) et `note_phonologie` (MVP fixe). + * + * Le scoring critère est cappé à 5 et le total recalculé côté serveur (cf. + * validateCorrectionRapportEO) pour neutraliser les dérives de DeepSeek. + */ +export async function correctEO( + transcript: string, + tache: TacheEO, + nclcCible: NclcCible = 9, + sujet: string | null = null, +): Promise { + const { system, user } = buildCorrectionPromptEO({ + tache, + transcript, + sujet, + nclcCible, + }); + const content = await callDeepSeek(system, user, 0.2); + const parsed: unknown = JSON.parse(content); + return validateCorrectionRapportEO(parsed, nclcCible, transcript); } // Alias legacy — temporairement conservé le temps que correctionController.correctEE // soit migré vers la nouvelle signature (étape E5). -export type EERapport = CorrectionRapport +export type EERapport = CorrectionRapport; +// Alias legacy — anciens consommateurs EO (frontend Sprint <4a). Dépréciation +// programmée Sprint 4b. Pointe vers le format 3.6a aligné. +export type EORapport = CorrectionRapport; diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 2e8f0bd..b9904aa 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -1,40 +1,102 @@ -const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? '' -const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta' +/** + * Client Gemini — transcription audio batch (Sprint 4a). + * + * Mode batch uniquement : on envoie l'audio entier en base64 et on récupère + * le transcript complet. La transcription live/streaming sera Session 4b. + * + * Robustesse : timeout 30 s + 1 retry automatique sur erreur réseau / timeout + * (les erreurs de quota ou d'auth ne sont PAS retentées — réponse HTTP non-OK + * indique une erreur de configuration, pas un aléa réseau). + */ -export async function transcribeAudio( +const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? ""; +const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const GEMINI_TIMEOUT_MS = 30_000; + +/** + * MIME types audio acceptés par le pipeline Sprint 4a. + * Aligné sur les capacités du MediaRecorder côté frontend (webm Chromium / mp4 + * Safari) + wav exporté par certains parcours d'upload. + */ +export const ACCEPTED_AUDIO_MIME = [ + "audio/webm", + "audio/mp4", + "audio/wav", +] as const; +export type AcceptedAudioMime = (typeof ACCEPTED_AUDIO_MIME)[number]; + +export function isAcceptedAudioMime(mime: string): mime is AcceptedAudioMime { + return (ACCEPTED_AUDIO_MIME as readonly string[]).includes(mime); +} + +async function callGeminiTranscribe( audioBase64: string, - mimeType: string + mimeType: string, ): Promise { const response = await fetch( `${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: [ { parts: [ { inlineData: { mimeType, data: audioBase64 } }, - { text: 'Transcris cet audio mot pour mot en francais. Retourne uniquement la transcription, sans commentaire.' }, + { + text: "Transcris cet audio mot pour mot en francais. Retourne uniquement la transcription, sans commentaire.", + }, ], }, ], }), - } - ) + signal: AbortSignal.timeout(GEMINI_TIMEOUT_MS), + }, + ); if (!response.ok) { - throw new Error(`Gemini API error: ${response.status} ${response.statusText}`) + throw new Error( + `Gemini API error: ${response.status} ${response.statusText}`, + ); } const data = (await response.json()) as { - candidates?: { content?: { parts?: { text?: string }[] } }[] - } - const text = data.candidates?.[0]?.content?.parts?.[0]?.text + candidates?: { content?: { parts?: { text?: string }[] } }[]; + }; + const text = data.candidates?.[0]?.content?.parts?.[0]?.text; - if (!text || typeof text !== 'string' || text.trim().length === 0) { - throw new Error('Gemini API: transcription vide') + if (!text || typeof text !== "string" || text.trim().length === 0) { + throw new Error("Gemini API: transcription vide"); } - return text.trim() + return text.trim(); +} + +/** + * Transcription audio batch. + * + * Retry policy : + * - 1 retry sur TimeoutError, AbortError, TypeError (erreurs réseau ; AbortSignal.timeout + * lève TimeoutError). + * - PAS de retry sur les erreurs HTTP applicatives (quota, auth, format) — un + * second appel échouera de la même manière. + */ +export async function transcribeAudio( + audioBase64: string, + mimeType: string, +): Promise { + try { + return await callGeminiTranscribe(audioBase64, mimeType); + } catch (err) { + const isRetryable = + err instanceof Error && + (err.name === "TimeoutError" || + err.name === "AbortError" || + err instanceof TypeError); + if (!isRetryable) throw err; + console.warn( + `[gemini.transcribeAudio] retry après erreur transitoire : ${err.message}`, + ); + return await callGeminiTranscribe(audioBase64, mimeType); + } } diff --git a/src/routes/__tests__/correctionsEO.test.ts b/src/routes/__tests__/correctionsEO.test.ts new file mode 100644 index 0000000..d68f86a --- /dev/null +++ b/src/routes/__tests__/correctionsEO.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ─────────────────────────────────────────────────────────────── + +vi.mock("../../middleware/auth", () => ({ + authMiddleware: async (c: any, next: any) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: true, code: "AUTH_REQUIRED" }, 401); + } + c.set("profile", { + id: "user-1", + email: "u@test.com", + plan: "standard", + simulations_used: 3, + }); + await next(); + }, +})); + +const { correctEOMock } = vi.hoisted(() => ({ correctEOMock: vi.fn() })); +vi.mock("../../controllers/correctionController", () => ({ + correctEE: vi.fn(), + correctEO: correctEOMock, +})); + +import correctionsRoutes from "../corrections"; + +function buildApp() { + const app = new Hono(); + app.route("/corrections", correctionsRoutes); + return app; +} + +const AUTH = { Authorization: "Bearer x" }; +const JSON_HEADERS = { ...AUTH, "Content-Type": "application/json" }; + +describe("POST /corrections/eo — Sprint 4a", () => { + beforeEach(() => { + correctEOMock.mockReset(); + }); + + it("401 sans Authorization", async () => { + const app = buildApp(); + const res = await app.request("/corrections/eo", { method: "POST" }); + expect(res.status).toBe(401); + }); + + it("400 si simulationId manquant", async () => { + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ tache: "EO_T1", transcript: "t" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("VALIDATION_ERROR"); + }); + + it("400 si tache invalide (EO_T2 par exemple)", async () => { + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + simulationId: "s1", + tache: "EO_T2", + transcript: "t", + }), + }); + expect(res.status).toBe(400); + }); + + it("400 si transcript manquant", async () => { + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ simulationId: "s1", tache: "EO_T1" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("VALIDATION_ERROR"); + }); + + it("400 si nclc_cible invalide", async () => { + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + simulationId: "s1", + tache: "EO_T1", + transcript: "t", + nclc_cible: 8, + }), + }); + expect(res.status).toBe(400); + }); + + it("200 quand le controller renvoie un rapport (mode transcript)", async () => { + correctEOMock.mockResolvedValue({ + data: { + score: 14, + nclc: 9, + simulation_id: "s1", + diagnostic: "d", + }, + }); + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + simulationId: "s1", + tache: "EO_T1", + transcript: "Bonjour je m appelle Pierre", + }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.score).toBe(14); + expect(correctEOMock).toHaveBeenCalledWith( + expect.objectContaining({ + simulationId: "s1", + tache: "EO_T1", + nclcCible: 9, + transcript: "Bonjour je m appelle Pierre", + }), + expect.any(Object), + ); + }); + + it("200 avec nclc_cible=10 transmis au controller", async () => { + correctEOMock.mockResolvedValue({ + data: { score: 16, nclc: 10, simulation_id: "s2", diagnostic: "d" }, + }); + const app = buildApp(); + const res = await app.request("/corrections/eo", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + simulationId: "s2", + tache: "EO_T1", + transcript: "Bonjour", + nclc_cible: 10, + }), + }); + expect(res.status).toBe(200); + expect(correctEOMock).toHaveBeenCalledWith( + expect.objectContaining({ + simulationId: "s2", + nclcCible: 10, + transcript: "Bonjour", + }), + expect.any(Object), + ); + }); +}); diff --git a/src/routes/__tests__/presentationsGenerate.test.ts b/src/routes/__tests__/presentationsGenerate.test.ts new file mode 100644 index 0000000..e3eded2 --- /dev/null +++ b/src/routes/__tests__/presentationsGenerate.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../middleware/auth", () => ({ + authMiddleware: async (c: any, next: any) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: true, code: "AUTH_REQUIRED" }, 401); + } + c.set("profile", { + id: "user-1", + email: "u@test.com", + plan: "standard", + simulations_used: 0, + }); + await next(); + }, +})); + +const { generateMock } = vi.hoisted(() => ({ generateMock: vi.fn() })); +vi.mock("../../controllers/presentationController", () => ({ + generate: generateMock, +})); + +import presentationsRoutes from "../presentations"; + +function buildApp() { + const app = new Hono(); + app.route("/presentations", presentationsRoutes); + return app; +} + +const JSON_HEADERS = { + Authorization: "Bearer x", + "Content-Type": "application/json", +}; + +describe("POST /presentations/generate", () => { + beforeEach(() => { + generateMock.mockReset(); + }); + + it("401 sans Authorization", async () => { + const app = buildApp(); + const res = await app.request("/presentations/generate", { + method: "POST", + }); + expect(res.status).toBe(401); + }); + + it("400 si body JSON invalide", async () => { + const app = buildApp(); + const res = await app.request("/presentations/generate", { + method: "POST", + headers: JSON_HEADERS, + body: "not-json", + }); + expect(res.status).toBe(400); + }); + + it("propage l'erreur de validation du controller", async () => { + generateMock.mockResolvedValue({ + error: true, + code: "VALIDATION_ERROR", + message: "field missing", + status: 400, + }); + const app = buildApp(); + const res = await app.request("/presentations/generate", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ reponses: {} }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("VALIDATION_ERROR"); + }); + + it("200 avec { presentation } quand le controller réussit", async () => { + generateMock.mockResolvedValue({ + data: { presentation: "Bonjour, je m'appelle Pierre…" }, + }); + const app = buildApp(); + const res = await app.request("/presentations/generate", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + reponses: { + prenom_age_ville: "Pierre", + formation_metier: "Ingénieur", + situation_familiale: "Marié", + loisirs: "Lecture", + motivation_canada: "Travail", + }, + }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.presentation).toContain("Pierre"); + expect(generateMock).toHaveBeenCalledWith({ + prenom_age_ville: "Pierre", + formation_metier: "Ingénieur", + situation_familiale: "Marié", + loisirs: "Lecture", + motivation_canada: "Travail", + }); + }); + + it("500 si DeepSeek down (controller renvoie INTERNAL_ERROR)", async () => { + generateMock.mockResolvedValue({ + error: true, + code: "INTERNAL_ERROR", + message: "fail", + status: 500, + }); + const app = buildApp(); + const res = await app.request("/presentations/generate", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + reponses: { + prenom_age_ville: "a", + formation_metier: "b", + situation_familiale: "c", + loisirs: "d", + motivation_canada: "e", + }, + }), + }); + expect(res.status).toBe(500); + }); +}); diff --git a/src/routes/__tests__/transcriptionsToken.test.ts b/src/routes/__tests__/transcriptionsToken.test.ts new file mode 100644 index 0000000..5b511c7 --- /dev/null +++ b/src/routes/__tests__/transcriptionsToken.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../middleware/auth", () => ({ + authMiddleware: async (c: any, next: any) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: true, code: "AUTH_REQUIRED" }, 401); + } + c.set("profile", { + id: "user-1", + email: "u@test.com", + plan: "standard", + simulations_used: 0, + }); + await next(); + }, +})); + +import transcriptionsRoutes from "../transcriptions"; + +function buildApp() { + const app = new Hono(); + app.route("/transcriptions", transcriptionsRoutes); + return app; +} + +const JSON_HEADERS = { + Authorization: "Bearer x", + "Content-Type": "application/json", +}; + +describe("POST /transcriptions/token — Sprint 4b", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("401 sans Authorization", async () => { + const app = buildApp(); + const res = await app.request("/transcriptions/token", { method: "POST" }); + expect(res.status).toBe(401); + }); + + it("200 + { token, expires_in } quand Deepgram répond OK", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "dg-temp-abc123", + expires_in: 600, + }), + }), + ); + + const app = buildApp(); + const res = await app.request("/transcriptions/token", { + method: "POST", + headers: JSON_HEADERS, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.token).toBe("dg-temp-abc123"); + expect(body.expires_in).toBe(600); + }); + + it("appelle Deepgram avec ttl_seconds=600 et Authorization Token ", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ access_token: "tok", expires_in: 600 }), + }); + vi.stubGlobal("fetch", fetchMock); + + const app = buildApp(); + await app.request("/transcriptions/token", { + method: "POST", + headers: JSON_HEADERS, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/v1/auth/grant"); + const headers = (init?.headers ?? {}) as Record; + expect(headers["Authorization"]).toMatch(/^Token /); + expect(JSON.parse(init.body as string)).toEqual({ ttl_seconds: 600 }); + }); + + it("500 INTERNAL_ERROR si Deepgram non-OK", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const app = buildApp(); + const res = await app.request("/transcriptions/token", { + method: "POST", + headers: JSON_HEADERS, + }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.code).toBe("INTERNAL_ERROR"); + }); + + it("500 INTERNAL_ERROR si fetch throw (timeout réseau)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("network down")), + ); + + const app = buildApp(); + const res = await app.request("/transcriptions/token", { + method: "POST", + headers: JSON_HEADERS, + }); + + expect(res.status).toBe(500); + }); + + it("500 INTERNAL_ERROR si access_token absent dans la réponse Deepgram", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ expires_in: 600 }), + }), + ); + + const app = buildApp(); + const res = await app.request("/transcriptions/token", { + method: "POST", + headers: JSON_HEADERS, + }); + + expect(res.status).toBe(500); + }); +}); diff --git a/src/routes/corrections.ts b/src/routes/corrections.ts index f3544e2..d2a0f08 100644 --- a/src/routes/corrections.ts +++ b/src/routes/corrections.ts @@ -1,137 +1,184 @@ -import { Hono } from 'hono' -import { authMiddleware } from '../middleware/auth.js' -import type { AppVariables } from '../middleware/auth.js' -import * as correctionController from '../controllers/correctionController.js' +import { Hono } from "hono"; +import { authMiddleware } from "../middleware/auth.js"; +import type { AppVariables } from "../middleware/auth.js"; +import * as correctionController from "../controllers/correctionController.js"; -const VALID_TACHES_EE = ['EE_T1', 'EE_T2', 'EE_T3'] -const VALID_TACHES_EO = ['EO_T1', 'EO_T3'] +const VALID_TACHES_EE = ["EE_T1", "EE_T2", "EE_T3"]; +const VALID_TACHES_EO = ["EO_T1", "EO_T3"]; -const corrections = new Hono<{ Variables: AppVariables }>() +const corrections = new Hono<{ Variables: AppVariables }>(); -corrections.post('/ee', authMiddleware, async (c) => { +corrections.post("/ee", authMiddleware, async (c) => { let body: { - simulationId?: unknown - contenu?: unknown - tache?: unknown - nclc_cible?: unknown - } + simulationId?: unknown; + contenu?: unknown; + tache?: unknown; + nclc_cible?: unknown; + }; try { - body = await c.req.json() + body = await c.req.json(); } catch { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, - 400 - ) + { + error: true, + code: "VALIDATION_ERROR", + message: "Corps de la requête invalide.", + }, + 400, + ); } - if (!body.simulationId || typeof body.simulationId !== 'string') { + if (!body.simulationId || typeof body.simulationId !== "string") { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'simulationId est requis.' }, - 400 - ) + { + error: true, + code: "VALIDATION_ERROR", + message: "simulationId est requis.", + }, + 400, + ); } - if (!body.contenu || typeof body.contenu !== 'string') { + if (!body.contenu || typeof body.contenu !== "string") { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'contenu est requis.' }, - 400 - ) + { error: true, code: "VALIDATION_ERROR", message: "contenu est requis." }, + 400, + ); } if (!body.tache || !VALID_TACHES_EE.includes(body.tache as string)) { return c.json( { error: true, - code: 'VALIDATION_ERROR', - message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EE.join(', ')}`, + code: "VALIDATION_ERROR", + message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EE.join(", ")}`, }, - 400 - ) + 400, + ); } // Sprint 3.6a — nclc_cible optionnel (défaut 9). Seules les valeurs 9 et 10 sont acceptées. - let nclcCible: 9 | 10 = 9 + let nclcCible: 9 | 10 = 9; if (body.nclc_cible !== undefined) { if (body.nclc_cible !== 9 && body.nclc_cible !== 10) { return c.json( { error: true, - code: 'VALIDATION_ERROR', - message: 'nclc_cible doit être 9 ou 10.', + code: "VALIDATION_ERROR", + message: "nclc_cible doit être 9 ou 10.", }, - 400 - ) + 400, + ); } - nclcCible = body.nclc_cible + nclcCible = body.nclc_cible; } - const profile = c.get('profile') + const profile = c.get("profile"); const result = await correctionController.correctEE( { simulationId: body.simulationId, contenu: body.contenu, - tache: body.tache as 'EE_T1' | 'EE_T2' | 'EE_T3', + tache: body.tache as "EE_T1" | "EE_T2" | "EE_T3", nclcCible, }, profile, - ) + ); - if ('error' in result) { - return c.json(result, result.status as 401 | 404 | 500) + if ("error" in result) { + return c.json(result, result.status as 401 | 404 | 500); } - return c.json(result.data, 200) -}) + return c.json(result.data, 200); +}); -corrections.post('/eo', authMiddleware, async (c) => { - let body: { simulationId?: unknown; transcript?: unknown; tache?: unknown } +// Sprint 4b — POST /corrections/eo reçoit uniquement le transcript final. +// La transcription live est gérée navigateur ↔ Deepgram (cf. /transcriptions/token). +// Aucun audio n'est stocké côté backend. +corrections.post("/eo", authMiddleware, async (c) => { + let body: { + simulationId?: unknown; + transcript?: unknown; + tache?: unknown; + nclc_cible?: unknown; + }; try { - body = await c.req.json() + body = await c.req.json(); } catch { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, - 400 - ) + { + error: true, + code: "VALIDATION_ERROR", + message: "Corps de la requête invalide.", + }, + 400, + ); } - if (!body.simulationId || typeof body.simulationId !== 'string') { + if (!body.simulationId || typeof body.simulationId !== "string") { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'simulationId est requis.' }, - 400 - ) + { + error: true, + code: "VALIDATION_ERROR", + message: "simulationId est requis.", + }, + 400, + ); } - if (!body.transcript || typeof body.transcript !== 'string') { + if (!body.transcript || typeof body.transcript !== "string") { return c.json( - { error: true, code: 'VALIDATION_ERROR', message: 'transcript est requis.' }, - 400 - ) + { + error: true, + code: "VALIDATION_ERROR", + message: "transcript est requis.", + }, + 400, + ); } if (!body.tache || !VALID_TACHES_EO.includes(body.tache as string)) { return c.json( { error: true, - code: 'VALIDATION_ERROR', - message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EO.join(', ')}`, + code: "VALIDATION_ERROR", + message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EO.join(", ")}`, }, - 400 - ) + 400, + ); } - const profile = c.get('profile') + // nclc_cible optionnel (défaut 9, valeurs 9 ou 10). + let nclcCible: 9 | 10 = 9; + if (body.nclc_cible !== undefined) { + if (body.nclc_cible !== 9 && body.nclc_cible !== 10) { + return c.json( + { + error: true, + code: "VALIDATION_ERROR", + message: "nclc_cible doit être 9 ou 10.", + }, + 400, + ); + } + nclcCible = body.nclc_cible; + } + + const profile = c.get("profile"); const result = await correctionController.correctEO( - body.simulationId as string, - body.transcript as string, - body.tache as string, - profile - ) + { + simulationId: body.simulationId, + tache: body.tache as "EO_T1" | "EO_T3", + nclcCible, + transcript: body.transcript, + }, + profile, + ); - if ('error' in result) { - return c.json(result, result.status as 401 | 404 | 500) + if ("error" in result) { + return c.json(result, result.status as 401 | 404 | 500); } - return c.json(result.data, 200) -}) + return c.json(result.data, 200); +}); -export default corrections +export default corrections; diff --git a/src/routes/presentations.ts b/src/routes/presentations.ts new file mode 100644 index 0000000..7d092d9 --- /dev/null +++ b/src/routes/presentations.ts @@ -0,0 +1,39 @@ +import { Hono } from "hono"; +import { authMiddleware } from "../middleware/auth.js"; +import type { AppVariables } from "../middleware/auth.js"; +import * as presentationController from "../controllers/presentationController.js"; + +const presentations = new Hono<{ Variables: AppVariables }>(); + +// Sprint 4a — POST /presentations/generate +// +// Body : { reponses: { prenom_age_ville, formation_metier, situation_familiale, +// loisirs, motivation_canada } } +// Réponse : { presentation: string } +// +// Pas de stockage en base — le frontend gère la persistance locale (MVP). +presentations.post("/generate", authMiddleware, async (c) => { + let body: { reponses?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json( + { + error: true, + code: "VALIDATION_ERROR", + message: "Corps de la requête invalide.", + }, + 400, + ); + } + + const result = await presentationController.generate(body.reponses); + + if ("error" in result) { + return c.json(result, result.status as 400 | 500); + } + + return c.json(result.data, 200); +}); + +export default presentations; diff --git a/src/routes/transcriptions.ts b/src/routes/transcriptions.ts new file mode 100644 index 0000000..7a7cd65 --- /dev/null +++ b/src/routes/transcriptions.ts @@ -0,0 +1,36 @@ +import { Hono } from "hono"; +import { authMiddleware } from "../middleware/auth.js"; +import type { AppVariables } from "../middleware/auth.js"; +import { createTemporaryToken } from "../lib/deepgram.js"; + +// Sprint 4b — POST /transcriptions/token +// +// Délivre un token Deepgram éphémère (10 min) que le frontend utilise pour +// ouvrir une connexion directe à l'API Deepgram. La clé maître DEEPGRAM_API_KEY +// reste côté serveur. Aucun proxy WebSocket — la transcription live est gérée +// navigateur ↔ Deepgram. + +const TOKEN_TTL_SECONDS = 600; + +const transcriptions = new Hono<{ Variables: AppVariables }>(); + +transcriptions.post("/token", authMiddleware, async (c) => { + try { + const { token, expires_in } = await createTemporaryToken(TOKEN_TTL_SECONDS); + return c.json({ token, expires_in }, 200); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[transcriptions.token] generation failed", { message }); + return c.json( + { + error: true, + code: "INTERNAL_ERROR", + message: + "Impossible de générer le token de transcription. Veuillez réessayer.", + }, + 500, + ); + } +}); + +export default transcriptions; diff --git a/supabase/migrations/006_sprint_4a_eo.sql b/supabase/migrations/006_sprint_4a_eo.sql new file mode 100644 index 0000000..3ebb6f9 --- /dev/null +++ b/supabase/migrations/006_sprint_4a_eo.sql @@ -0,0 +1,38 @@ +-- Sprint 4a → réorienté Sprint 4b — Audio backend abandonné. +-- +-- HISTORIQUE : +-- - Sprint 4a (état initial de cette migration) : créait des policies RLS sur +-- `storage.objects` pour un bucket `audio-productions` destiné à stocker les +-- enregistrements EO côté serveur, après transcription Gemini batch. +-- - Sprint 4b (décision Hermann, 2026-04-25) : changement d'architecture. +-- La transcription live est gérée par Deepgram en connexion DIRECTE depuis le +-- navigateur via un token éphémère (cf. POST /transcriptions/token, route +-- ajoutée Sprint 4b). L'audio brut est téléchargé en local par l'utilisateur +-- et n'est PAS stocké côté serveur. Le backend reçoit uniquement le transcript +-- texte final. +-- +-- Cette migration nettoie les policies RLS Storage qui ont pu être créées en +-- environnement de dev pendant le développement Sprint 4a. Elle est idempotente +-- (DROP IF EXISTS) — sûre à rejouer en dev comme en prod. +-- +-- Aucun bucket Supabase Storage n'est nécessaire pour le pipeline EO. Le champ +-- `productions.audio_url` reste dans le schéma (héritage du projet initial) +-- mais n'est plus alimenté. + +DROP POLICY IF EXISTS "audio_productions_select_owner" + ON storage.objects; + +DROP POLICY IF EXISTS "audio_productions_insert_owner" + ON storage.objects; + +DROP POLICY IF EXISTS "audio_productions_update_owner" + ON storage.objects; + +DROP POLICY IF EXISTS "audio_productions_delete_owner" + ON storage.objects; + +-- Vérification post-migration (purement informative) : +-- SELECT policyname FROM pg_policies +-- WHERE schemaname = 'storage' AND tablename = 'objects' +-- AND policyname LIKE 'audio_productions_%'; +-- → 0 ligne attendue.