feat: POST /corrections/ee — DeepSeek rapport complet — 73/73 tests
This commit is contained in:
parent
a6ee76d4a8
commit
77d5a8373e
6 changed files with 385 additions and 23 deletions
|
|
@ -15,7 +15,7 @@ Il consiste en 4 comptes Supabase préconfigurés, un par situation critique.
|
||||||
**Règles absolues :**
|
**Règles absolues :**
|
||||||
- Ces comptes n'existent que dans l'environnement de développement / staging
|
- Ces comptes n'existent que dans l'environnement de développement / staging
|
||||||
- Jamais en production
|
- 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
|
- 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é |
|
| Compte | Plan | simulations_used | Cas testé |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| test.free@expria.local | free | 0 | Parcours Free normal |
|
| test.free@gmail.com | free | 0 | Parcours Free normal |
|
||||||
| test.standard@expria.local | standard | 12 | Parcours Standard complet |
|
| test.standard@gmail.com | standard | 12 | Parcours Standard complet |
|
||||||
| test.premium@expria.local | premium | 28 | Parcours Premium complet |
|
| test.premium@gmail.com | premium | 28 | Parcours Premium complet |
|
||||||
| test.quota@expria.local | free | 5 | Blocage quota Free |
|
| test.quota@gmail.com | free | 5 | Blocage quota Free |
|
||||||
|
|
||||||
**Mot de passe pour tous les comptes de test :** `Expria2025!test`
|
**Mot de passe pour tous les comptes de test :** `Expria2025!test`
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ INSERT INTO auth.users (
|
||||||
) VALUES
|
) VALUES
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000001',
|
||||||
'test.free@expria.local',
|
'test.free@gmail.com',
|
||||||
crypt('Expria2025!test', gen_salt('bf')),
|
crypt('Expria2025!test', gen_salt('bf')),
|
||||||
NOW(), NOW(), NOW(),
|
NOW(), NOW(), NOW(),
|
||||||
'{"provider":"email","providers":["email"]}',
|
'{"provider":"email","providers":["email"]}',
|
||||||
|
|
@ -69,7 +69,7 @@ INSERT INTO auth.users (
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000002',
|
'00000000-0000-0000-0000-000000000002',
|
||||||
'test.standard@expria.local',
|
'test.standard@gmail.com',
|
||||||
crypt('Expria2025!test', gen_salt('bf')),
|
crypt('Expria2025!test', gen_salt('bf')),
|
||||||
NOW(), NOW(), NOW(),
|
NOW(), NOW(), NOW(),
|
||||||
'{"provider":"email","providers":["email"]}',
|
'{"provider":"email","providers":["email"]}',
|
||||||
|
|
@ -77,7 +77,7 @@ INSERT INTO auth.users (
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000003',
|
'00000000-0000-0000-0000-000000000003',
|
||||||
'test.premium@expria.local',
|
'test.premium@gmail.com',
|
||||||
crypt('Expria2025!test', gen_salt('bf')),
|
crypt('Expria2025!test', gen_salt('bf')),
|
||||||
NOW(), NOW(), NOW(),
|
NOW(), NOW(), NOW(),
|
||||||
'{"provider":"email","providers":["email"]}',
|
'{"provider":"email","providers":["email"]}',
|
||||||
|
|
@ -85,7 +85,7 @@ INSERT INTO auth.users (
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000004',
|
'00000000-0000-0000-0000-000000000004',
|
||||||
'test.quota@expria.local',
|
'test.quota@gmail.com',
|
||||||
crypt('Expria2025!test', gen_salt('bf')),
|
crypt('Expria2025!test', gen_salt('bf')),
|
||||||
NOW(), NOW(), NOW(),
|
NOW(), NOW(), NOW(),
|
||||||
'{"provider":"email","providers":["email"]}',
|
'{"provider":"email","providers":["email"]}',
|
||||||
|
|
@ -107,27 +107,27 @@ INSERT INTO profiles (
|
||||||
) VALUES
|
) VALUES
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000001',
|
||||||
'test.free@expria.local',
|
'test.free@gmail.com',
|
||||||
'free', 0, NULL, NULL, NULL,
|
'free', 0, NULL, NULL, NULL,
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000002',
|
'00000000-0000-0000-0000-000000000002',
|
||||||
'test.standard@expria.local',
|
'test.standard@gmail.com',
|
||||||
'standard', 12, 'cus_test_standard', 'sub_test_standard',
|
'standard', 12, 'cus_test_standard', 'sub_test_standard',
|
||||||
NOW() + INTERVAL '14 days',
|
NOW() + INTERVAL '14 days',
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000003',
|
'00000000-0000-0000-0000-000000000003',
|
||||||
'test.premium@expria.local',
|
'test.premium@gmail.com',
|
||||||
'premium', 28, 'cus_test_premium', 'sub_test_premium',
|
'premium', 28, 'cus_test_premium', 'sub_test_premium',
|
||||||
NOW() + INTERVAL '21 days',
|
NOW() + INTERVAL '21 days',
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
'00000000-0000-0000-0000-000000000004',
|
'00000000-0000-0000-0000-000000000004',
|
||||||
'test.quota@expria.local',
|
'test.quota@gmail.com',
|
||||||
'free', 5, NULL, NULL, NULL,
|
'free', 5, NULL, NULL, NULL,
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
|
|
@ -231,7 +231,7 @@ SELECT
|
||||||
simulations_used,
|
simulations_used,
|
||||||
plan_expires_at
|
plan_expires_at
|
||||||
FROM profiles
|
FROM profiles
|
||||||
WHERE email LIKE '%@expria.local'
|
WHERE email LIKE '%@gmail.com'
|
||||||
ORDER BY email;
|
ORDER BY email;
|
||||||
|
|
||||||
-- Vérifier les productions créées
|
-- Vérifier les productions créées
|
||||||
|
|
@ -243,7 +243,7 @@ SELECT
|
||||||
prod.created_at
|
prod.created_at
|
||||||
FROM productions prod
|
FROM productions prod
|
||||||
JOIN profiles p ON p.id = prod.user_id
|
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;
|
ORDER BY p.email, prod.created_at;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -260,7 +260,7 @@ ORDER BY p.email, prod.created_at;
|
||||||
-- Supprimer les productions de test
|
-- Supprimer les productions de test
|
||||||
DELETE FROM productions
|
DELETE FROM productions
|
||||||
WHERE user_id IN (
|
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
|
-- Remettre les profils à leur état initial
|
||||||
|
|
@ -271,7 +271,7 @@ UPDATE profiles SET
|
||||||
stripe_subscription_id = NULL,
|
stripe_subscription_id = NULL,
|
||||||
plan_expires_at = NULL,
|
plan_expires_at = NULL,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE email = 'test.free@expria.local';
|
WHERE email = 'test.free@gmail.com';
|
||||||
|
|
||||||
UPDATE profiles SET
|
UPDATE profiles SET
|
||||||
plan = 'standard',
|
plan = 'standard',
|
||||||
|
|
@ -280,7 +280,7 @@ UPDATE profiles SET
|
||||||
stripe_subscription_id = 'sub_test_standard',
|
stripe_subscription_id = 'sub_test_standard',
|
||||||
plan_expires_at = NOW() + INTERVAL '14 days',
|
plan_expires_at = NOW() + INTERVAL '14 days',
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE email = 'test.standard@expria.local';
|
WHERE email = 'test.standard@gmail.com';
|
||||||
|
|
||||||
UPDATE profiles SET
|
UPDATE profiles SET
|
||||||
plan = 'premium',
|
plan = 'premium',
|
||||||
|
|
@ -289,7 +289,7 @@ UPDATE profiles SET
|
||||||
stripe_subscription_id = 'sub_test_premium',
|
stripe_subscription_id = 'sub_test_premium',
|
||||||
plan_expires_at = NOW() + INTERVAL '21 days',
|
plan_expires_at = NOW() + INTERVAL '21 days',
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE email = 'test.premium@expria.local';
|
WHERE email = 'test.premium@gmail.com';
|
||||||
|
|
||||||
UPDATE profiles SET
|
UPDATE profiles SET
|
||||||
plan = 'free',
|
plan = 'free',
|
||||||
|
|
@ -298,21 +298,21 @@ UPDATE profiles SET
|
||||||
stripe_subscription_id = NULL,
|
stripe_subscription_id = NULL,
|
||||||
plan_expires_at = NULL,
|
plan_expires_at = NULL,
|
||||||
updated_at = NOW()
|
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)
|
-- 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) :
|
Ajouter cette validation dans le backend (middleware d'inscription) :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/middleware/auth.ts — backend Hono
|
// src/middleware/auth.ts — backend Hono
|
||||||
|
|
||||||
const BLOCKED_EMAIL_DOMAINS = ['@expria.local']
|
const BLOCKED_EMAIL_DOMAINS = ['@gmail.com']
|
||||||
|
|
||||||
export function validateEmail(email: string): boolean {
|
export function validateEmail(email: string): boolean {
|
||||||
const isBlocked = BLOCKED_EMAIL_DOMAINS.some(domain =>
|
const isBlocked = BLOCKED_EMAIL_DOMAINS.some(domain =>
|
||||||
|
|
@ -348,7 +348,7 @@ app.post('/auth/register', async (c) => {
|
||||||
Étape 3 : Exécuter
|
Étape 3 : Exécuter
|
||||||
Étape 4 : Copier-coller le script de vérification (section 4)
|
Étape 4 : Copier-coller le script de vérification (section 4)
|
||||||
Étape 5 : Vérifier : 4 profils + 12 productions affichés
|
É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)
|
dans l'application (mot de passe : Expria2025!test)
|
||||||
Étape 7 : Vérifier que le dashboard Free s'affiche correctement
|
Étape 7 : Vérifier que le dashboard Free s'affiche correctement
|
||||||
```
|
```
|
||||||
|
|
|
||||||
78
src/controllers/correctionController.ts
Normal file
78
src/controllers/correctionController.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { serve } from '@hono/node-server'
|
||||||
import authRoutes from './routes/auth'
|
import authRoutes from './routes/auth'
|
||||||
import plansRoutes from './routes/plans'
|
import plansRoutes from './routes/plans'
|
||||||
import simulationsRoutes from './routes/simulations'
|
import simulationsRoutes from './routes/simulations'
|
||||||
|
import correctionsRoutes from './routes/corrections'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ app.get('/', (c) => {
|
||||||
app.route('/auth', authRoutes)
|
app.route('/auth', authRoutes)
|
||||||
app.route('/plans', plansRoutes)
|
app.route('/plans', plansRoutes)
|
||||||
app.route('/simulations', simulationsRoutes)
|
app.route('/simulations', simulationsRoutes)
|
||||||
|
app.route('/corrections', correctionsRoutes)
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3000
|
const port = Number(process.env.PORT) || 3000
|
||||||
|
|
||||||
|
|
|
||||||
130
src/lib/__tests__/deepseek.test.ts
Normal file
130
src/lib/__tests__/deepseek.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
91
src/lib/deepseek.ts
Normal file
91
src/lib/deepseek.ts
Normal file
|
|
@ -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": <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": "Pertinence", "score": <number 0-5>, "commentaire": "<string>" }
|
||||||
|
],
|
||||||
|
"erreurs": ["<erreur 1>", "<erreur 2>", ...],
|
||||||
|
"production_modele": "<texte modèle corrigé>",
|
||||||
|
"suggestions_idees": ["<idée 1>", "<idée 2>", ...],
|
||||||
|
"exercices": ["<exercice recommandé 1>", "<exercice recommandé 2>", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EERapport> {
|
||||||
|
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
|
||||||
|
}
|
||||||
61
src/routes/corrections.ts
Normal file
61
src/routes/corrections.ts
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue