feat(eo): align correction EO on 3.6a format + Deepgram token + T1 presentation generation

Sprint 4a:
- correctEO aligned on CorrectionRapport format (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)
- nclc_cible parameter (default 9, accepts 9|10)
- Fire-and-forget modele + exercices jobs (same pattern as EE)
- EO-specific DeepSeek prompt (oral transcript tolerance, 4 TCF criteria)
- Gemini transcribeAudio: 30s timeout + 1 retry
- POST /presentations/generate: 5-field questionnaire → DeepSeek generates oral presentation (~220-260 words, NCLC 7-8)
- Migration 006_sprint_4a_eo.sql (documentation only — no audio storage)

Sprint 4b:
- POST /transcriptions/token: Deepgram temporary API key (600s TTL)
- Removed audio storage pipeline (audioStorage.ts, XOR validation, 14MB limit)
- Backend receives transcript text only, no audio files
- TD-10/TD-11 resolved (Sprint 3.6c), TD-16/17/18 resolved (4b cleanup)

Typecheck: OK · Tests: 241/241 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 05:04:26 +03:00
parent f5954e6d72
commit 7cac057062
18 changed files with 2907 additions and 911 deletions

View file

@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ───────────────────────────────────────────────────────────────
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 3,
});
await next();
},
}));
const { correctEOMock } = vi.hoisted(() => ({ correctEOMock: vi.fn() }));
vi.mock("../../controllers/correctionController", () => ({
correctEE: vi.fn(),
correctEO: correctEOMock,
}));
import correctionsRoutes from "../corrections";
function buildApp() {
const app = new Hono();
app.route("/corrections", correctionsRoutes);
return app;
}
const AUTH = { Authorization: "Bearer x" };
const JSON_HEADERS = { ...AUTH, "Content-Type": "application/json" };
describe("POST /corrections/eo — Sprint 4a", () => {
beforeEach(() => {
correctEOMock.mockReset();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", { method: "POST" });
expect(res.status).toBe(401);
});
it("400 si simulationId manquant", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ tache: "EO_T1", transcript: "t" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si tache invalide (EO_T2 par exemple)", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T2",
transcript: "t",
}),
});
expect(res.status).toBe(400);
});
it("400 si transcript manquant", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ simulationId: "s1", tache: "EO_T1" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si nclc_cible invalide", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
transcript: "t",
nclc_cible: 8,
}),
});
expect(res.status).toBe(400);
});
it("200 quand le controller renvoie un rapport (mode transcript)", async () => {
correctEOMock.mockResolvedValue({
data: {
score: 14,
nclc: 9,
simulation_id: "s1",
diagnostic: "d",
},
});
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
transcript: "Bonjour je m appelle Pierre",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.score).toBe(14);
expect(correctEOMock).toHaveBeenCalledWith(
expect.objectContaining({
simulationId: "s1",
tache: "EO_T1",
nclcCible: 9,
transcript: "Bonjour je m appelle Pierre",
}),
expect.any(Object),
);
});
it("200 avec nclc_cible=10 transmis au controller", async () => {
correctEOMock.mockResolvedValue({
data: { score: 16, nclc: 10, simulation_id: "s2", diagnostic: "d" },
});
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s2",
tache: "EO_T1",
transcript: "Bonjour",
nclc_cible: 10,
}),
});
expect(res.status).toBe(200);
expect(correctEOMock).toHaveBeenCalledWith(
expect.objectContaining({
simulationId: "s2",
nclcCible: 10,
transcript: "Bonjour",
}),
expect.any(Object),
);
});
});

View file

@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
});
await next();
},
}));
const { generateMock } = vi.hoisted(() => ({ generateMock: vi.fn() }));
vi.mock("../../controllers/presentationController", () => ({
generate: generateMock,
}));
import presentationsRoutes from "../presentations";
function buildApp() {
const app = new Hono();
app.route("/presentations", presentationsRoutes);
return app;
}
const JSON_HEADERS = {
Authorization: "Bearer x",
"Content-Type": "application/json",
};
describe("POST /presentations/generate", () => {
beforeEach(() => {
generateMock.mockReset();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
});
expect(res.status).toBe(401);
});
it("400 si body JSON invalide", async () => {
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: "not-json",
});
expect(res.status).toBe(400);
});
it("propage l'erreur de validation du controller", async () => {
generateMock.mockResolvedValue({
error: true,
code: "VALIDATION_ERROR",
message: "field missing",
status: 400,
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ reponses: {} }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("200 avec { presentation } quand le controller réussit", async () => {
generateMock.mockResolvedValue({
data: { presentation: "Bonjour, je m'appelle Pierre…" },
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
reponses: {
prenom_age_ville: "Pierre",
formation_metier: "Ingénieur",
situation_familiale: "Marié",
loisirs: "Lecture",
motivation_canada: "Travail",
},
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.presentation).toContain("Pierre");
expect(generateMock).toHaveBeenCalledWith({
prenom_age_ville: "Pierre",
formation_metier: "Ingénieur",
situation_familiale: "Marié",
loisirs: "Lecture",
motivation_canada: "Travail",
});
});
it("500 si DeepSeek down (controller renvoie INTERNAL_ERROR)", async () => {
generateMock.mockResolvedValue({
error: true,
code: "INTERNAL_ERROR",
message: "fail",
status: 500,
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
reponses: {
prenom_age_ville: "a",
formation_metier: "b",
situation_familiale: "c",
loisirs: "d",
motivation_canada: "e",
},
}),
});
expect(res.status).toBe(500);
});
});

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
});
await next();
},
}));
import transcriptionsRoutes from "../transcriptions";
function buildApp() {
const app = new Hono();
app.route("/transcriptions", transcriptionsRoutes);
return app;
}
const JSON_HEADERS = {
Authorization: "Bearer x",
"Content-Type": "application/json",
};
describe("POST /transcriptions/token — Sprint 4b", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/transcriptions/token", { method: "POST" });
expect(res.status).toBe(401);
});
it("200 + { token, expires_in } quand Deepgram répond OK", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "dg-temp-abc123",
expires_in: 600,
}),
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.token).toBe("dg-temp-abc123");
expect(body.expires_in).toBe(600);
});
it("appelle Deepgram avec ttl_seconds=600 et Authorization Token <key>", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ access_token: "tok", expires_in: 600 }),
});
vi.stubGlobal("fetch", fetchMock);
const app = buildApp();
await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/v1/auth/grant");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["Authorization"]).toMatch(/^Token /);
expect(JSON.parse(init.body as string)).toEqual({ ttl_seconds: 600 });
});
it("500 INTERNAL_ERROR si Deepgram non-OK", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("INTERNAL_ERROR");
});
it("500 INTERNAL_ERROR si fetch throw (timeout réseau)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network down")),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
});
it("500 INTERNAL_ERROR si access_token absent dans la réponse Deepgram", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ expires_in: 600 }),
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
});
});

View file

@ -1,137 +1,184 @@
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import * as correctionController from '../controllers/correctionController.js'
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import * as correctionController from "../controllers/correctionController.js";
const VALID_TACHES_EE = ['EE_T1', 'EE_T2', 'EE_T3']
const VALID_TACHES_EO = ['EO_T1', 'EO_T3']
const VALID_TACHES_EE = ["EE_T1", "EE_T2", "EE_T3"];
const VALID_TACHES_EO = ["EO_T1", "EO_T3"];
const corrections = new Hono<{ Variables: AppVariables }>()
const corrections = new Hono<{ Variables: AppVariables }>();
corrections.post('/ee', authMiddleware, async (c) => {
corrections.post("/ee", authMiddleware, async (c) => {
let body: {
simulationId?: unknown
contenu?: unknown
tache?: unknown
nclc_cible?: unknown
}
simulationId?: unknown;
contenu?: unknown;
tache?: unknown;
nclc_cible?: unknown;
};
try {
body = await c.req.json()
body = await c.req.json();
} catch {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' },
400
)
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
if (!body.simulationId || typeof body.simulationId !== 'string') {
if (!body.simulationId || typeof body.simulationId !== "string") {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'simulationId est requis.' },
400
)
{
error: true,
code: "VALIDATION_ERROR",
message: "simulationId est requis.",
},
400,
);
}
if (!body.contenu || typeof body.contenu !== 'string') {
if (!body.contenu || typeof body.contenu !== "string") {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'contenu est requis.' },
400
)
{ error: true, code: "VALIDATION_ERROR", message: "contenu est requis." },
400,
);
}
if (!body.tache || !VALID_TACHES_EE.includes(body.tache as string)) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EE.join(', ')}`,
code: "VALIDATION_ERROR",
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EE.join(", ")}`,
},
400
)
400,
);
}
// Sprint 3.6a — nclc_cible optionnel (défaut 9). Seules les valeurs 9 et 10 sont acceptées.
let nclcCible: 9 | 10 = 9
let nclcCible: 9 | 10 = 9;
if (body.nclc_cible !== undefined) {
if (body.nclc_cible !== 9 && body.nclc_cible !== 10) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: 'nclc_cible doit être 9 ou 10.',
code: "VALIDATION_ERROR",
message: "nclc_cible doit être 9 ou 10.",
},
400
)
400,
);
}
nclcCible = body.nclc_cible
nclcCible = body.nclc_cible;
}
const profile = c.get('profile')
const profile = c.get("profile");
const result = await correctionController.correctEE(
{
simulationId: body.simulationId,
contenu: body.contenu,
tache: body.tache as 'EE_T1' | 'EE_T2' | 'EE_T3',
tache: body.tache as "EE_T1" | "EE_T2" | "EE_T3",
nclcCible,
},
profile,
)
);
if ('error' in result) {
return c.json(result, result.status as 401 | 404 | 500)
if ("error" in result) {
return c.json(result, result.status as 401 | 404 | 500);
}
return c.json(result.data, 200)
})
return c.json(result.data, 200);
});
corrections.post('/eo', authMiddleware, async (c) => {
let body: { simulationId?: unknown; transcript?: unknown; tache?: unknown }
// Sprint 4b — POST /corrections/eo reçoit uniquement le transcript final.
// La transcription live est gérée navigateur ↔ Deepgram (cf. /transcriptions/token).
// Aucun audio n'est stocké côté backend.
corrections.post("/eo", authMiddleware, async (c) => {
let body: {
simulationId?: unknown;
transcript?: unknown;
tache?: unknown;
nclc_cible?: unknown;
};
try {
body = await c.req.json()
body = await c.req.json();
} catch {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' },
400
)
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
if (!body.simulationId || typeof body.simulationId !== 'string') {
if (!body.simulationId || typeof body.simulationId !== "string") {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'simulationId est requis.' },
400
)
{
error: true,
code: "VALIDATION_ERROR",
message: "simulationId est requis.",
},
400,
);
}
if (!body.transcript || typeof body.transcript !== 'string') {
if (!body.transcript || typeof body.transcript !== "string") {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'transcript est requis.' },
400
)
{
error: true,
code: "VALIDATION_ERROR",
message: "transcript est requis.",
},
400,
);
}
if (!body.tache || !VALID_TACHES_EO.includes(body.tache as string)) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EO.join(', ')}`,
code: "VALIDATION_ERROR",
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EO.join(", ")}`,
},
400
)
400,
);
}
const profile = c.get('profile')
// nclc_cible optionnel (défaut 9, valeurs 9 ou 10).
let nclcCible: 9 | 10 = 9;
if (body.nclc_cible !== undefined) {
if (body.nclc_cible !== 9 && body.nclc_cible !== 10) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "nclc_cible doit être 9 ou 10.",
},
400,
);
}
nclcCible = body.nclc_cible;
}
const profile = c.get("profile");
const result = await correctionController.correctEO(
body.simulationId as string,
body.transcript as string,
body.tache as string,
profile
)
{
simulationId: body.simulationId,
tache: body.tache as "EO_T1" | "EO_T3",
nclcCible,
transcript: body.transcript,
},
profile,
);
if ('error' in result) {
return c.json(result, result.status as 401 | 404 | 500)
if ("error" in result) {
return c.json(result, result.status as 401 | 404 | 500);
}
return c.json(result.data, 200)
})
return c.json(result.data, 200);
});
export default corrections
export default corrections;

View file

@ -0,0 +1,39 @@
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import * as presentationController from "../controllers/presentationController.js";
const presentations = new Hono<{ Variables: AppVariables }>();
// Sprint 4a — POST /presentations/generate
//
// Body : { reponses: { prenom_age_ville, formation_metier, situation_familiale,
// loisirs, motivation_canada } }
// Réponse : { presentation: string }
//
// Pas de stockage en base — le frontend gère la persistance locale (MVP).
presentations.post("/generate", authMiddleware, async (c) => {
let body: { reponses?: unknown };
try {
body = await c.req.json();
} catch {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
const result = await presentationController.generate(body.reponses);
if ("error" in result) {
return c.json(result, result.status as 400 | 500);
}
return c.json(result.data, 200);
});
export default presentations;

View file

@ -0,0 +1,36 @@
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import { createTemporaryToken } from "../lib/deepgram.js";
// Sprint 4b — POST /transcriptions/token
//
// Délivre un token Deepgram éphémère (10 min) que le frontend utilise pour
// ouvrir une connexion directe à l'API Deepgram. La clé maître DEEPGRAM_API_KEY
// reste côté serveur. Aucun proxy WebSocket — la transcription live est gérée
// navigateur ↔ Deepgram.
const TOKEN_TTL_SECONDS = 600;
const transcriptions = new Hono<{ Variables: AppVariables }>();
transcriptions.post("/token", authMiddleware, async (c) => {
try {
const { token, expires_in } = await createTemporaryToken(TOKEN_TTL_SECONDS);
return c.json({ token, expires_in }, 200);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[transcriptions.token] generation failed", { message });
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message:
"Impossible de générer le token de transcription. Veuillez réessayer.",
},
500,
);
}
});
export default transcriptions;