feat: POST /corrections/eo — Gemini transcription + DeepSeek EO — 84/84 tests

This commit is contained in:
Hermann_Kitio 2026-04-16 17:43:46 +03:00
parent 77d5a8373e
commit f4f8c55ce7
7 changed files with 422 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<EERappo
return rapport
}
const SYSTEM_PROMPT_EO = `Tu es un examinateur officiel du TCF Canada (Test de connaissance du français).
Tu évalues une production orale à partir de sa transcription selon les 4 critères officiels de l'Expression Orale :
1. Cohérence et cohésion
2. Lexique (étendue et maîtrise du vocabulaire)
3. Morphosyntaxe (grammaire et structures)
4. Phonologie NOTE IMPORTANTE : ce critère est fixé à 0 car l'évaluation se fait sur une transcription textuelle, pas sur l'audio original. Mets toujours 0 pour ce critère.
Tu dois retourner un JSON strict avec cette structure exacte :
{
"score": <number 0-20>,
"nclc": <number 4-12>,
"criteres": [
{ "nom": "Cohérence et cohésion", "score": <number 0-5>, "commentaire": "<string>" },
{ "nom": "Lexique", "score": <number 0-5>, "commentaire": "<string>" },
{ "nom": "Morphosyntaxe", "score": <number 0-5>, "commentaire": "<string>" },
{ "nom": "Phonologie", "score": 0, "commentaire": "Non évalué sur transcription textuelle." }
],
"erreurs": ["<erreur 1>", "<erreur 2>", ...],
"production_modele": "<version corrigée de la transcription>",
"suggestions_idees": ["<idée 1>", "<idée 2>", ...],
"exercices": ["<exercice recommandé 1>", "<exercice recommandé 2>", ...]
}
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<EORapport> {
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
}

38
src/lib/gemini.ts Normal file
View file

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

View file

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