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

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