expria-backend/src/lib/deepseek.ts
2026-04-22 17:27:29 +03:00

774 lines
28 KiB
TypeScript

/**
* Client DeepSeek — Sprint 3.6a.
*
* Expose trois appels dédiés à la correction EE (entraînement / examen) :
* 1. `correctEE` → prompt maître (rapport avec revelation, diagnostic,
* critères détaillés, conseil_nclc, erreurs_codes)
* 2. `generateProductionModele` → production modèle réécrite à NCLC 9 (fixe)
* 3. `generateExercices` → 3 exercices ciblés sur les erreurs détectées
*
* Contrat JSON défini par docs/Prompt_maître.md et docs/Prompt_production_modèle.md.
* Codes d'erreurs issus de src/lib/taxonomieErreurs.ts (validation runtime incluse).
*
* EO (Expression Orale) conserve le pipeline V1 monolithique (hors scope Sprint 3.6a).
*/
import {
CRITERES,
CRITERE_LABELS,
NCLC_MIN_SCORE,
buildTaxonomyPromptSection,
isValidCode,
isValidCritere,
type Critere,
} from './taxonomieErreurs.js'
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? ''
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
// ── Types — Sprint 3.6a ──────────────────────────────────────────────────
export type TacheEE = 'EE_T1' | 'EE_T2' | 'EE_T3'
export type NclcCible = 9 | 10
export interface CorrectionInput {
tache: TacheEE
contenu: string
sujet: string | null
sourceDoc1?: string | null
sourceDoc2?: string | null
nclcCible: NclcCible
}
export interface CorrectionCritereDetail {
nom: string
score: number
commentaire: string
exemple: string
suggestion: string
astuce: string
}
export interface ErreurCode {
code: string
critere: Critere
description: string | null
}
export interface CorrectionRapport {
score: number
nclc: number
nclc_cible: NclcCible
revelation: {
croyance: string
realite: string
consequence: string
}
diagnostic: string
criteres: CorrectionCritereDetail[]
conseil_nclc: {
nclc_cible: string
ecart: string
action_prioritaire: string
}
erreurs_codes: ErreurCode[]
}
export interface ProductionModeleInput {
tache: TacheEE
sujet: string | null
texte: string
nclcObtenu: number
}
export interface TransformationItem {
original: string
ameliore: string
explication: string
}
export interface NotePedagogique {
passage: string
explication: string
}
export interface ProductionModele {
production_modele_propre: string
notes_pedagogiques: NotePedagogique[]
transformations: TransformationItem[]
message: string
// Métadonnées ajoutées par le post-traitement serveur
nclc_modele: 9
nclc_obtenu: number
score_cible: number
tcf_word_count: number
tcf_word_min: number
tcf_word_max: number
tcf_truncated: boolean
}
export interface ExercicesInput {
tache: TacheEE
erreursCodes: ErreurCode[]
criteres: CorrectionCritereDetail[]
}
export interface ExerciceItem {
difficulte: 'facile' | 'intermediaire' | 'difficile'
theme: string
diagnostic: string
consigne: string
extrait: string
indice: string
correction: string
explication: string
}
// Longueurs TCF Canada par tâche (docs/Prompt_production_modèle.md §LONGUEUR)
const WORD_LIMITS: Record<TacheEE, { min: number; max: number }> = {
EE_T1: { min: 60, max: 120 },
EE_T2: { min: 120, max: 150 },
EE_T3: { min: 120, max: 180 },
}
const TASK_DESCRIPTIONS: Record<TacheEE, string> = {
EE_T1:
'Tâche 1 — Message / mail / annonce (60-120 mots) : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne.',
EE_T2:
'Tâche 2 — Article de blog / forum (120-150 mots) : compte rendu d\'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif.',
EE_T3:
'Tâche 3 — Texte comparatif (120-180 mots) : Partie 1 (40-60 mots) synthèse des deux points de vue des documents sources ; Partie 2 (80-120 mots) prise de position personnelle argumentée.',
}
// ── Prompts builders ─────────────────────────────────────────────────────
/**
* Prompt maître — correction EE.
* Retourne le couple (system, user) à envoyer à DeepSeek.
*/
export function buildCorrectionPrompt(input: CorrectionInput): {
system: string
user: string
} {
const { tache, contenu, sujet, sourceDoc1, sourceDoc2, nclcCible } = input
const minScore = NCLC_MIN_SCORE[nclcCible]
const taxonomySection = buildTaxonomyPromptSection()
const system = `Tu es un correcteur TCF Canada certifié par France Éducation International. Tu corriges avec précision et bienveillance.
RÈGLES ABSOLUES :
- "exemple" = citation textuelle EXACTE, mot pour mot, extraite de la production du candidat. Jamais inventée.
- "commentaire" = 2 phrases maximum, directes, sans formule introductive.
- Interdit : "Voici", "Bien sûr", "Il convient de", toute formule introductive, tout markdown, tout backtick.
- "score" global = somme exacte des 4 scores critères (0 à 20).
- JSON strict sans aucun texte avant ni après.
CRITÈRES OFFICIELS TCF (chacun noté de 0 à 5) :
1. ${CRITERE_LABELS.adequation_tache} — respect des consignes, longueur, registre, pertinence du contenu.
2. ${CRITERE_LABELS.coherence_cohesion} — structure logique, connecteurs, progression thématique.
3. ${CRITERE_LABELS.competence_lexicale} — étendue du vocabulaire, précision, variété, absence de répétitions excessives.
4. ${CRITERE_LABELS.competence_grammaticale} — correction des structures, morphologie verbale, syntaxe, ponctuation.
${taxonomySection}
FORMAT DE RÉPONSE (JSON strict, aucun autre texte) :
{
"score": <entier 0-20, somme des 4 critères>,
"nclc": <entier 4-12, niveau estimé à partir du score>,
"revelation": {
"croyance": "<ce que le candidat croit faire bien>",
"realite": "<ce que le correcteur observe réellement>",
"consequence": "<impact concret sur la note>"
},
"diagnostic": "<phrase courte et directe identifiant le principal frein>",
"criteres": [
{ "nom": "${CRITERE_LABELS.adequation_tache}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
{ "nom": "${CRITERE_LABELS.coherence_cohesion}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
{ "nom": "${CRITERE_LABELS.competence_lexicale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" },
{ "nom": "${CRITERE_LABELS.competence_grammaticale}", "score": <0-5>, "commentaire": "<2 phrases max>", "exemple": "<citation exacte>", "suggestion": "<reformulation concrète>", "astuce": "<conseil court>" }
],
"conseil_nclc": {
"nclc_cible": "NCLC ${nclcCible}",
"ecart": "<manque X points / objectif atteint / X points au-dessus>",
"action_prioritaire": "<conseil direct, concret et personnalisé>"
},
"erreurs_codes": [
{ "code": "<code taxonomie>", "critere": "<un des 4 critères>", "description": <null OU texte si code="autre"> }
]
}`
const docsBlock =
tache === 'EE_T3' && (sourceDoc1 || sourceDoc2)
? `\n\nDOCUMENTS SOURCES :
Document 1 (point de vue POUR) : ${sourceDoc1 ?? 'Non précisé'}
Document 2 (point de vue CONTRE) : ${sourceDoc2 ?? 'Non précisé'}`
: ''
const user = `OBJECTIF DU CANDIDAT : NCLC ${nclcCible} — score minimum requis : ${minScore}/20.
TÂCHE : ${TASK_DESCRIPTIONS[tache]}${docsBlock}
CONSIGNE / SUJET : ${sujet ?? 'Non précisé'}
PRODUCTION DU CANDIDAT :
"""
${contenu}
"""`
return { system, user }
}
/**
* Prompt production modèle — cible fixe NCLC 9 (cf. consigne Sprint 3.6a).
*/
export function buildModelPrompt(input: ProductionModeleInput): {
system: string
user: string
} {
const { tache, sujet, texte, nclcObtenu } = input
const nclcModele: 9 = 9
const scoreCible = NCLC_MIN_SCORE[nclcModele]
const { min, max } = WORD_LIMITS[tache]
const system = `Tu es un correcteur expert TCF Canada.
Ta mission : réécrire la production du candidat EN CONSERVANT le fond, les idées, le positionnement et les arguments — mais en appliquant parfaitement les 4 critères officiels TCF Canada :
1. Réalisation de la tâche — respecter le format, les limites de mots, la consigne, le registre
2. Cohérence / Structure — paragraphes clairs, connecteurs logiques variés, progression cohérente
3. Étendue du lexique — vocabulaire riche et précis, zéro répétition, registre adapté
4. Maîtrise grammaticale — structures complexes, subjonctif, passif, subordination
RÈGLES ABSOLUES :
- Conserver les idées et arguments du candidat — ne pas inventer
- Respecter STRICTEMENT les limites de mots pour production_modele_propre (maximum : ${max} mots)
- Viser exactement le niveau NCLC ${nclcModele} (score minimum ${scoreCible}/20)
- Aucune annotation dans production_modele_propre (pas de [NOTE:], pas de commentaire entre parenthèses)
- Exactement 3 entrées dans notes_pedagogiques
- Répondre en JSON valide, sans markdown, sans texte avant ni après
COMPTAGE DES MOTS (TCF Canada) :
- Un mot = segment séparé par un espace. L'apostrophe (' ou ') et le tiret (-) ne créent pas un mot supplémentaire.
- Exemples : « c'est », « aujourd'hui », « c'est-à-dire », « vas-y » = 1 mot chacun.
LONGUEUR pour cette tâche : ${min} à ${max} mots — ne pas dépasser ${max}.
FORMAT JSON (strict) :
{
"production_modele_propre": "<texte final, prêt pour l'examen, sans annotation>",
"notes_pedagogiques": [
{ "passage": "<extrait court du texte modèle>", "explication": "<pourquoi efficace au TCF>" }
],
"transformations": [
{ "original": "<extrait du candidat>", "ameliore": "<version améliorée>", "explication": "<pourquoi c'est mieux>" }
],
"message": "<phrase courte encourageante sur les idées du candidat>"
}`
const user = `SUJET : ${sujet ?? 'Non précisé'}
TÂCHE : ${TASK_DESCRIPTIONS[tache]}
PRODUCTION DU CANDIDAT :
${texte}
Le candidat a obtenu NCLC ${nclcObtenu}. Montre-lui comment atteindre NCLC ${nclcModele}.`
return { system, user }
}
/**
* Prompt exercices — 3 exercices ciblés sur les erreurs_codes les plus saillantes.
* Format aligné sur les captures d'écran (cf. plan session).
*/
export function buildExercicesPrompt(input: ExercicesInput): {
system: string
user: string
} {
const { tache, erreursCodes, criteres } = input
const system = `Tu es un coach TCF Canada. Tu produis des micro-exercices ciblés pour faire travailler un candidat sur ses erreurs réelles.
RÈGLES ABSOLUES :
- Produire EXACTEMENT 3 exercices, ciblés sur les 3 codes d'erreurs les plus impactants fournis en entrée.
- "extrait" = citation textuelle exacte du candidat (tirée des champs "exemple" des critères quand pertinent). Jamais inventée.
- "correction" = la version corrigée de "extrait".
- Aucune formule introductive, aucun markdown, aucun backtick.
- JSON strict sans aucun texte avant ni après.
FORMAT JSON :
{
"exercices": [
{
"difficulte": "facile" | "intermediaire" | "difficile",
"theme": "<code taxonomie concerné, ex: accord_sujet_verbe>",
"diagnostic": "<1 phrase : quelle erreur cet exercice cible>",
"consigne": "<instruction claire donnée au candidat>",
"extrait": "<citation exacte du candidat>",
"indice": "<piste courte pour corriger sans donner la réponse>",
"correction": "<la version corrigée attendue>",
"explication": "<pourquoi la correction est meilleure, 2 phrases max>"
}
]
}`
const erreursBlock = erreursCodes
.map((e) => `- ${e.code} (${e.critere})${e.description ? ` : ${e.description}` : ''}`)
.join('\n')
const criteresBlock = criteres
.map((c) => `- ${c.nom} (score ${c.score}/5) — exemple : « ${c.exemple} »`)
.join('\n')
const user = `TÂCHE : ${TASK_DESCRIPTIONS[tache]}
ERREURS DÉTECTÉES DANS LA PRODUCTION :
${erreursBlock || '(aucune erreur listée)'}
EXTRAITS PAR CRITÈRE (pour alimenter "extrait") :
${criteresBlock}
Produis 3 exercices ciblés. Privilégie les codes d'erreurs qui apparaissent le plus souvent, puis les plus impactants pour l'objectif NCLC.`
return { system, user }
}
// ── Post-traitement production modèle ───────────────────────────────────
/**
* Compte des mots TCF Canada : un mot = segment séparé par un espace.
* Apostrophes et tirets ne créent pas de mot supplémentaire.
*/
export function wordCountTCF(text: string): number {
const trimmed = text.trim()
if (trimmed.length === 0) return 0
return trimmed.split(/\s+/).length
}
/**
* Supprime les annotations [NOTE: ...] et les commentaires entre parenthèses
* ajoutés par erreur par DeepSeek malgré la consigne.
*/
export function stripModelAnnotations(text: string): string {
return text
.replace(/\[NOTE:[^\]]*\]/gi, '')
.replace(/\s{2,}/g, ' ')
.trim()
}
/**
* Tronque à `maxWords` mots TCF. Retourne {text, truncated}.
*/
export function truncateToMaxWords(text: string, maxWords: number): { text: string; truncated: boolean } {
const words = text.trim().split(/\s+/)
if (words.length <= maxWords) return { text, truncated: false }
return { text: words.slice(0, maxWords).join(' '), truncated: true }
}
// ── Appels DeepSeek ──────────────────────────────────────────────────────
async function callDeepSeek(system: string, user: string, temperature: number): Promise<string> {
try {
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 },
{ role: 'user', content: user },
],
temperature,
response_format: { type: 'json_object' },
}),
// Le prompt maître + taxonomie produit une réponse JSON longue : DeepSeek
// peut prendre 20-40 s. Le frontend abort à 60 s (CORRECTION_TIMEOUT_MS)
// → on abort ici à 55 s pour laisser une marge côté client.
signal: AbortSignal.timeout(55_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')
}
return content
} catch (err) {
const kind =
err instanceof Error && err.name === 'TimeoutError'
? 'TIMEOUT'
: err instanceof Error && err.name === 'AbortError'
? 'ABORT'
: err instanceof SyntaxError
? 'JSON_PARSE'
: err instanceof TypeError
? 'NETWORK'
: 'OTHER'
const message = err instanceof Error ? err.message : String(err)
console.error(`[deepseek.callDeepSeek] ${kind}${message}`)
throw err
}
}
// ── Validation runtime ───────────────────────────────────────────────────
function validateErreursCodes(raw: unknown): ErreurCode[] {
if (!Array.isArray(raw)) return []
const valid: ErreurCode[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue
const o = item as { code?: unknown; critere?: unknown; description?: unknown }
if (typeof o.code !== 'string' || typeof o.critere !== 'string') continue
if (!isValidCritere(o.critere)) continue
if (!isValidCode(o.critere, o.code)) continue
const description =
typeof o.description === 'string' && o.description.trim().length > 0 ? o.description : null
if (o.code === 'autre' && description === null) continue // autre exige une description
valid.push({ code: o.code, critere: o.critere, description })
}
return valid
}
function validateCorrectionRapport(raw: unknown, nclcCible: NclcCible): CorrectionRapport {
if (typeof raw !== 'object' || raw === null) {
throw new Error('Réponse DeepSeek invalide : racine non-objet')
}
const r = raw as Record<string, unknown>
const score = typeof r.score === 'number' ? r.score : Number(r.score)
if (!Number.isFinite(score) || score < 0 || score > 20) {
throw new Error(`Score invalide: ${String(r.score)} (attendu 0-20)`)
}
const nclc = typeof r.nclc === 'number' ? r.nclc : Number(r.nclc)
if (!Number.isFinite(nclc) || nclc < 4 || nclc > 12) {
throw new Error(`NCLC invalide: ${String(r.nclc)} (attendu 4-12)`)
}
const revelation = r.revelation as Record<string, unknown> | undefined
if (
!revelation ||
typeof revelation.croyance !== 'string' ||
typeof revelation.realite !== 'string' ||
typeof revelation.consequence !== 'string'
) {
throw new Error('revelation invalide : attendu { croyance, realite, consequence } en chaînes')
}
if (typeof r.diagnostic !== 'string' || r.diagnostic.trim().length === 0) {
throw new Error('diagnostic invalide : chaîne non vide attendue')
}
if (!Array.isArray(r.criteres) || r.criteres.length !== 4) {
throw new Error('criteres invalide : 4 entrées attendues')
}
const criteres: CorrectionCritereDetail[] = r.criteres.map((c: unknown, i: number) => {
const o = c as Record<string, unknown>
if (typeof o?.nom !== 'string') throw new Error(`criteres[${i}].nom invalide`)
const cScore = typeof o.score === 'number' ? o.score : Number(o.score)
if (!Number.isFinite(cScore) || cScore < 0 || cScore > 5) {
throw new Error(`criteres[${i}].score invalide`)
}
return {
nom: o.nom,
score: cScore,
commentaire: typeof o.commentaire === 'string' ? o.commentaire : '',
exemple: typeof o.exemple === 'string' ? o.exemple : '',
suggestion: typeof o.suggestion === 'string' ? o.suggestion : '',
astuce: typeof o.astuce === 'string' ? o.astuce : '',
}
})
const conseil = r.conseil_nclc as Record<string, unknown> | undefined
if (
!conseil ||
typeof conseil.nclc_cible !== 'string' ||
typeof conseil.ecart !== 'string' ||
typeof conseil.action_prioritaire !== 'string'
) {
throw new Error('conseil_nclc invalide')
}
const erreursCodes = validateErreursCodes(r.erreurs_codes)
return {
score,
nclc,
nclc_cible: nclcCible,
revelation: {
croyance: revelation.croyance,
realite: revelation.realite,
consequence: revelation.consequence,
},
diagnostic: r.diagnostic,
criteres,
conseil_nclc: {
nclc_cible: conseil.nclc_cible,
ecart: conseil.ecart,
action_prioritaire: conseil.action_prioritaire,
},
erreurs_codes: erreursCodes,
}
}
// ── Fonctions exportées — correction + modèle + exercices ───────────────
export async function correctEE(input: CorrectionInput): Promise<CorrectionRapport> {
const { system, user } = buildCorrectionPrompt(input)
const content = await callDeepSeek(system, user, 0.2)
const parsed: unknown = JSON.parse(content)
return validateCorrectionRapport(parsed, input.nclcCible)
}
export async function generateProductionModele(input: ProductionModeleInput): Promise<ProductionModele> {
const { system, user } = buildModelPrompt(input)
const content = await callDeepSeek(system, user, 0.3)
const parsed = JSON.parse(content) as Record<string, unknown>
if (typeof parsed.production_modele_propre !== 'string') {
throw new Error('production_modele_propre invalide : chaîne attendue')
}
const cleaned = stripModelAnnotations(parsed.production_modele_propre)
const { min, max } = WORD_LIMITS[input.tache]
const { text: final, truncated } = truncateToMaxWords(cleaned, max)
const count = wordCountTCF(final)
const notes = Array.isArray(parsed.notes_pedagogiques)
? (parsed.notes_pedagogiques as unknown[])
.map((n) => n as Record<string, unknown>)
.filter((n) => typeof n.passage === 'string' && typeof n.explication === 'string')
.map((n) => ({ passage: n.passage as string, explication: n.explication as string }))
: []
const transformations = Array.isArray(parsed.transformations)
? (parsed.transformations as unknown[])
.map((t) => t as Record<string, unknown>)
.filter(
(t) =>
typeof t.original === 'string' &&
typeof t.ameliore === 'string' &&
typeof t.explication === 'string'
)
.map((t) => ({
original: t.original as string,
ameliore: t.ameliore as string,
explication: t.explication as string,
}))
: []
return {
production_modele_propre: final,
notes_pedagogiques: notes,
transformations,
message: typeof parsed.message === 'string' ? parsed.message : '',
nclc_modele: 9,
nclc_obtenu: input.nclcObtenu,
score_cible: NCLC_MIN_SCORE[9],
tcf_word_count: count,
tcf_word_min: min,
tcf_word_max: max,
tcf_truncated: truncated,
}
}
export async function generateExercices(input: ExercicesInput): Promise<ExerciceItem[]> {
const { system, user } = buildExercicesPrompt(input)
const content = await callDeepSeek(system, user, 0.4)
const parsed = JSON.parse(content) as { exercices?: unknown }
if (!Array.isArray(parsed.exercices)) {
throw new Error('exercices invalide : tableau attendu')
}
const DIFFICULTES: ExerciceItem['difficulte'][] = ['facile', 'intermediaire', 'difficile']
return (parsed.exercices as unknown[])
.map((e) => e as Record<string, unknown>)
.filter((e) => typeof e.consigne === 'string' && typeof e.correction === 'string')
.map((e) => ({
difficulte: DIFFICULTES.includes(e.difficulte as ExerciceItem['difficulte'])
? (e.difficulte as ExerciceItem['difficulte'])
: 'intermediaire',
theme: typeof e.theme === 'string' ? e.theme : '',
diagnostic: typeof e.diagnostic === 'string' ? e.diagnostic : '',
consigne: e.consigne as string,
extrait: typeof e.extrait === 'string' ? e.extrait : '',
indice: typeof e.indice === 'string' ? e.indice : '',
correction: e.correction as string,
explication: typeof e.explication === 'string' ? e.explication : '',
}))
}
// ── EO (Expression Orale) — inchangé par Sprint 3.6a ────────────────────
export interface EOCritere {
nom: string
score: number
commentaire: string
}
export interface EORapport {
score: number
nclc: number
feedback_court: string
criteres: EOCritere[]
erreurs: string[]
modele: string
idees: string[]
exercices: string[]
}
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>,
"feedback_court": "<2 à 3 lignes de feedback global, orientées action>",
"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>", ...],
"modele": "<version corrigée de la transcription>",
"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)
- feedback_court est un résumé de 2 à 3 lignes, toujours renseigné (visible pour tous les plans)
- 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": ["<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> {
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()) as {
choices?: { message?: { content?: string } }[]
}
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)`)
}
if (typeof rapport.feedback_court !== 'string' || rapport.feedback_court.trim().length === 0) {
throw new Error('feedback_court invalide: attendu une chaîne non vide')
}
return rapport
}
// Alias legacy — temporairement conservé le temps que correctionController.correctEE
// soit migré vers la nouvelle signature (étape E5).
export type EERapport = CorrectionRapport