feat(eo): align correction EO on 3.6a format + Deepgram token + T1 presentation generation

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) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 05:04:26 +03:00
parent f5954e6d72
commit 7cac057062
18 changed files with 2907 additions and 911 deletions

View file

@ -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

233
docs/TECH_DEBT-backend.md Normal file
View file

@ -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 |

View file

@ -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<string, unknown>;
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<string, unknown>) => ({
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<string, unknown>) => ({
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);
});
});

View file

@ -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<string, string> = {
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();
});
});

View file

@ -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<void> {
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<void> {
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 } };
}

View file

@ -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<string, unknown>;
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, 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 } };
}

View file

@ -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);

View file

@ -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<CorrectionRapport, 'nclc_cible'> & { erreurs_codes: unknown[] }
} satisfies Omit<CorrectionRapport, "nclc_cible"> & {
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);
});
});

53
src/lib/deepgram.ts Normal file
View file

@ -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<DeepgramToken> {
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 };
}

File diff suppressed because it is too large Load diff

View file

@ -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<string> {
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<string> {
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);
}
}

View file

@ -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),
);
});
});

View file

@ -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);
});
});

View file

@ -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 <key>", 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<string, string>;
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);
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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.