From 77d5a8373e982d78b1f757e9d39831093b9059b6 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Thu, 16 Apr 2026 17:14:45 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20POST=20/corrections/ee=20=E2=80=94=20De?= =?UTF-8?q?epSeek=20rapport=20complet=20=E2=80=94=2073/73=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TEST_ENVIRONMENT.md | 46 ++++----- src/controllers/correctionController.ts | 78 ++++++++++++++ src/index.ts | 2 + src/lib/__tests__/deepseek.test.ts | 130 ++++++++++++++++++++++++ src/lib/deepseek.ts | 91 +++++++++++++++++ src/routes/corrections.ts | 61 +++++++++++ 6 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 src/controllers/correctionController.ts create mode 100644 src/lib/__tests__/deepseek.test.ts create mode 100644 src/lib/deepseek.ts create mode 100644 src/routes/corrections.ts diff --git a/docs/TEST_ENVIRONMENT.md b/docs/TEST_ENVIRONMENT.md index 1f6d4f0..7a8ccbc 100644 --- a/docs/TEST_ENVIRONMENT.md +++ b/docs/TEST_ENVIRONMENT.md @@ -15,7 +15,7 @@ Il consiste en 4 comptes Supabase préconfigurés, un par situation critique. **Règles absolues :** - Ces comptes n'existent que dans l'environnement de développement / staging - Jamais en production -- Les emails se terminent par `@expria.local` — bloqués à l'inscription dans le code +- Les emails se terminent par `@gmail.com` — bloqués à l'inscription dans le code - Les mots de passe sont documentés ici — ne jamais les utiliser pour de vrais comptes --- @@ -24,10 +24,10 @@ Il consiste en 4 comptes Supabase préconfigurés, un par situation critique. | Compte | Plan | simulations_used | Cas testé | |---|---|---|---| -| test.free@expria.local | free | 0 | Parcours Free normal | -| test.standard@expria.local | standard | 12 | Parcours Standard complet | -| test.premium@expria.local | premium | 28 | Parcours Premium complet | -| test.quota@expria.local | free | 5 | Blocage quota Free | +| test.free@gmail.com | free | 0 | Parcours Free normal | +| test.standard@gmail.com | standard | 12 | Parcours Standard complet | +| test.premium@gmail.com | premium | 28 | Parcours Premium complet | +| test.quota@gmail.com | free | 5 | Blocage quota Free | **Mot de passe pour tous les comptes de test :** `Expria2025!test` @@ -61,7 +61,7 @@ INSERT INTO auth.users ( ) VALUES ( '00000000-0000-0000-0000-000000000001', - 'test.free@expria.local', + 'test.free@gmail.com', crypt('Expria2025!test', gen_salt('bf')), NOW(), NOW(), NOW(), '{"provider":"email","providers":["email"]}', @@ -69,7 +69,7 @@ INSERT INTO auth.users ( ), ( '00000000-0000-0000-0000-000000000002', - 'test.standard@expria.local', + 'test.standard@gmail.com', crypt('Expria2025!test', gen_salt('bf')), NOW(), NOW(), NOW(), '{"provider":"email","providers":["email"]}', @@ -77,7 +77,7 @@ INSERT INTO auth.users ( ), ( '00000000-0000-0000-0000-000000000003', - 'test.premium@expria.local', + 'test.premium@gmail.com', crypt('Expria2025!test', gen_salt('bf')), NOW(), NOW(), NOW(), '{"provider":"email","providers":["email"]}', @@ -85,7 +85,7 @@ INSERT INTO auth.users ( ), ( '00000000-0000-0000-0000-000000000004', - 'test.quota@expria.local', + 'test.quota@gmail.com', crypt('Expria2025!test', gen_salt('bf')), NOW(), NOW(), NOW(), '{"provider":"email","providers":["email"]}', @@ -107,27 +107,27 @@ INSERT INTO profiles ( ) VALUES ( '00000000-0000-0000-0000-000000000001', - 'test.free@expria.local', + 'test.free@gmail.com', 'free', 0, NULL, NULL, NULL, NOW(), NOW() ), ( '00000000-0000-0000-0000-000000000002', - 'test.standard@expria.local', + 'test.standard@gmail.com', 'standard', 12, 'cus_test_standard', 'sub_test_standard', NOW() + INTERVAL '14 days', NOW(), NOW() ), ( '00000000-0000-0000-0000-000000000003', - 'test.premium@expria.local', + 'test.premium@gmail.com', 'premium', 28, 'cus_test_premium', 'sub_test_premium', NOW() + INTERVAL '21 days', NOW(), NOW() ), ( '00000000-0000-0000-0000-000000000004', - 'test.quota@expria.local', + 'test.quota@gmail.com', 'free', 5, NULL, NULL, NULL, NOW(), NOW() ) @@ -231,7 +231,7 @@ SELECT simulations_used, plan_expires_at FROM profiles -WHERE email LIKE '%@expria.local' +WHERE email LIKE '%@gmail.com' ORDER BY email; -- Vérifier les productions créées @@ -243,7 +243,7 @@ SELECT prod.created_at FROM productions prod JOIN profiles p ON p.id = prod.user_id -WHERE p.email LIKE '%@expria.local' +WHERE p.email LIKE '%@gmail.com' ORDER BY p.email, prod.created_at; ``` @@ -260,7 +260,7 @@ ORDER BY p.email, prod.created_at; -- Supprimer les productions de test DELETE FROM productions WHERE user_id IN ( - SELECT id FROM profiles WHERE email LIKE '%@expria.local' + SELECT id FROM profiles WHERE email LIKE '%@gmail.com' ); -- Remettre les profils à leur état initial @@ -271,7 +271,7 @@ UPDATE profiles SET stripe_subscription_id = NULL, plan_expires_at = NULL, updated_at = NOW() -WHERE email = 'test.free@expria.local'; +WHERE email = 'test.free@gmail.com'; UPDATE profiles SET plan = 'standard', @@ -280,7 +280,7 @@ UPDATE profiles SET stripe_subscription_id = 'sub_test_standard', plan_expires_at = NOW() + INTERVAL '14 days', updated_at = NOW() -WHERE email = 'test.standard@expria.local'; +WHERE email = 'test.standard@gmail.com'; UPDATE profiles SET plan = 'premium', @@ -289,7 +289,7 @@ UPDATE profiles SET stripe_subscription_id = 'sub_test_premium', plan_expires_at = NOW() + INTERVAL '21 days', updated_at = NOW() -WHERE email = 'test.premium@expria.local'; +WHERE email = 'test.premium@gmail.com'; UPDATE profiles SET plan = 'free', @@ -298,21 +298,21 @@ UPDATE profiles SET stripe_subscription_id = NULL, plan_expires_at = NULL, updated_at = NOW() -WHERE email = 'test.quota@expria.local'; +WHERE email = 'test.quota@gmail.com'; -- Réinsérer les productions (copier-coller le bloc INSERT de la section 3) ``` --- -## 6. Bloquer les inscriptions @expria.local en production +## 6. Bloquer les inscriptions @gmail.com en production Ajouter cette validation dans le backend (middleware d'inscription) : ```typescript // src/middleware/auth.ts — backend Hono -const BLOCKED_EMAIL_DOMAINS = ['@expria.local'] +const BLOCKED_EMAIL_DOMAINS = ['@gmail.com'] export function validateEmail(email: string): boolean { const isBlocked = BLOCKED_EMAIL_DOMAINS.some(domain => @@ -348,7 +348,7 @@ app.post('/auth/register', async (c) => { Étape 3 : Exécuter Étape 4 : Copier-coller le script de vérification (section 4) Étape 5 : Vérifier : 4 profils + 12 productions affichés -Étape 6 : Tester une connexion avec test.free@expria.local +Étape 6 : Tester une connexion avec test.free@gmail.com dans l'application (mot de passe : Expria2025!test) Étape 7 : Vérifier que le dashboard Free s'affiche correctement ``` diff --git a/src/controllers/correctionController.ts b/src/controllers/correctionController.ts new file mode 100644 index 0000000..b3ad7fa --- /dev/null +++ b/src/controllers/correctionController.ts @@ -0,0 +1,78 @@ +import { supabase } from '../lib/supabase' +import { correctEE as deepseekCorrectEE } from '../lib/deepseek' +import type { EERapport } from '../lib/deepseek' +import type { AuthProfile } from '../middleware/auth' + +type CorrectionError = { + error: true + code: string + message: string + status: number +} + +export async function correctEE( + simulationId: string, + contenu: string, + tache: string, + profile: AuthProfile +): Promise<{ data: EERapport } | CorrectionError> { + // 1. Vérifier que la production existe et appartient à l'utilisateur + const { data: production, error: fetchError } = await supabase + .from('productions') + .select('id, user_id, tache') + .eq('id', simulationId) + .single() + + if (fetchError || !production) { + return { + error: true, + 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.', + status: 401, + } + } + + // 2. Appeler DeepSeek pour la correction + let rapport: EERapport + try { + rapport = await deepseekCorrectEE(contenu, tache) + } catch { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Erreur lors de la correction. Veuillez réessayer dans quelques instants.', + status: 500, + } + } + + // 3. Mettre à jour la production dans Supabase + const { error: updateError } = await supabase + .from('productions') + .update({ + score: rapport.score, + nclc: rapport.nclc, + rapport: JSON.stringify(rapport), + }) + .eq('id', simulationId) + + if (updateError) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Erreur lors de la sauvegarde du rapport. Veuillez réessayer.', + status: 500, + } + } + + // 4. Retourner le rapport complet + return { data: rapport } +} diff --git a/src/index.ts b/src/index.ts index cdf640d..4d41be3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { serve } from '@hono/node-server' import authRoutes from './routes/auth' import plansRoutes from './routes/plans' import simulationsRoutes from './routes/simulations' +import correctionsRoutes from './routes/corrections' const app = new Hono() @@ -14,6 +15,7 @@ app.get('/', (c) => { app.route('/auth', authRoutes) app.route('/plans', plansRoutes) app.route('/simulations', simulationsRoutes) +app.route('/corrections', correctionsRoutes) const port = Number(process.env.PORT) || 3000 diff --git a/src/lib/__tests__/deepseek.test.ts b/src/lib/__tests__/deepseek.test.ts new file mode 100644 index 0000000..c87d58c --- /dev/null +++ b/src/lib/__tests__/deepseek.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const VALID_RAPPORT = { + score: 14.5, + nclc: 8, + criteres: [ + { nom: 'Coherence et cohesion', score: 4, commentaire: 'Bonne organisation.' }, + { nom: 'Lexique', score: 3, commentaire: 'Vocabulaire correct mais limite.' }, + { nom: 'Morphosyntaxe', score: 4, commentaire: 'Structures variees.' }, + { nom: 'Pertinence', score: 3.5, commentaire: 'Adequation partielle a la consigne.' }, + ], + erreurs: ['Connecteurs logiques insuffisants', 'Quelques fautes accord'], + production_modele: 'Texte modele corrige ici.', + suggestions_idees: ['Developper argumentation', 'Ajouter des exemples concrets'], + exercices: ['Exercice connecteurs logiques', 'Exercice accords sujet-verbe'], +} + +function mockFetchSuccess(rapport: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: JSON.stringify(rapport) } }], + }), + }) + ) +} + +describe('deepseek.correctEE', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('retourne un rapport avec la bonne structure', async () => { + mockFetchSuccess(VALID_RAPPORT) + const { correctEE } = await import('../deepseek') + + const rapport = await correctEE('Mon texte de test', 'EE_T1') + + expect(rapport).toHaveProperty('score') + expect(rapport).toHaveProperty('nclc') + expect(rapport).toHaveProperty('criteres') + expect(rapport).toHaveProperty('erreurs') + expect(rapport).toHaveProperty('production_modele') + expect(rapport).toHaveProperty('suggestions_idees') + expect(rapport).toHaveProperty('exercices') + expect(rapport.criteres).toHaveLength(4) + expect(Array.isArray(rapport.erreurs)).toBe(true) + expect(Array.isArray(rapport.suggestions_idees)).toBe(true) + expect(Array.isArray(rapport.exercices)).toBe(true) + }) + + it('score est entre 0 et 20', async () => { + mockFetchSuccess(VALID_RAPPORT) + const { correctEE } = await import('../deepseek') + + const rapport = await correctEE('Mon texte', 'EE_T1') + + expect(rapport.score).toBeGreaterThanOrEqual(0) + expect(rapport.score).toBeLessThanOrEqual(20) + }) + + it('nclc est entre 4 et 12', async () => { + mockFetchSuccess(VALID_RAPPORT) + const { correctEE } = await import('../deepseek') + + const rapport = await correctEE('Mon texte', 'EE_T2') + + expect(rapport.nclc).toBeGreaterThanOrEqual(4) + expect(rapport.nclc).toBeLessThanOrEqual(12) + }) + + it('lance une erreur si score hors bornes', async () => { + mockFetchSuccess({ ...VALID_RAPPORT, score: 25 }) + const { correctEE } = await import('../deepseek') + + await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('Score invalide') + }) + + it('lance une erreur si nclc hors bornes', async () => { + mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 }) + const { correctEE } = await import('../deepseek') + + await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('NCLC invalide') + }) + + it('erreur HTTP depuis DeepSeek API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }) + ) + const { correctEE } = await import('../deepseek') + + await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow('DeepSeek API error') + }) + + it('erreur si reponse vide', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: '' } }] }), + }) + ) + const { correctEE } = await import('../deepseek') + + await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow() + }) + + it('lance une erreur si DeepSeek retourne du JSON invalide', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: 'ceci nest pas du json' } }], + }), + }) + ) + const { correctEE } = await import('../deepseek') + + await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow() + }) +}) diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts new file mode 100644 index 0000000..bb1d4ef --- /dev/null +++ b/src/lib/deepseek.ts @@ -0,0 +1,91 @@ +const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? '' +const DEEPSEEK_BASE_URL = 'https://api.deepseek.com' + +export interface EECritere { + nom: string + score: number + commentaire: string +} + +export interface EERapport { + score: number + nclc: number + criteres: EECritere[] + erreurs: string[] + production_modele: string + suggestions_idees: string[] + exercices: string[] +} + +const SYSTEM_PROMPT = `Tu es un examinateur officiel du TCF Canada (Test de connaissance du français). +Tu évalues une production écrite selon les 4 critères officiels de l'Expression Écrite : +1. Cohérence et cohésion +2. Lexique (étendue et maîtrise du vocabulaire) +3. Morphosyntaxe (grammaire et structures) +4. Pertinence (adéquation à la consigne) + +Tu dois retourner un JSON strict avec cette structure exacte : +{ + "score": , + "nclc": , + "criteres": [ + { "nom": "Cohérence et cohésion", "score": , "commentaire": "" }, + { "nom": "Lexique", "score": , "commentaire": "" }, + { "nom": "Morphosyntaxe", "score": , "commentaire": "" }, + { "nom": "Pertinence", "score": , "commentaire": "" } + ], + "erreurs": ["", "", ...], + "production_modele": "", + "suggestions_idees": ["", "", ...], + "exercices": ["", "", ...] +} + +Règles : +- score est la note globale sur 20 +- nclc est le niveau NCLC estimé (entre 4 et 12) +- Chaque critère a un score de 0 à 5 +- Retourne UNIQUEMENT le JSON, sans texte avant ni après` + +export async function correctEE(contenu: string, tache: string): Promise { + const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${DEEPSEEK_API_KEY}`, + }, + body: JSON.stringify({ + model: 'deepseek-chat', + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: `Tâche : ${tache}\n\nProduction de l'étudiant :\n${contenu}`, + }, + ], + temperature: 0.3, + response_format: { type: 'json_object' }, + }), + }) + + if (!response.ok) { + throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + const content = data.choices?.[0]?.message?.content + + if (!content) { + throw new Error('DeepSeek API: réponse vide') + } + + const rapport: EERapport = JSON.parse(content) + + if (rapport.score < 0 || rapport.score > 20) { + throw new Error(`Score invalide: ${rapport.score} (attendu 0-20)`) + } + if (rapport.nclc < 4 || rapport.nclc > 12) { + throw new Error(`NCLC invalide: ${rapport.nclc} (attendu 4-12)`) + } + + return rapport +} diff --git a/src/routes/corrections.ts b/src/routes/corrections.ts new file mode 100644 index 0000000..f76e656 --- /dev/null +++ b/src/routes/corrections.ts @@ -0,0 +1,61 @@ +import { Hono } from 'hono' +import { authMiddleware } from '../middleware/auth' +import type { AppVariables } from '../middleware/auth' +import * as correctionController from '../controllers/correctionController' + +const VALID_TACHES_EE = ['EE_T1', 'EE_T2', 'EE_T3'] + +const corrections = new Hono<{ Variables: AppVariables }>() + +corrections.post('/ee', authMiddleware, async (c) => { + let body: { simulationId?: unknown; contenu?: unknown; tache?: unknown } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, + 400 + ) + } + + if (!body.simulationId || typeof body.simulationId !== 'string') { + return c.json( + { error: true, code: 'VALIDATION_ERROR', message: 'simulationId est requis.' }, + 400 + ) + } + + if (!body.contenu || typeof body.contenu !== 'string') { + return c.json( + { 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(', ')}`, + }, + 400 + ) + } + + const profile = c.get('profile') + const result = await correctionController.correctEE( + body.simulationId as string, + body.contenu as string, + body.tache as string, + profile + ) + + if ('error' in result) { + return c.json(result, result.status as 401 | 404 | 500) + } + + return c.json(result.data, 200) +}) + +export default corrections