feat(sujets): POST /sujets/idees — suggestions DeepSeek (G5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ecb478e10c
commit
bd8ab4b72b
2 changed files with 129 additions and 0 deletions
|
|
@ -146,6 +146,67 @@ Règles :
|
||||||
- Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle."
|
- Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle."
|
||||||
- Retourne UNIQUEMENT le JSON, sans texte avant ni après`
|
- Retourne UNIQUEMENT le JSON, sans texte avant ni après`
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_IDEES = `Tu es un coach TCF Canada. Tu aides un étudiant à continuer sa rédaction en cours.
|
||||||
|
Tu dois retourner UNIQUEMENT un JSON strict : { "idees": ["<idée 1>", "<idée 2>", ...] }
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Exactement 5 idées courtes et concrètes (1 phrase max chacune)
|
||||||
|
- Les idées doivent prolonger ce que l'étudiant a déjà écrit, sans répéter
|
||||||
|
- Rester en français, ton encourageant, orienté action
|
||||||
|
- Aucun texte avant ni après le JSON`
|
||||||
|
|
||||||
|
export async function generateIdees(consigne: string, contenu: string): Promise<string[]> {
|
||||||
|
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_IDEES },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Sujet : ${consigne}\n\nCe que l'étudiant a écrit jusqu'ici :\n${contenu}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.5,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
choices?: { message?: { content?: string } }[]
|
||||||
|
}
|
||||||
|
const content = data.choices?.[0]?.message?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('DeepSeek API: réponse vide')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content) as { idees?: unknown }
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed.idees) || parsed.idees.length === 0) {
|
||||||
|
throw new Error('Réponse DeepSeek invalide : idees doit être un tableau non vide')
|
||||||
|
}
|
||||||
|
|
||||||
|
const idees = parsed.idees.filter(
|
||||||
|
(i): i is string => typeof i === 'string' && i.trim().length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (idees.length === 0) {
|
||||||
|
throw new Error('Réponse DeepSeek invalide : aucune idée exploitable')
|
||||||
|
}
|
||||||
|
|
||||||
|
return idees
|
||||||
|
}
|
||||||
|
|
||||||
export async function correctEO(transcript: string, tache: string): Promise<EORapport> {
|
export async function correctEO(transcript: string, tache: string): Promise<EORapport> {
|
||||||
const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {
|
const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||||
import { authMiddleware } from '../middleware/auth.js'
|
import { authMiddleware } from '../middleware/auth.js'
|
||||||
import type { AppVariables } from '../middleware/auth.js'
|
import type { AppVariables } from '../middleware/auth.js'
|
||||||
import { supabase } from '../lib/supabase.js'
|
import { supabase } from '../lib/supabase.js'
|
||||||
|
import { generateIdees } from '../lib/deepseek.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes de la table `sujets` — catalogue des consignes d'examen.
|
* Routes de la table `sujets` — catalogue des consignes d'examen.
|
||||||
|
|
@ -9,8 +10,18 @@ import { supabase } from '../lib/supabase.js'
|
||||||
* GET /sujets?mode=EE|EO&tache=1|2|3
|
* GET /sujets?mode=EE|EO&tache=1|2|3
|
||||||
* Retourne la liste des sujets actifs pour la paire (mode, tache).
|
* Retourne la liste des sujets actifs pour la paire (mode, tache).
|
||||||
* Utilisé par l'écran de choix de sujet côté frontend (tâche G4).
|
* Utilisé par l'écran de choix de sujet côté frontend (tâche G4).
|
||||||
|
*
|
||||||
|
* POST /sujets/idees
|
||||||
|
* Génère 5 suggestions d'idées via DeepSeek pour aider l'étudiant
|
||||||
|
* à continuer sa rédaction en cours (tâche G5).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const MIN_WORDS_IDEES = 30
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.trim().split(/\s+/).filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_MODES = ['EE', 'EO'] as const
|
const VALID_MODES = ['EE', 'EO'] as const
|
||||||
const VALID_TACHES = [1, 2, 3] as const
|
const VALID_TACHES = [1, 2, 3] as const
|
||||||
|
|
||||||
|
|
@ -69,4 +80,61 @@ sujets.get('/', authMiddleware, async (c) => {
|
||||||
return c.json({ sujets: data ?? [] }, 200)
|
return c.json({ sujets: data ?? [] }, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sujets.post('/idees', authMiddleware, async (c) => {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await c.req.json()
|
||||||
|
} catch {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Corps de requête JSON invalide.',
|
||||||
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sujet_consigne, contenu_partiel } = (body ?? {}) as {
|
||||||
|
sujet_consigne?: unknown
|
||||||
|
contenu_partiel?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof sujet_consigne !== 'string' || sujet_consigne.trim().length === 0) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'sujet_consigne requis (string non vide).',
|
||||||
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof contenu_partiel !== 'string' || countWords(contenu_partiel) < MIN_WORDS_IDEES) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: `Le contenu doit comporter au moins ${MIN_WORDS_IDEES} mots.`,
|
||||||
|
},
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idees = await generateIdees(sujet_consigne, contenu_partiel)
|
||||||
|
return c.json({ idees }, 200)
|
||||||
|
} catch {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default sujets
|
export default sujets
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue