From bd8ab4b72b6eb5ee865aa7d90f66cf0b2d8644f6 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Tue, 21 Apr 2026 03:05:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(sujets):=20POST=20/sujets/idees=20?= =?UTF-8?q?=E2=80=94=20suggestions=20DeepSeek=20(G5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/deepseek.ts | 61 +++++++++++++++++++++++++++++++++++++++ src/routes/sujets.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index 05cd0c3..e413626 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -146,6 +146,67 @@ Règles : - Phonologie est toujours à 0 avec le commentaire "Non évalué sur transcription textuelle." - 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": ["", "", ...] } + +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 { + 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 { const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, { method: 'POST', diff --git a/src/routes/sujets.ts b/src/routes/sujets.ts index c1903dc..c0c5fe7 100644 --- a/src/routes/sujets.ts +++ b/src/routes/sujets.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { authMiddleware } from '../middleware/auth.js' import type { AppVariables } from '../middleware/auth.js' import { supabase } from '../lib/supabase.js' +import { generateIdees } from '../lib/deepseek.js' /** * 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 * 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). + * + * 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_TACHES = [1, 2, 3] as const @@ -69,4 +80,61 @@ sujets.get('/', authMiddleware, async (c) => { 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