feat(corrections/eo): évaluation phonologique Gemini — 5 critères × /4 (Sprint 4.8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 03:08:12 +03:00
parent 34b4bcdd82
commit ec0598d122
15 changed files with 2086 additions and 290 deletions

View file

@ -11,6 +11,9 @@ const PROFILE: AuthProfile = {
simulations_used: 3,
};
// Sprint 4.8 — DeepSeek renvoie 4 critères textuels /4 (somme ≤ 16). Le
// controller ajoute la 5e dimension Phonologie (Gemini) puis recalcule le
// score final /20.
const VALID_RAPPORT_EO: CorrectionRapport = {
score: 14,
nclc: 9,
@ -19,7 +22,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
diagnostic: "d",
criteres: [
{
nom: "Réalisation de la tâche",
nom: "Adéquation à la tâche",
score: 4,
commentaire: "",
exemple: "",
@ -27,7 +30,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
astuce: "",
},
{
nom: "Cohérence et fluidité",
nom: "Cohérence et cohésion",
score: 3,
commentaire: "",
exemple: "",
@ -35,7 +38,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
astuce: "",
},
{
nom: "Étendue du lexique",
nom: "Étendue et maîtrise du lexique",
score: 3,
commentaire: "",
exemple: "",
@ -43,7 +46,7 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
astuce: "",
},
{
nom: "Maîtrise grammaticale orale",
nom: "Maîtrise morphosyntaxique",
score: 4,
commentaire: "",
exemple: "",
@ -60,7 +63,6 @@ const VALID_RAPPORT_EO: CorrectionRapport = {
},
],
transcription_affichee: "Bonjour. Je m'appelle Pierre.",
note_phonologie: "Analyse phonologique non disponible pour cette session.",
};
interface ProductionRow {
@ -144,7 +146,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn().mockResolvedValue(VALID_RAPPORT_EO),
generateProductionModele: vi.fn().mockResolvedValue({
@ -177,8 +196,13 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data.simulation_id).toBe("sim-1");
// Mode transcript : phonologie = stub 0/4 → total = 14 (textuel) + 0 = 14.
expect(result.data.score).toBe(14);
expect(result.data.note_phonologie).toContain("phonologique");
// Sprint 4.8 : 5 critères (4 textuels + Phonologie).
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(0);
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
}
const persisted = updates.find(
@ -201,7 +225,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
it("simulation introuvable → SIMULATION_NOT_FOUND 404", async () => {
const { mock } = createSupabaseMock(null);
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
@ -234,7 +275,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
@ -271,7 +329,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
const correctEOSpy = vi
.fn()
.mockResolvedValue({ ...VALID_RAPPORT_EO, nclc_cible: 10 });
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOSpy,
generateProductionModele: vi.fn().mockResolvedValue({
@ -320,7 +395,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
const correctEOSpy = vi.fn().mockResolvedValue(VALID_RAPPORT_EO);
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOSpy,
generateProductionModele: vi.fn().mockResolvedValue({
@ -385,7 +477,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
@ -423,7 +532,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
@ -461,7 +587,24 @@ describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio ba
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),

View file

@ -0,0 +1,369 @@
/**
* Tests Sprint 4.8 fusion phonologie Gemini dans correctionController.correctEO.
*
* Couvre :
* - Mode B (audioBase64) : phonologie /4 injectée comme 5e critère, score
* final /20 = somme des 5 critères.
* - Mode A (transcript) : phonologie = stub 0/4 avec commentaire.
* - evaluatePhonology rejette fallback stub, la correction n'échoue pas.
* - Persistance Supabase : criteres à 5 entrées, score recalculé.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CorrectionRapport } from "../../lib/deepseek";
import type { AuthProfile } from "../../middleware/auth";
const PROFILE: AuthProfile = {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 3,
};
const RAPPORT_TEXTUEL: CorrectionRapport = {
score: 12, // somme textuelle 4+3+2+3 = 12
nclc: 8,
nclc_cible: 9,
revelation: { croyance: "c", realite: "r", consequence: "co" },
diagnostic: "d",
criteres: [
{
nom: "Adéquation à la tâche",
score: 4,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Cohérence et cohésion",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Étendue et maîtrise du lexique",
score: 2,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Maîtrise morphosyntaxique",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
],
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "ok", action_prioritaire: "a" },
erreurs_codes: [],
transcription_affichee: "Bonjour.",
};
interface ProductionRow {
id: string;
user_id: string;
tache: string;
sujet_id: string | null;
}
function createSupabaseMock(production: ProductionRow) {
const updates: { table: string; data: Record<string, unknown> }[] = [];
const fromMock = vi.fn((table: string) => {
if (table === "productions") {
return {
select: () => ({
eq: () => ({
single: async () => ({ data: production, error: null }),
}),
}),
update: (data: Record<string, unknown>) => ({
eq: async () => {
updates.push({ table, data });
return { error: null };
},
}),
};
}
if (table === "sujets") {
return {
select: () => ({
eq: () => ({
single: async () => ({
data: { consigne: "Présentez-vous." },
error: null,
}),
}),
}),
};
}
if (table === "profiles") {
return {
update: (data: Record<string, unknown>) => ({
eq: async () => {
updates.push({ table, data });
return { error: null };
},
}),
};
}
return {};
});
return { mock: { from: fromMock }, updates };
}
const STANDARD_DEEPSEEK_MOCK = (correctEOImpl: ReturnType<typeof vi.fn>) => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOImpl,
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: "t",
notes_pedagogiques: [],
transformations: [],
message: "",
nclc_modele: 9,
nclc_obtenu: 8,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 200,
tcf_word_max: 300,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
});
const STANDARD_GEMINI_MOCK = {
transcribeAudio: vi.fn().mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
};
describe("correctionController.correctEO — phonologie (Sprint 4.8)", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("Mode B (audio) : phonologie injectée comme 5e critère, score = textuel + phono", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-phono-1",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 3,
commentaire: "Prononciation correcte avec quelques liaisons manquées.",
exemple: "les amis",
suggestion: "Réaliser la liaison.",
astuce: "S'entraîner sur les liaisons.",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "stub",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-1",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// 4 textuels (4+3+2+3 = 12) + phonologie 3 = 15
expect(result.data.score).toBe(15);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(3);
expect(result.data.criteres[4]!.commentaire).toMatch(/Prononciation/);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted!.data.score).toBe(15);
});
it("Mode A (transcript) : phonologie = stub 0/4 avec commentaire indisponibilité", async () => {
const { mock } = createSupabaseMock({
id: "sim-phono-2",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
const evaluatePhonology = vi.fn();
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology,
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-2",
tache: "EO_T1",
nclcCible: 9,
transcript: "Bonjour je m appelle Pierre",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// Mode A → phonologie stub 0 → score = 12 + 0 = 12.
expect(result.data.score).toBe(12);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(0);
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
// evaluatePhonology n'est PAS appelée en Mode A.
expect(evaluatePhonology).not.toHaveBeenCalled();
});
it("Mode B + evaluatePhonology rejette → fallback stub, correction réussit", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-phono-3",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi
.fn()
.mockRejectedValue(new Error("Gemini phonology timeout")),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-3",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// Phonologie tombe sur le stub → score = 12 + 0 = 12, correction OK.
expect(result.data.score).toBe(12);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.score).toBe(0);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted!.data.score).toBe(12);
});
it("score phonologie 4 + textuel 16 → total final 20 (cap respecté)", async () => {
const RAPPORT_PARFAIT: CorrectionRapport = {
...RAPPORT_TEXTUEL,
score: 16,
criteres: RAPPORT_TEXTUEL.criteres.map((c) => ({ ...c, score: 4 })),
};
const { mock } = createSupabaseMock({
id: "sim-phono-4",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_PARFAIT)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 4,
commentaire: "Prononciation native.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "stub",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-4",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
expect(result.data.score).toBe(20);
expect(result.data.criteres[4]!.score).toBe(4);
});
});

View file

@ -23,14 +23,25 @@ import {
correctEO as deepseekCorrectEO,
generateProductionModele,
generateExercices,
CRITERE_LABEL_PHONOLOGIE,
type CorrectionRapport,
type CorrectionCritereDetail,
type NclcCible,
type TacheEE,
type TacheEO,
type TacheCorrection,
} from "../lib/deepseek.js";
import { PLANS, type Plan } from "../lib/access.js";
import { transcribeAudio, isAcceptedAudioMime } from "../lib/gemini.js";
import {
transcribeAudio,
isAcceptedAudioMime,
type AcceptedAudioMime,
} from "../lib/gemini.js";
import {
evaluatePhonology,
PHONOLOGY_STUB,
type PhonologyResult,
} from "../lib/geminiPhonology.js";
import type { AuthProfile } from "../middleware/auth.js";
type CorrectionError = {
@ -391,8 +402,14 @@ export async function correctEO(
}
}
// 3. Mode batch audio : transcrire d'abord. Mode transcript direct : passer.
// 3. Préparer l'audio (Mode B) ou le transcript (Mode A).
// Mode B : on lance la transcription Gemini ET l'évaluation phonologique
// en parallèle sur le même payload audio (Sprint 4.8).
// Mode A : le client fournit déjà le transcript, la phonologie devient un
// stub /4 (cf. PHONOLOGY_STUB) — pas d'audio à analyser.
let transcript: string;
let phonologyPromise: Promise<PhonologyResult>;
if (input.audioBase64 && input.mimeType) {
// Normalisation du MIME : `MediaRecorder` côté navigateur produit souvent
// un type complet `audio/webm;codecs=opus`. La whitelist Gemini compare
@ -407,8 +424,23 @@ export async function correctEO(
status: 400,
};
}
const acceptedMime = normalizedMime as AcceptedAudioMime;
// Démarrer la phonologie tout de suite — elle tourne en parallèle de la
// transcription puis de la correction DeepSeek. Si elle échoue, on bascule
// sur le stub et on log : la correction ne doit JAMAIS être bloquée par
// une défaillance phonologique.
phonologyPromise = evaluatePhonology(input.audioBase64, acceptedMime).catch(
(err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(
"[correctionController.correctEO] phonology evaluation failed",
{ simulationId, message },
);
return PHONOLOGY_STUB;
},
);
try {
transcript = await transcribeAudio(input.audioBase64, normalizedMime);
transcript = await transcribeAudio(input.audioBase64, acceptedMime);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[correctionController.correctEO] transcription failed", {
@ -425,6 +457,7 @@ export async function correctEO(
}
} else if (typeof input.transcript === "string") {
transcript = input.transcript;
phonologyPromise = Promise.resolve(PHONOLOGY_STUB);
} else {
return {
error: true,
@ -451,9 +484,13 @@ export async function correctEO(
nclcObtenu: nclcObtenuEstime,
});
let rapport: CorrectionRapport;
let rapportTextuel: CorrectionRapport;
let phonology: PhonologyResult;
try {
rapport = await correctionPromise;
[rapportTextuel, phonology] = await Promise.all([
correctionPromise,
phonologyPromise,
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[correctionController.correctEO] correction failed", {
@ -471,6 +508,30 @@ export async function correctEO(
};
}
// 4-bis. Sprint 4.8 — fusionner la phonologie comme 5e critère et recalculer
// le score global ∈ [0,20] (4 textuels × /4 + phonologie × /4).
const phonologyCritere: CorrectionCritereDetail = {
nom: CRITERE_LABEL_PHONOLOGIE,
score: phonology.score,
commentaire: phonology.commentaire,
exemple: phonology.exemple,
suggestion: phonology.suggestion,
astuce: phonology.astuce,
};
const criteresAvecPhonologie: CorrectionCritereDetail[] = [
...rapportTextuel.criteres,
phonologyCritere,
];
const scoreFinal = criteresAvecPhonologie.reduce(
(acc, c) => acc + c.score,
0,
);
const rapport: CorrectionRapport = {
...rapportTextuel,
criteres: criteresAvecPhonologie,
score: scoreFinal,
};
// 5. Persister le rapport. Pas de *_status (race condition — cf. correctEE).
const { error: updateError } = await supabase
.from("productions")

View file

@ -474,7 +474,7 @@ const VALID_RAPPORT_EO = {
"Bonjour, je vais me présenter. Je m'appelle Pierre. Je travaille comme ingénieur.",
criteres: [
{
nom: "Réalisation de la tâche",
nom: "Adéquation à la tâche",
score: 4,
commentaire: "Tâche globalement respectée.",
exemple: "Je vais me présenter",
@ -482,7 +482,7 @@ const VALID_RAPPORT_EO = {
astuce: "Soigner les ouvertures.",
},
{
nom: "Cohérence et fluidité",
nom: "Cohérence et cohésion",
score: 3,
commentaire: "Ruptures fréquentes.",
exemple: "euh euh",
@ -490,7 +490,7 @@ const VALID_RAPPORT_EO = {
astuce: "Limiter les hésitations vocalisées.",
},
{
nom: "Étendue du lexique",
nom: "Étendue et maîtrise du lexique",
score: 3,
commentaire: "Vocabulaire basique.",
exemple: "mon travail",
@ -498,7 +498,7 @@ const VALID_RAPPORT_EO = {
astuce: "Varier les mots du même champ.",
},
{
nom: "Maîtrise grammaticale orale",
nom: "Maîtrise morphosyntaxique",
score: 4,
commentaire: "Accords globalement corrects.",
exemple: "les gens travaille",
@ -542,22 +542,23 @@ describe("deepseek.correctEO", () => {
expect(rapport.diagnostic).toBeDefined();
expect(rapport.criteres).toHaveLength(4);
expect(rapport.transcription_affichee).toContain("Bonjour");
expect(rapport.note_phonologie).toBe(
"Analyse phonologique non disponible pour cette session.",
);
// Sprint 4.8 : `note_phonologie` est retiré ; la phonologie est désormais
// un 5e critère injecté par le controller (pas par DeepSeek).
expect(rapport.note_phonologie).toBeUndefined();
expect(rapport.erreurs_codes.length).toBeGreaterThan(0);
});
it("cap score critère à 5 et recalcule le total", async () => {
// DeepSeek déclare score=10 mais sort 7 sur le 1er critère (>5). On vérifie
// que (a) chaque critère est cappé à 5 et (b) le total est recalculé sur la
// somme des critères cappés (5+5+3+4=17), pas sur le score déclaré.
it("cap score critère à 4 et recalcule le total textuel", async () => {
// Sprint 4.8 : DeepSeek déclare score=10 mais sort 7 sur le 1er critère
// (>4). On vérifie que (a) chaque critère est cappé à 4 et (b) le total
// textuel est recalculé sur la somme des critères cappés (4+4+3+4=15),
// pas sur le score déclaré. La phonologie /4 sera ajoutée par le controller.
mockFetchSuccess({
...VALID_RAPPORT_EO,
score: 10,
criteres: [
{ ...VALID_RAPPORT_EO.criteres[0], score: 7 },
{ ...VALID_RAPPORT_EO.criteres[1], score: 5 },
{ ...VALID_RAPPORT_EO.criteres[1], score: 4 },
{ ...VALID_RAPPORT_EO.criteres[2], score: 3 },
{ ...VALID_RAPPORT_EO.criteres[3], score: 4 },
],
@ -565,9 +566,9 @@ describe("deepseek.correctEO", () => {
const { correctEO } = await import("../deepseek");
const rapport = await correctEO("t", "EO_T1", 9);
expect(rapport.criteres.every((c) => c.score <= 5)).toBe(true);
// 5 (cappé) + 5 + 3 + 4 = 17 (et non 99)
expect(rapport.score).toBe(17);
expect(rapport.criteres.every((c) => c.score <= 4)).toBe(true);
// 4 (cappé) + 4 + 3 + 4 = 15 (et non 99)
expect(rapport.score).toBe(15);
});
it("transcription_affichee absente → fallback sur le transcript brut", async () => {

View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
function mockFetchSuccess(jsonText: string) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [{ content: { parts: [{ text: jsonText }] } }],
}),
}),
);
}
const VALID_PAYLOAD = JSON.stringify({
score: 3,
commentaire:
"Prononciation globalement claire avec quelques liaisons manquées.",
exemple: "les amis",
suggestion: "Réaliser la liaison _les_amis_.",
astuce: "S'entraîner sur 5 paires liaison/non-liaison.",
});
describe("geminiPhonology.evaluatePhonology", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("retourne un PhonologyResult valide sur succès", async () => {
mockFetchSuccess(VALID_PAYLOAD);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
expect(result.commentaire).toMatch(/Prononciation/);
expect(result.exemple).toBe("les amis");
expect(result.suggestion).toMatch(/liaison/);
expect(result.astuce).toMatch(/entraîner/);
});
it("cap le score à 4 si Gemini renvoie 5+", async () => {
mockFetchSuccess(
JSON.stringify({ score: 7, commentaire: "Score sur-évalué." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(4);
});
it("ramène le score à 0 si Gemini renvoie négatif", async () => {
mockFetchSuccess(
JSON.stringify({ score: -2, commentaire: "Score négatif." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(0);
});
it("arrondit un score décimal", async () => {
mockFetchSuccess(
JSON.stringify({ score: 2.7, commentaire: "Score décimal." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
});
it("rejette si la réponse n'est pas du JSON", async () => {
mockFetchSuccess("ceci n'est pas du JSON");
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/non-JSON/);
});
it("rejette si le commentaire est manquant", async () => {
mockFetchSuccess(JSON.stringify({ score: 3 }));
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/commentaire manquant/);
});
it("rejette sur erreur HTTP applicative (pas de retry)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
});
vi.stubGlobal("fetch", fetchMock);
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/Gemini phonology API error/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("réessaie une fois sur TimeoutError et réussit au 2e essai", async () => {
const timeoutErr = Object.assign(new Error("timeout"), {
name: "TimeoutError",
});
const fetchMock = vi
.fn()
.mockRejectedValueOnce(timeoutErr)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
candidates: [{ content: { parts: [{ text: VALID_PAYLOAD }] } }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("PHONOLOGY_STUB est un objet exploitable directement", async () => {
const { PHONOLOGY_STUB } = await import("../geminiPhonology");
expect(PHONOLOGY_STUB.score).toBe(0);
expect(PHONOLOGY_STUB.commentaire).toMatch(/audio requis/);
});
});

View file

@ -85,14 +85,17 @@ export interface CorrectionRapport {
* 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",
adequation_tache: "Adéquation à la tâche",
coherence_cohesion: "Cohérence et cohésion",
competence_lexicale: "Étendue et maîtrise du lexique",
competence_grammaticale: "Maîtrise morphosyntaxique",
};
const EO_NOTE_PHONOLOGIE_DEFAULT =
"Analyse phonologique non disponible pour cette session.";
/**
* Sprint 4.8 Label officiel TCF Canada du 5e critère, ajouté hors prompt
* DeepSeek (évalué par Gemini sur l'audio brut, cf. geminiPhonology.ts).
*/
export const CRITERE_LABEL_PHONOLOGIE = "Phonologie";
export interface ProductionModeleInput {
tache: TacheCorrection;
@ -953,13 +956,13 @@ 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).
- 'score' par critère = entier de 0 à 4 UNIQUEMENT.
- 'score' global = somme des 4 scores critères textuels (0 à 16). Le 5e critère « Phonologie » est évalué séparément côté serveur ; tu N'INCLUS PAS la phonologie dans ce score ni dans la liste 'criteres'.
- 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) :
CRITÈRES OFFICIELS TCF Canada Expression Orale (les 4 critères textuels ci-dessous, chacun noté 0 à 4 la Phonologie /4 est évaluée à part sur l'audio) :
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.
@ -971,8 +974,8 @@ ${buildConseilNclcRulesBlock(nclcCible, minScore, "single")}
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>,
'score': <entier 0-16, somme des 4 critères textuels phonologie évaluée séparément>,
'nclc': <entier 4-12, niveau estimé à partir du score textuel + phonologie attendue>,
'revelation': {
'croyance': '<ce que le candidat croit faire bien à l oral>',
'realite': '<ce que le correcteur observe réellement dans le transcript>',
@ -981,10 +984,10 @@ FORMAT DE RÉPONSE (JSON strict, aucun autre texte) :
'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>' }
{ 'nom': '${CRITERE_LABELS_EO.adequation_tache}', 'score': <0-4>, '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-4>, '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-4>, '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-4>, 'commentaire': '<2 phrases max>', 'exemple': '<citation exacte du transcript>', 'suggestion': '<reformulation orale concrète>', 'astuce': '<conseil court>' }
],
'conseil_nclc': {
'nclc_cible': 'NCLC ${nclcCible}',
@ -1079,22 +1082,29 @@ export async function generateIdees(
}
/**
* Sprint 4a Validation runtime du rapport EO.
* Sprint 4a / Sprint 4.8 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.
* - Cap chaque score critère textuel à 4 (Sprint 4.8 : passage de /5 à /4).
* La 5e dimension « Phonologie » /4 est ajoutée par le controller à partir
* de l'évaluation Gemini sur l'audio brut (cf. geminiPhonology.ts) elle
* N'EST PAS gérée ici.
* - Recalcule le score textuel comme somme des 4 scores cappés [0,16]. Le
* total final /20 est calculé par le controller après injection de la
* phonologie.
* - Lit `transcription_affichee` (chaîne, fallback : transcript brut nettoyé minimalement).
* - Ajoute `note_phonologie` fixe (MVP TD-08).
*
* Note : `note_phonologie` (champ fixe MVP) est retiré au Sprint 4.8 puisque
* la phonologie est désormais un critère structuré à part entière.
*/
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
// Pré-traitement EO : cap chaque score critère à [0,4] et recalcule le score
// textuel comme somme des critères cappés (≤ 16) AVANT la validation EE de
// base, pour éviter que le validateur parent ne rejette une valeur > 16
// (DeepSeek peut dériver malgré la consigne).
if (typeof raw === "object" && raw !== null) {
const r = raw as Record<string, unknown>;
@ -1104,7 +1114,7 @@ function validateCorrectionRapportEO(
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)))
? Math.max(0, Math.min(4, Math.round(s)))
: 0;
return { ...o, score: capped };
});
@ -1128,7 +1138,6 @@ function validateCorrectionRapportEO(
return {
...baseRapport,
transcription_affichee: transcriptionAffichee,
note_phonologie: EO_NOTE_PHONOLOGIE_DEFAULT,
};
}

170
src/lib/geminiPhonology.ts Normal file
View file

@ -0,0 +1,170 @@
/**
* Évaluation phonologique EO via Gemini batch Sprint 4.8.
*
* Reçoit l'audio brut du candidat (base64) et retourne un score `/4` ainsi
* qu'un commentaire pédagogique structuré, alignés sur la grille TCF Canada.
* Cet appel est complémentaire de `transcribeAudio` (cf. gemini.ts) :
* - `transcribeAudio` extrait le texte DeepSeek évalue 4 critères /4.
* - `evaluatePhonology` écoute l'audio 5e critère Phonologie /4.
*
* Robustesse : timeout 45 s + 1 retry sur erreur transitoire (TimeoutError,
* AbortError, TypeError). Pas de retry sur erreur HTTP applicative (config
* Gemini cassée un second essai échouera identiquement).
*
* Mode A (transcript fourni sans audio) : utiliser `PHONOLOGY_STUB`
* directement plutôt que d'appeler cette fonction.
*/
import type { AcceptedAudioMime } from "./gemini.js";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_TIMEOUT_MS = 45_000;
export interface PhonologyResult {
/** Score entier 0..4 (capé côté serveur pour neutraliser les dérives). */
score: number;
commentaire: string;
exemple: string;
suggestion: string;
astuce: string;
}
/**
* Stub utilisé quand aucune piste audio n'est disponible (ex. Mode A
* transcript fourni directement par le client). Le score est volontairement
* 0 pour que le total /20 reflète l'absence d'évaluation.
*/
export const PHONOLOGY_STUB: PhonologyResult = {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
};
const PHONOLOGY_SYSTEM_PROMPT = `Tu es un correcteur TCF Canada certifié, spécialiste de la phonologie pour l'épreuve d'Expression Orale.
Tu écoutes un enregistrement audio bref ( 5 minutes) et tu évalues UNIQUEMENT la phonologie selon la grille officielle TCF Canada :
- Prononciation des sons consonantiques et vocaliques
- Liaisons et enchaînements
- Rythme, débit, accentuation
- Intonation et prosodie
- Fluidité phonique (présence d'hésitations marquées, hachures)
Échelle : entier de 0 à 4 UNIQUEMENT.
- 0 : prononciation très défaillante, intelligibilité fortement compromise.
- 1 : nombreux écarts, intelligibilité difficile.
- 2 : écarts notables mais intelligibilité préservée.
- 3 : prononciation correcte avec quelques écarts ponctuels.
- 4 : prononciation maîtrisée, naturelle, proche du francophone natif.
Réponds par un JSON STRICT, sans aucun texte avant ni après, sans markdown, sans backtick :
{
"score": <entier 0-4>,
"commentaire": "<2 phrases max — observations concrètes sur la prononciation>",
"exemple": "<mot ou expression où l'erreur phonologique est notable, ou chaîne vide si rien à signaler>",
"suggestion": "<reformulation orale ciblée, par ex. 'détacher la liaison de _les_amis_'>",
"astuce": "<conseil court et actionnable pour s'entraîner>"
}`;
const PHONOLOGY_USER_PROMPT =
"Évalue la phonologie de cet enregistrement selon la grille TCF Canada. Renvoie uniquement le JSON décrit dans le prompt système.";
interface GeminiResponse {
candidates?: { content?: { parts?: { text?: string }[] } }[];
}
function clampScore(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(4, Math.round(n)));
}
function parsePhonologyJson(text: string): PhonologyResult {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("Gemini phonology: réponse non-JSON");
}
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Gemini phonology: payload invalide");
}
const r = parsed as Record<string, unknown>;
const score = clampScore(r.score);
const commentaire = typeof r.commentaire === "string" ? r.commentaire : "";
if (commentaire.trim().length === 0) {
throw new Error("Gemini phonology: commentaire manquant");
}
const exemple = typeof r.exemple === "string" ? r.exemple : "";
const suggestion = typeof r.suggestion === "string" ? r.suggestion : "";
const astuce = typeof r.astuce === "string" ? r.astuce : "";
return { score, commentaire, exemple, suggestion, astuce };
}
async function callGeminiPhonology(
audioBase64: string,
mimeType: AcceptedAudioMime,
): Promise<PhonologyResult> {
const response = await fetch(
`${GEMINI_BASE_URL}/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
systemInstruction: { parts: [{ text: PHONOLOGY_SYSTEM_PROMPT }] },
contents: [
{
parts: [
{ inlineData: { mimeType, data: audioBase64 } },
{ text: PHONOLOGY_USER_PROMPT },
],
},
],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
},
}),
signal: AbortSignal.timeout(GEMINI_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(
`Gemini phonology API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as GeminiResponse;
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text || typeof text !== "string" || text.trim().length === 0) {
throw new Error("Gemini phonology: réponse vide");
}
return parsePhonologyJson(text.trim());
}
/**
* Évalue la phonologie sur l'audio brut. 1 retry automatique sur erreur
* transitoire ; les erreurs HTTP applicatives ne sont PAS retentées.
*/
export async function evaluatePhonology(
audioBase64: string,
mimeType: AcceptedAudioMime,
): Promise<PhonologyResult> {
try {
return await callGeminiPhonology(audioBase64, mimeType);
} catch (err) {
const isRetryable =
err instanceof Error &&
(err.name === "TimeoutError" ||
err.name === "AbortError" ||
err instanceof TypeError);
if (!isRetryable) throw err;
console.warn(
`[geminiPhonology.evaluatePhonology] retry après erreur transitoire : ${err.message}`,
);
return await callGeminiPhonology(audioBase64, mimeType);
}
}