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:
parent
34b4bcdd82
commit
ec0598d122
15 changed files with 2086 additions and 290 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
369
src/controllers/__tests__/correctionEoPhonology.test.ts
Normal file
369
src/controllers/__tests__/correctionEoPhonology.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
123
src/lib/__tests__/geminiPhonology.test.ts
Normal file
123
src/lib/__tests__/geminiPhonology.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
170
src/lib/geminiPhonology.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue