From f4f8c55ce78c1792e6979553c1a51d6063a842fc Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Thu, 16 Apr 2026 17:43:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20POST=20/corrections/eo=20=E2=80=94=20Ge?= =?UTF-8?q?mini=20transcription=20+=20DeepSeek=20EO=20=E2=80=94=2084/84=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVELOPMENT_PRINCIPLES.md | 5 ++ src/controllers/correctionController.ts | 72 +++++++++++++++++- src/lib/__tests__/deepseek.test.ts | 97 +++++++++++++++++++++++++ src/lib/__tests__/gemini.test.ts | 71 ++++++++++++++++++ src/lib/deepseek.ts | 89 +++++++++++++++++++++++ src/lib/gemini.ts | 38 ++++++++++ src/routes/corrections.ts | 52 +++++++++++++ 7 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/gemini.test.ts create mode 100644 src/lib/gemini.ts diff --git a/docs/DEVELOPMENT_PRINCIPLES.md b/docs/DEVELOPMENT_PRINCIPLES.md index 263399c..eb604de 100644 --- a/docs/DEVELOPMENT_PRINCIPLES.md +++ b/docs/DEVELOPMENT_PRINCIPLES.md @@ -118,6 +118,11 @@ Si pendant l'implémentation Claude Code réalise que le plan doit être modifi il STOP, signale le changement, explique pourquoi, et attend une nouvelle validation. Il ne prend jamais de décision architecturale de sa propre initiative. +### Règle I — Pas de worktree Git +Claude Code ne crée jamais de worktree Git (`git worktree add`). +Toutes les modifications se font directement dans le dossier +du projet principal. + --- ## 3. Structure du code — conventions diff --git a/src/controllers/correctionController.ts b/src/controllers/correctionController.ts index b3ad7fa..1b0109f 100644 --- a/src/controllers/correctionController.ts +++ b/src/controllers/correctionController.ts @@ -1,6 +1,6 @@ import { supabase } from '../lib/supabase' -import { correctEE as deepseekCorrectEE } from '../lib/deepseek' -import type { EERapport } from '../lib/deepseek' +import { correctEE as deepseekCorrectEE, correctEO as deepseekCorrectEO } from '../lib/deepseek' +import type { EERapport, EORapport } from '../lib/deepseek' import type { AuthProfile } from '../middleware/auth' type CorrectionError = { @@ -76,3 +76,71 @@ export async function correctEE( // 4. Retourner le rapport complet return { data: rapport } } + +export async function correctEO( + simulationId: string, + transcript: string, + tache: string, + profile: AuthProfile +): Promise<{ data: EORapport } | 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 EO + let rapport: EORapport + try { + rapport = await deepseekCorrectEO(transcript, 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({ + contenu: transcript, + 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/lib/__tests__/deepseek.test.ts b/src/lib/__tests__/deepseek.test.ts index c87d58c..3c37daa 100644 --- a/src/lib/__tests__/deepseek.test.ts +++ b/src/lib/__tests__/deepseek.test.ts @@ -128,3 +128,100 @@ describe('deepseek.correctEE', () => { await expect(correctEE('Texte', 'EE_T1')).rejects.toThrow() }) }) + +const VALID_RAPPORT_EO = { + score: 12, + nclc: 7, + 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.' }, + ], + erreurs: ['Hesitations frequentes', 'Registre parfois familier'], + production_modele: 'Transcription corrigee ici.', + suggestions_idees: ['Structurer les reponses', 'Enrichir le vocabulaire'], + exercices: ['Exercice fluidite orale', 'Exercice registre formel'], +} + +describe('deepseek.correctEO', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('retourne un rapport EO avec la bonne structure', async () => { + mockFetchSuccess(VALID_RAPPORT_EO) + const { correctEO } = await import('../deepseek') + + const rapport = await correctEO('Ma transcription orale', 'EO_T1') + + expect(rapport).toHaveProperty('score') + expect(rapport).toHaveProperty('nclc') + expect(rapport).toHaveProperty('criteres') + expect(rapport.criteres).toHaveLength(4) + expect(rapport).toHaveProperty('erreurs') + expect(rapport).toHaveProperty('production_modele') + expect(rapport).toHaveProperty('suggestions_idees') + expect(rapport).toHaveProperty('exercices') + }) + + it('phonologie est a 0', async () => { + mockFetchSuccess(VALID_RAPPORT_EO) + const { correctEO } = await import('../deepseek') + + const rapport = await correctEO('Ma transcription', 'EO_T1') + + const phonologie = rapport.criteres.find((c) => c.nom === 'Phonologie') + expect(phonologie).toBeDefined() + expect(phonologie!.score).toBe(0) + }) + + it('score est entre 0 et 20', async () => { + mockFetchSuccess(VALID_RAPPORT_EO) + const { correctEO } = await import('../deepseek') + + const rapport = await correctEO('Ma transcription', 'EO_T3') + + expect(rapport.score).toBeGreaterThanOrEqual(0) + expect(rapport.score).toBeLessThanOrEqual(20) + }) + + it('nclc est entre 4 et 12', async () => { + mockFetchSuccess(VALID_RAPPORT_EO) + const { correctEO } = await import('../deepseek') + + const rapport = await correctEO('Ma transcription', 'EO_T1') + + expect(rapport.nclc).toBeGreaterThanOrEqual(4) + expect(rapport.nclc).toBeLessThanOrEqual(12) + }) + + it('lance une erreur si score hors bornes', async () => { + mockFetchSuccess({ ...VALID_RAPPORT_EO, score: 25 }) + const { correctEO } = await import('../deepseek') + + await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('Score invalide') + }) + + it('lance une erreur si nclc hors bornes', async () => { + mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 }) + const { correctEO } = await import('../deepseek') + + await expect(correctEO('Transcription', 'EO_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 { correctEO } = await import('../deepseek') + + await expect(correctEO('Transcription', 'EO_T1')).rejects.toThrow('DeepSeek API error') + }) +}) diff --git a/src/lib/__tests__/gemini.test.ts b/src/lib/__tests__/gemini.test.ts new file mode 100644 index 0000000..488a7c1 --- /dev/null +++ b/src/lib/__tests__/gemini.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +function mockFetchSuccess(text: string) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text }] } }], + }), + }) + ) +} + +describe('gemini.transcribeAudio', () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('retourne une transcription non vide sur succes', async () => { + mockFetchSuccess('Bonjour, je suis candidat au TCF Canada.') + const { transcribeAudio } = await import('../gemini') + + const result = await transcribeAudio('base64audio', 'audio/webm') + + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + expect(result).toBe('Bonjour, je suis candidat au TCF Canada.') + }) + + it('erreur HTTP depuis Gemini API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }) + ) + const { transcribeAudio } = await import('../gemini') + + await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow( + 'Gemini API error' + ) + }) + + it('erreur si transcription vide', async () => { + mockFetchSuccess('') + const { transcribeAudio } = await import('../gemini') + + await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow( + 'Gemini API: transcription vide' + ) + }) + + it('erreur si reponse sans candidates', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ candidates: [] }), + }) + ) + const { transcribeAudio } = await import('../gemini') + + await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow( + 'Gemini API: transcription vide' + ) + }) +}) diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index bb1d4ef..322a6f2 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -17,6 +17,22 @@ export interface EERapport { exercices: string[] } +export interface EOCritere { + nom: string + score: number + commentaire: string +} + +export interface EORapport { + score: number + nclc: number + criteres: EOCritere[] + 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 @@ -89,3 +105,76 @@ export async function correctEE(contenu: string, tache: string): Promise, + "nclc": , + "criteres": [ + { "nom": "Cohérence et cohésion", "score": , "commentaire": "" }, + { "nom": "Lexique", "score": , "commentaire": "" }, + { "nom": "Morphosyntaxe", "score": , "commentaire": "" }, + { "nom": "Phonologie", "score": 0, "commentaire": "Non évalué sur transcription textuelle." } + ], + "erreurs": ["", "", ...], + "production_modele": "", + "suggestions_idees": ["", "", ...], + "exercices": ["", "", ...] +} + +Règles : +- score est la note globale sur 20 (basée uniquement sur les 3 critères évalués) +- nclc est le niveau NCLC estimé (entre 4 et 12) +- Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle." +- Retourne UNIQUEMENT le JSON, sans texte avant ni après` + +export async function correctEO(transcript: string, tache: string): Promise { + const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${DEEPSEEK_API_KEY}`, + }, + body: JSON.stringify({ + model: 'deepseek-chat', + messages: [ + { role: 'system', content: SYSTEM_PROMPT_EO }, + { + role: 'user', + content: `Tâche : ${tache}\n\nTranscription de la production orale :\n${transcript}`, + }, + ], + temperature: 0.3, + response_format: { type: 'json_object' }, + }), + }) + + if (!response.ok) { + throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + const content = data.choices?.[0]?.message?.content + + if (!content) { + throw new Error('DeepSeek API: réponse vide') + } + + const rapport: EORapport = 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/lib/gemini.ts b/src/lib/gemini.ts new file mode 100644 index 0000000..8c74dac --- /dev/null +++ b/src/lib/gemini.ts @@ -0,0 +1,38 @@ +const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? '' +const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta' + +export async function transcribeAudio( + audioBase64: string, + mimeType: string +): Promise { + const response = await fetch( + `${GEMINI_BASE_URL}/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { inlineData: { mimeType, data: audioBase64 } }, + { text: 'Transcris cet audio mot pour mot en francais. Retourne uniquement la transcription, sans commentaire.' }, + ], + }, + ], + }), + } + ) + + if (!response.ok) { + throw new Error(`Gemini API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + 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') + } + + return text.trim() +} diff --git a/src/routes/corrections.ts b/src/routes/corrections.ts index f76e656..24031e7 100644 --- a/src/routes/corrections.ts +++ b/src/routes/corrections.ts @@ -4,6 +4,7 @@ import type { AppVariables } from '../middleware/auth' import * as correctionController from '../controllers/correctionController' const VALID_TACHES_EE = ['EE_T1', 'EE_T2', 'EE_T3'] +const VALID_TACHES_EO = ['EO_T1', 'EO_T3'] const corrections = new Hono<{ Variables: AppVariables }>() @@ -58,4 +59,55 @@ corrections.post('/ee', authMiddleware, async (c) => { return c.json(result.data, 200) }) +corrections.post('/eo', authMiddleware, async (c) => { + let body: { simulationId?: unknown; transcript?: 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.transcript || typeof body.transcript !== 'string') { + return c.json( + { 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(', ')}`, + }, + 400 + ) + } + + const profile = c.get('profile') + const result = await correctionController.correctEO( + body.simulationId as string, + body.transcript 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