expria-backend/src/lib/deepseek.ts

917 lines
33 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 : '',
}))
}
// ── Sprint 3.6c — Exercices long terme (patterns Premium) ──────────────
export interface PatternInput {
code: string
critere: Critere
frequency: number
description: string | null
}
export interface PatternExerciceItem {
code: string
critere: Critere
diagnostic: string
exercice: {
consigne: string
exemple: string
correction: string
astuce: string
}
}
const PATTERN_EXERCICES_SYSTEM = `Tu es un coach spécialisé dans la préparation au TCF Canada (Test de connaissance du français).
Un candidat commet systématiquement les mêmes erreurs sur ses 5 dernières productions écrites. Tu dois produire UN exercice ciblé par pattern d'erreur récurrent identifié.
CONTEXTE :
- Ces exercices sont des exercices LONG TERME destinés à corriger des faiblesses structurelles récurrentes.
- Ils sont DISTINCTS des exercices individuels générés après chaque correction (qui ciblent une production spécifique).
- Tu n'as PAS accès au texte du candidat. Tes exemples doivent être génériques et représentatifs de l'erreur.
RÈGLES :
1. Un exercice par pattern en entrée, dans le même ordre.
2. Le diagnostic explique en 1-2 phrases POURQUOI cette erreur est problématique pour le TCF Canada.
3. La consigne demande au candidat de corriger ou reformuler une phrase.
4. L'exemple est une phrase incorrecte illustrant le pattern (inventée, pas tirée du candidat).
5. La correction est la version correcte de l'exemple.
6. L'astuce est un procédé mnémotechnique, une règle pratique ou un réflexe de relecture que le candidat doit appliquer APRÈS avoir rédigé son texte pour détecter et corriger cette erreur lui-même. Formulée comme un conseil direct et actionnable.
Exemples d'astuces :
- Subjonctif : "Après 'bien que', 'pourvu que', 'avant que' → le verbe qui suit est TOUJOURS au subjonctif. Relisez votre texte en cherchant ces expressions."
- Accords : "Relisez chaque phrase en pointant du doigt le sujet et son verbe. S'ils sont éloignés, vérifiez l'accord."
- Connecteurs : "Après rédaction, surlignez tous vos connecteurs. Si le même revient plus de 2 fois, remplacez-en un."
7. Niveau de langue : NCLC 7-9 (ni trop simple, ni trop littéraire).
8. Les exemples doivent être en contexte TCF Canada : courriels, lettres formelles, essais argumentatifs, situations professionnelles canadiennes.
FORMAT DE SORTIE — JSON strict, aucun texte avant ni après :
{
"exercises": [
{
"code": "<code_taxonomie>",
"critere": "<critere>",
"diagnostic": "<1-2 phrases>",
"exercice": {
"consigne": "<instruction au candidat>",
"exemple": "<phrase incorrecte>",
"correction": "<phrase corrigée>",
"astuce": "<procédé mnémotechnique ou réflexe de relecture>"
}
}
]
}`
function buildPatternExercicesUserPrompt(patterns: PatternInput[]): string {
const lines = patterns.map((p) => {
const desc = p.description ? ` — « ${p.description} »` : ''
return `- ${p.code} (${p.critere}) — apparu ${p.frequency}/5 fois${desc}`
})
return `Voici les patterns d'erreurs récurrents détectés sur les 5 dernières productions du candidat :
${lines.join('\n')}
Produis un exercice ciblé par pattern. JSON strict uniquement.`
}
export async function generatePatternExercices(
patterns: PatternInput[],
): Promise<PatternExerciceItem[]> {
if (patterns.length === 0) return []
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: PATTERN_EXERCICES_SYSTEM },
{ role: 'user', content: buildPatternExercicesUserPrompt(patterns) },
],
temperature: 0.4,
response_format: { type: 'json_object' },
}),
signal: AbortSignal.timeout(20_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 { exercises?: unknown }
if (!Array.isArray(parsed.exercises)) {
throw new Error('Réponse DeepSeek invalide : exercises doit être un tableau')
}
const out: PatternExerciceItem[] = []
for (const raw of parsed.exercises as unknown[]) {
const item = raw as Record<string, unknown>
const ex = item.exercice as Record<string, unknown> | undefined
if (
typeof item.code !== 'string' ||
typeof item.critere !== 'string' ||
typeof item.diagnostic !== 'string' ||
!ex ||
typeof ex.consigne !== 'string' ||
typeof ex.exemple !== 'string' ||
typeof ex.correction !== 'string' ||
typeof ex.astuce !== 'string'
) {
continue
}
if (!isValidCritere(item.critere)) continue
out.push({
code: item.code,
critere: item.critere,
diagnostic: item.diagnostic,
exercice: {
consigne: ex.consigne,
exemple: ex.exemple,
correction: ex.correction,
astuce: ex.astuce,
},
})
}
return out
}
// ── 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