expria-backend/src/lib/deepseek.ts

1126 lines
42 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 TacheEO = "EO_T1" | "EO_T3";
export type TacheCorrection = TacheEE | TacheEO;
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[];
// Sprint 4a — champs EO uniquement (présents si tache ∈ EO_T*).
transcription_affichee?: string;
note_phonologie?: string;
}
/**
* Sprint 4a — Labels officiels TCF Canada pour les 4 critères Expression Orale.
* Distincts des labels EE bien que la taxonomie d'erreurs sous-jacente reste
* identique (mappage via le champ `critere` interne adequation_tache, etc.).
*/
export const CRITERE_LABELS_EO: Record<Critere, string> = {
adequation_tache: "Réalisation de la tâche",
coherence_cohesion: "Cohérence et fluidité",
competence_lexicale: "Étendue du lexique",
competence_grammaticale: "Maîtrise grammaticale orale",
};
const EO_NOTE_PHONOLOGIE_DEFAULT =
"Analyse phonologique non disponible pour cette session.";
export interface ProductionModeleInput {
tache: TacheCorrection;
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: TacheCorrection;
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.
// EE : docs/Prompt_production_modèle.md §LONGUEUR.
// EO : équivalent transcript pour un monologue fluide aux durées TCF
// (T1 ~3 min, T3 ~5 min) — sert au gabarit de la production modèle EO.
const WORD_LIMITS: Record<TacheCorrection, { min: number; max: number }> = {
EE_T1: { min: 60, max: 120 },
EE_T2: { min: 120, max: 150 },
EE_T3: { min: 120, max: 180 },
EO_T1: { min: 200, max: 300 },
EO_T3: { min: 450, max: 620 },
};
const TASK_DESCRIPTIONS: Record<TacheCorrection, 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.",
EO_T1:
"T1 — Présentation personnelle (entretien dirigé, 2 minutes) : se présenter, parler de son parcours, de ses projets, de sa motivation. Registre courant, discours fluide et structuré.",
EO_T3:
"T3 — Expression d'un point de vue spontané (4 minutes 30) : exprimer et défendre un point de vue sur une question, illustrer par des exemples concrets, organiser l'argumentation, conclure. Registre courant à standard.",
};
// ── 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 ──────────────────────────────────────────────────────
/**
* Nettoie une réponse DeepSeek avant `JSON.parse`.
*
* Deux dérives observées malgré `response_format: { type: "json_object" }` :
* 1. Wrap markdown ```json … ``` (rare mais arrive).
* 2. Guillemets simples au lieu de doubles (`'key': 'value'`) — voire
* des chevrons « ... ». Diagnostiqué Sprint 4b (correction EO).
*
* Stratégie défensive :
* - Strip systématique des fences markdown.
* - Tentative JSON.parse en l'état → si OK, on retourne tel quel.
* - Sinon : remplacement des single quotes JSON par des doubles, en
* préservant les apostrophes légitimes à l'intérieur des valeurs
* (heuristique : on bascule les `\'` échappés en `'` après le swap).
*/
function sanitizeJsonContent(raw: string): string {
let cleaned = raw
.replace(/```json\s*/gi, "")
.replace(/```\s*/g, "")
.trim();
try {
JSON.parse(cleaned);
return cleaned;
} catch {
// Fallback : DeepSeek a renvoyé du « pseudo-JSON » avec single quotes.
// 1. Remplace toutes les `'` par `"` (suffisant dans la plupart des cas).
// 2. Restaure les apostrophes échappées : `\"` (résultat du swap sur un
// `\'` original) redevient `'`.
cleaned = cleaned.replace(/'/g, '"').replace(/\\"/g, "'");
return cleaned;
}
}
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 sanitizeJsonContent(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) — Sprint 4a : aligné sur le format 3.6a ──────
export interface CorrectionEOInput {
tache: TacheEO;
transcript: string;
sujet: string | null;
nclcCible: NclcCible;
}
/**
* Prompt de correction EO — Sprint 4a.
*
* Adapté à un transcript oral (issu de Gemini batch) : tolère les marques
* d'oralité usuelles (hésitations « euh », reformulations, faux départs, élisions
* familières) sans les sanctionner systématiquement, mais évalue la fluidité
* discursive (capacité à enchaîner des idées sans rupture excessive).
*
* La phonologie n'est pas évaluable depuis un transcript textuel : critères
* limités aux 3 axes pertinents pour l'oral retranscrit. Nous reprojetons
* cependant les 4 critères TCF Canada (adéquation tâche, cohérence/cohésion,
* lexique, grammaticale) pour conserver la même structure de rapport que EE.
*
* Cible NCLC, taxonomie d'erreurs, structure (revelation, diagnostic,
* conseil_nclc, erreurs_codes) : strictement identique à correctEE.
*/
export function buildCorrectionPromptEO(input: CorrectionEOInput): {
system: string;
user: string;
} {
const { tache, transcript, sujet, nclcCible } = input;
const minScore = NCLC_MIN_SCORE[nclcCible];
const taxonomySection = buildTaxonomyPromptSection();
const system = `Tu es un correcteur TCF Canada certifié par France Éducation International, spécialiste de l'Expression Orale. Tu corriges avec précision et bienveillance le TRANSCRIPT TEXTUEL d'une production orale.
CONTEXTE ORAL — RÈGLES SPÉCIFIQUES :
- Tu évalues un transcript issu d'une transcription audio batch (Gemini). Tu n'as PAS accès à l'audio.
- Les marques d'oralité courantes sont TOLÉRÉES si elles n'entravent pas la communication : hésitations (« euh », « hm »), reformulations, faux départs, élisions familières (« j'ai pas », « y'a »), répétitions de soutien.
- Tu SANCTIONNES en revanche : ruptures discursives répétées, idées non finies, mauvaise organisation argumentative, lexique pauvre, fautes morphosyntaxiques systématiques.
- La phonologie n'est PAS évaluée sur ce transcript : ne la mentionne dans aucun critère ni erreur.
- Évalue la FLUIDITÉ DISCURSIVE dans le critère « Cohérence et fluidité ».
- La taxonomie d'erreurs ci-dessous s'applique aussi à l'oral retranscrit : conserve les codes valides.
RÈGLES ABSOLUES :
- 'exemple' = citation textuelle EXACTE, mot pour mot, extraite du transcript 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' par critère = entier de 0 à 5 UNIQUEMENT.
- 'score' global = somme des 4 scores critères (0 à 20).
- Dans les valeurs JSON (chaînes), n'utilise JAMAIS de guillemets doubles ; préfère les guillemets simples ou les chevrons « ».
- 'transcription_affichee' = version NETTOYÉE du transcript brut : ponctuation restaurée, majuscules en début de phrase, paragraphes ajoutés. Tu ne MODIFIES PAS les mots prononcés ; tu n'ajoutes ni n'enlèves rien au contenu.
- JSON strict sans aucun texte avant ni après.
CRITÈRES OFFICIELS TCF Canada — Expression Orale (chacun noté 0 à 5) :
1. ${CRITERE_LABELS_EO.adequation_tache} — respect de la consigne, durée perçue, registre, pertinence du contenu.
2. ${CRITERE_LABELS_EO.coherence_cohesion} — structure logique, fluidité discursive, connecteurs, progression thématique, capacité à enchaîner sans rupture excessive.
3. ${CRITERE_LABELS_EO.competence_lexicale} — étendue du vocabulaire à l'oral, précision, variété, absence de répétitions excessives.
4. ${CRITERE_LABELS_EO.competence_grammaticale} — correction des structures à l'oral, morphologie verbale, syntaxe, accords. Ne sanctionne pas les élisions familières usuelles.
${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 à l oral>',
'realite': '<ce que le correcteur observe réellement dans le transcript>',
'consequence': '<impact concret sur la note>'
},
'diagnostic': '<phrase courte et directe identifiant le principal frein à l oral>',
'transcription_affichee': '<transcript nettoyé : ponctuation, majuscules, paragraphes>',
'criteres': [
{ 'nom': '${CRITERE_LABELS_EO.adequation_tache}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
{ 'nom': '${CRITERE_LABELS_EO.coherence_cohesion}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
{ 'nom': '${CRITERE_LABELS_EO.competence_lexicale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' },
{ 'nom': '${CRITERE_LABELS_EO.competence_grammaticale}', 'score': <0-5>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale 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é pour l oral>'
},
'erreurs_codes': [
{ 'code': '<code taxonomie>', 'critere': '<un des 4 critères>', 'description': <null OU texte si code='autre'> }
]
}`;
const user = `OBJECTIF DU CANDIDAT : NCLC ${nclcCible} — score minimum requis : ${minScore}/20.
TÂCHE : ${TASK_DESCRIPTIONS[tache]}
CONSIGNE / SUJET : ${sujet ?? "Non précisé"}
TRANSCRIPT DE LA PRODUCTION ORALE DU CANDIDAT :
"""
${transcript}
"""`;
return { system, user };
}
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;
}
/**
* Sprint 4a — Validation runtime du rapport EO.
*
* Différences avec validateCorrectionRapport (EE) :
* - Cap chaque score critère à 5 (sécurité — DeepSeek peut sortir 6+ malgré la consigne).
* - Recalcule le score global comme somme des 4 scores cappés.
* - Lit `transcription_affichee` (chaîne, fallback : transcript brut nettoyé minimalement).
* - Ajoute `note_phonologie` fixe (MVP — TD-08).
*/
function validateCorrectionRapportEO(
raw: unknown,
nclcCible: NclcCible,
transcriptBrut: string,
): CorrectionRapport {
// Pré-traitement EO : cap chaque score critère à [0,5] et recalcule le score
// global comme somme des critères cappés AVANT la validation EE de base, pour
// éviter que le validateur parent ne rejette une valeur > 5 ou un total > 20
// (DeepSeek peut dériver malgré la consigne).
if (typeof raw === "object" && raw !== null) {
const r = raw as Record<string, unknown>;
if (Array.isArray(r.criteres)) {
r.criteres = r.criteres.map((c) => {
if (typeof c !== "object" || c === null) return c;
const o = c as Record<string, unknown>;
const s = typeof o.score === "number" ? o.score : Number(o.score);
const capped = Number.isFinite(s)
? Math.max(0, Math.min(5, Math.round(s)))
: 0;
return { ...o, score: capped };
});
const sum = (r.criteres as { score: number }[]).reduce(
(acc, c) => acc + (typeof c.score === "number" ? c.score : 0),
0,
);
r.score = sum;
}
}
const baseRapport = validateCorrectionRapport(raw, nclcCible);
const r = raw as Record<string, unknown>;
const transcriptionAffichee =
typeof r.transcription_affichee === "string" &&
r.transcription_affichee.trim().length > 0
? r.transcription_affichee
: transcriptBrut;
return {
...baseRapport,
transcription_affichee: transcriptionAffichee,
note_phonologie: EO_NOTE_PHONOLOGIE_DEFAULT,
};
}
/**
* Sprint 4a — Correction EO sur transcript textuel.
*
* Retourne un CorrectionRapport aligné sur le format 3.6a (revelation, diagnostic,
* 4 critères enrichis, conseil_nclc, erreurs_codes) + champs EO additionnels :
* `transcription_affichee` (transcript nettoyé) et `note_phonologie` (MVP fixe).
*
* Le scoring critère est cappé à 5 et le total recalculé côté serveur (cf.
* validateCorrectionRapportEO) pour neutraliser les dérives de DeepSeek.
*/
export async function correctEO(
transcript: string,
tache: TacheEO,
nclcCible: NclcCible = 9,
sujet: string | null = null,
): Promise<CorrectionRapport> {
const { system, user } = buildCorrectionPromptEO({
tache,
transcript,
sujet,
nclcCible,
});
const content = await callDeepSeek(system, user, 0.2);
const parsed: unknown = JSON.parse(content);
return validateCorrectionRapportEO(parsed, nclcCible, transcript);
}
// Alias legacy — temporairement conservé le temps que correctionController.correctEE
// soit migré vers la nouvelle signature (étape E5).
export type EERapport = CorrectionRapport;
// Alias legacy — anciens consommateurs EO (frontend Sprint <4a). Dépréciation
// programmée Sprint 4b. Pointe vers le format 3.6a aligné.
export type EORapport = CorrectionRapport;