1126 lines
42 KiB
TypeScript
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;
|