Sprint 6a — Backend T2 Live (WS proxy + correction + persistance)

feat(geminiLive): dynamic prompt builder, transcript accumulation,
  VAD config (END_SENSITIVITY_LOW, 2s silence), 210s timeout + 180s warning
feat(t2live): sujet fetch + validation, correction pipeline (deepseekCorrectEO
  + PHONOLOGY_STUB TD-08), production insert + report delivery via WS
feat(deepseek): TacheEO extended with EO_T2, VALID_TACHES_EO updated
test: 11 geminiLive tests (rewritten + 4 new), 10 t2live integration tests
  292/292 backend tests green (+15)
This commit is contained in:
Hermann_Kitio 2026-04-26 19:50:48 +03:00
parent 28f8373f5d
commit d89b0b1e89
8 changed files with 1218 additions and 254 deletions

View file

@ -59,14 +59,14 @@ describe("POST /corrections/eo — Sprint 4a", () => {
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si tache invalide (EO_T2 par exemple)", async () => {
it("400 si tache invalide (hors EO_T1/T2/T3)", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T2",
tache: "EE_T1",
transcript: "t",
}),
});

View file

@ -0,0 +1,319 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
// ─── Mocks ───────────────────────────────────────────────────────────────────
vi.mock("../../lib/supabase", () => ({
supabase: {
auth: {
getUser: vi.fn(),
},
from: vi.fn(),
},
}));
vi.mock("../../lib/deepseek", async () => {
const actual =
await vi.importActual<typeof import("../../lib/deepseek")>(
"../../lib/deepseek",
);
return {
...actual,
correctEO: vi.fn(),
};
});
vi.mock("../../lib/geminiPhonology", () => ({
PHONOLOGY_STUB: {
score: 2,
commentaire: "Stub",
note_phonologie: "Stub",
},
}));
import { supabase } from "../../lib/supabase";
import { correctEO as deepseekCorrectEO } from "../../lib/deepseek";
import { authenticate, fetchSujetT2, runT2LiveCorrection } from "../t2live";
import type { WebSocketLike } from "../../lib/geminiLive";
// ─── Helpers ─────────────────────────────────────────────────────────────────
class FakeWs extends EventEmitter implements WebSocketLike {
public sent: unknown[] = [];
public closed = false;
public closeCode?: number;
public closeReason?: string;
send(data: unknown): void {
this.sent.push(data);
}
close(code?: number, reason?: string): void {
if (this.closed) return;
this.closed = true;
this.closeCode = code;
this.closeReason = reason;
}
}
function mockProfileQuery(plan: string | null, userId = "u1") {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
plan === null
? { data: null, error: { message: "not found" } }
: { data: { id: userId, plan }, error: null },
),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockSujetQuery(
row: {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
} | null,
) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
row === null
? { data: null, error: { message: "not found" } }
: { data: row, error: null },
),
})),
})),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockProductionInsert(
resultId: string | null,
errorMsg: string | null = null,
) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
insert: vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn(async () =>
errorMsg
? { data: null, error: { message: errorMsg } }
: { data: { id: resultId }, error: null },
),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
function mockProductionUpdate(errorMsg: string | null = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: vi.fn(() => ({
eq: vi.fn(async () =>
errorMsg ? { error: { message: errorMsg } } : { error: null },
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
}
const FAKE_RAPPORT = {
score: 14,
nclc: 8,
nclc_cible: 9,
revelation: { croyance: "a", realite: "b", consequence: "c" },
diagnostic: "d",
criteres: [],
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "e", action_prioritaire: "p" },
erreurs_codes: [],
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("authenticate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refuse si token absent → 4001", async () => {
const result = await authenticate(undefined);
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si Supabase rejette le JWT → 4001", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: null } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: { message: "invalid" } as any,
});
const result = await authenticate("bad-token");
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si plan ne donne pas oral_t2_live → 4003", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("standard");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: false,
code: 4003,
reason: "PLAN_INSUFFICIENT",
});
});
it("accepte un utilisateur Premium → ok:true + profile", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("premium");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: true,
profile: { id: "u1", plan: "premium" },
});
});
});
describe("fetchSujetT2", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne null si Supabase ne trouve pas le sujet", async () => {
mockSujetQuery(null);
const result = await fetchSujetT2("unknown-id");
expect(result).toBeNull();
});
it("retourne le sujet si trouvé", async () => {
const row = {
id: "s1",
role: "un bailleur",
contexte: "Vous cherchez un appartement.",
consigne: "Appelez le bailleur.",
};
mockSujetQuery(row);
const result = await fetchSujetT2("s1");
expect(result).toEqual(row);
});
});
describe("runT2LiveCorrection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const profile = { id: "u1", plan: "premium" as const };
const sujet = {
id: "s1",
role: "un bailleur",
contexte: "Recherche appartement.",
consigne: "Appelez le bailleur.",
};
it("transcript vide → envoie EMPTY_TRANSCRIPT et close 1000 sans appeler DeepSeek", async () => {
const ws = new FakeWs();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: " ",
});
expect(deepseekCorrectEO).not.toHaveBeenCalled();
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1000);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent).toMatchObject({ type: "error", code: "EMPTY_TRANSCRIPT" });
});
it("flux nominal : insert production → DeepSeek → update → report → close 1000", async () => {
const ws = new FakeWs();
mockProductionInsert("prod-123");
vi.mocked(deepseekCorrectEO).mockResolvedValueOnce(FAKE_RAPPORT);
mockProductionUpdate();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour\nExaminateur : Bonjour",
});
expect(deepseekCorrectEO).toHaveBeenCalledWith(
"Candidat : Bonjour\nExaminateur : Bonjour",
"EO_T2",
9,
"Appelez le bailleur.",
);
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1000);
const reportFrame = ws.sent.find(
(f) => typeof f === "string" && f.includes('"report"'),
);
expect(reportFrame).toBeDefined();
const parsed = JSON.parse(reportFrame as string);
expect(parsed.type).toBe("report");
// Score textuel 14 + phonologie stub 2 = 16
expect(parsed.data.score).toBe(16);
expect(parsed.data.nclc).toBe(8);
expect(parsed.data.simulation_id).toBe("prod-123");
});
it("insert production échoue → PERSISTENCE_FAILED + close 1011", async () => {
const ws = new FakeWs();
mockProductionInsert(null, "db down");
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour",
});
expect(deepseekCorrectEO).not.toHaveBeenCalled();
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1011);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent.code).toBe("PERSISTENCE_FAILED");
});
it("DeepSeek throw → CORRECTION_FAILED + close 1011", async () => {
const ws = new FakeWs();
mockProductionInsert("prod-456");
vi.mocked(deepseekCorrectEO).mockRejectedValueOnce(new Error("timeout"));
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour",
});
expect(ws.closed).toBe(true);
expect(ws.closeCode).toBe(1011);
const sent = JSON.parse(ws.sent[0] as string);
expect(sent.code).toBe("CORRECTION_FAILED");
});
});

View file

@ -4,7 +4,7 @@ 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_EO = ["EO_T1", "EO_T2", "EO_T3"];
const corrections = new Hono<{ Variables: AppVariables }>();
@ -200,7 +200,7 @@ corrections.post("/eo", authMiddleware, async (c) => {
const result = await correctionController.correctEO(
{
simulationId: body.simulationId,
tache: body.tache as "EO_T1" | "EO_T3",
tache: body.tache as "EO_T1" | "EO_T2" | "EO_T3",
nclcCible,
transcript: hasTranscript ? (body.transcript as string) : undefined,
audioBase64: hasAudio ? (body.audioBase64 as string) : undefined,

View file

@ -1,101 +1,343 @@
import { Hono } from 'hono'
import type { UpgradeWebSocket } from 'hono/ws'
import { EventEmitter } from 'node:events'
import { supabase } from '../lib/supabase.js'
import { checkFeatureAccess } from '../lib/access.js'
import type { Plan } from '../lib/access.js'
import { Hono } from "hono";
import type { UpgradeWebSocket } from "hono/ws";
import { EventEmitter } from "node:events";
import { supabase } from "../lib/supabase.js";
import { checkFeatureAccess } from "../lib/access.js";
import type { Plan } from "../lib/access.js";
import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
import {
openGeminiLiveSession,
type WebSocketLike,
} from '../lib/geminiLive.js'
type OpenGeminiLiveSessionOptions,
} from "../lib/geminiLive.js";
interface SujetRow {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
}
interface Profile {
id: string;
plan: Plan;
}
interface AuthSucces {
ok: true;
profile: Profile;
}
interface AuthFailure {
ok: false;
code: number;
reason: string;
}
export async function authenticate(
token: string | undefined,
): Promise<AuthSucces | AuthFailure> {
if (!token) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token);
if (authError || !user) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("id, plan")
.eq("id", user.id)
.single();
if (profileError || !profile) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
if (!checkFeatureAccess(profile.plan as Plan, "oral_t2_live")) {
return { ok: false, code: 4003, reason: "PLAN_INSUFFICIENT" };
}
return {
ok: true,
profile: { id: profile.id as string, plan: profile.plan as Plan },
};
} catch {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
}
export async function fetchSujetT2(sujetId: string): Promise<SujetRow | null> {
const { data, error } = await supabase
.from("sujets")
.select("id, role, contexte, consigne")
.eq("id", sujetId)
.eq("mode", "EO")
.eq("tache", 2)
.single();
if (error || !data) return null;
return data as SujetRow;
}
/**
* Pipeline post-session : crée la production, lance la correction EO sur le
* transcript reconstruit, persiste le rapport, envoie au client puis ferme.
*
* Cf. docs/IMPLEMENTATION_T2_LIVE.md §3 Phase 3.
*
* Notes :
* - tache='EO_T2' pour la correction (le pipeline DeepSeek), tache='EO_T2_LIVE'
* pour la persistance (enum DB).
* - Phonologie = PHONOLOGY_STUB (TD-08 pas d'audio brut côté backend).
*/
export async function runT2LiveCorrection(args: {
clientWs: WebSocketLike;
profile: Profile;
sujet: SujetRow;
transcript: string;
}): Promise<void> {
const { clientWs, profile, sujet, transcript } = args;
if (transcript.trim().length === 0) {
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "EMPTY_TRANSCRIPT",
message: "Aucun échange enregistré.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000, "EMPTY_TRANSCRIPT");
} catch {
/* ignore */
}
return;
}
// 1. Créer la production (rapport=null pour l'instant).
const { data: created, error: insertError } = await supabase
.from("productions")
.insert({
user_id: profile.id,
tache: "EO_T2_LIVE",
mode: "entrainement",
sujet_id: sujet.id,
contenu: transcript,
})
.select("id")
.single();
if (insertError || !created) {
console.error("[T2] production insert failed:", insertError?.message);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "PERSISTENCE_FAILED",
message: "Impossible d'enregistrer la session.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "PERSISTENCE_FAILED");
} catch {
/* ignore */
}
return;
}
const productionId = (created as { id: string }).id;
// 2. Lancer la correction EO via DeepSeek.
let rapport;
try {
rapport = await deepseekCorrectEO(
transcript,
"EO_T2",
9,
sujet.consigne ?? null,
);
} catch (err) {
console.error(
"[T2] DeepSeek correction failed:",
err instanceof Error ? err.message : String(err),
);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "CORRECTION_FAILED",
message: "Erreur lors de la correction.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "CORRECTION_FAILED");
} catch {
/* ignore */
}
return;
}
// 3. Appliquer phonologie stub (TD-08) : score textuel /16 + phonologie /4 = /20.
const scoreTextuel = rapport.score;
const scoreFinal = scoreTextuel + PHONOLOGY_STUB.score;
// 4. Persister le rapport.
const { error: updateError } = await supabase
.from("productions")
.update({
rapport,
score: scoreFinal,
nclc: rapport.nclc,
})
.eq("id", productionId);
if (updateError) {
console.error("[T2] production update failed:", updateError.message);
}
// 5. Envoyer le rapport au client puis fermer.
try {
clientWs.send(
JSON.stringify({
type: "report",
data: {
...rapport,
score: scoreFinal,
simulation_id: productionId,
},
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000);
} catch {
/* ignore */
}
}
export interface CreateT2LiveRoutesOptions {
/** Injection pour les tests : fabrique de WebSocket vers Gemini. */
geminiFactory?: OpenGeminiLiveSessionOptions["geminiFactory"];
/** Injection pour les tests : override timeout/warning. */
timeoutMs?: number;
warningMs?: number;
}
/**
* Crée le router pour `WS /t2/live`.
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
* - Refus auth close 4001, refus plan close 4003
* - OK openGeminiLiveSession (proxy vers Gemini Live)
* - Sujet : id passé en query param `?sujet=<uuid>` table `sujets` (mode='EO', tache=2)
* - Refus auth 4001, refus plan 4003, sujet introuvable 4004
* - OK openGeminiLiveSession onSessionEnd : correction EO + persistance + report
*/
export default function createT2LiveRoutes(
upgradeWebSocket: UpgradeWebSocket
upgradeWebSocket: UpgradeWebSocket,
opts: CreateT2LiveRoutesOptions = {},
) {
const app = new Hono()
const app = new Hono();
app.get(
'/live',
"/live",
upgradeWebSocket(async (c) => {
const token = c.req.query('token')
let denyCode: number | null = null
let denyReason = ''
const token = c.req.query("token");
const sujetId = c.req.query("sujet");
if (!token) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
let denyCode: number | null = null;
let denyReason = "";
let profile: Profile | null = null;
let sujet: SujetRow | null = null;
const auth = await authenticate(token);
if (!auth.ok) {
denyCode = auth.code;
denyReason = auth.reason;
} else {
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token)
if (authError || !user) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
} else {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('plan')
.eq('id', user.id)
.single()
if (profileError || !profile) {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
} else if (
!checkFeatureAccess(profile.plan as Plan, 'oral_t2_live')
) {
denyCode = 4003
denyReason = 'PLAN_INSUFFICIENT'
}
profile = auth.profile;
if (!sujetId) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else {
sujet = await fetchSujetT2(sujetId);
if (!sujet) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else if (!sujet.role || !sujet.contexte) {
// Sécurité : un sujet T2 sans role/contexte ne peut pas alimenter le prompt.
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
}
} catch {
denyCode = 4001
denyReason = 'AUTH_REQUIRED'
}
}
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
const adapter = new EventEmitter() as EventEmitter & WebSocketLike
adapter.send = () => {}
adapter.close = () => {}
const adapter = new EventEmitter() as EventEmitter & WebSocketLike;
adapter.send = () => {};
adapter.close = () => {};
return {
onOpen(_evt, ws) {
adapter.send = (data: unknown) =>
ws.send(data as Parameters<typeof ws.send>[0])
ws.send(data as Parameters<typeof ws.send>[0]);
adapter.close = (code?: number, reason?: string) =>
ws.close(code, reason)
ws.close(code, reason);
if (denyCode !== null) {
ws.send(JSON.stringify({ error: true, code: denyReason }))
setTimeout(() => ws.close(denyCode!, denyReason), 100)
return
try {
ws.send(JSON.stringify({ error: true, code: denyReason }));
} catch {
/* ignore */
}
setTimeout(() => ws.close(denyCode!, denyReason), 100);
return;
}
openGeminiLiveSession(adapter)
// À ce stade : profile et sujet sont garantis non-null par les checks ci-dessus.
const profileNonNull = profile!;
const sujetNonNull = sujet!;
openGeminiLiveSession(adapter, {
role: sujetNonNull.role!,
contexte: sujetNonNull.contexte!,
geminiFactory: opts.geminiFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
onSessionEnd: async (transcript) => {
await runT2LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
sujet: sujetNonNull,
transcript,
});
},
});
},
onMessage(evt) {
adapter.emit('message', evt.data)
adapter.emit("message", evt.data);
},
onClose() {
adapter.emit('close')
adapter.emit("close");
},
onError() {
adapter.emit('error', new Error('CLIENT_ERROR'))
adapter.emit("error", new Error("CLIENT_ERROR"));
},
}
})
)
};
}),
);
return app
return app;
}