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:
parent
28f8373f5d
commit
d89b0b1e89
8 changed files with 1218 additions and 254 deletions
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
319
src/routes/__tests__/t2live.test.ts
Normal file
319
src/routes/__tests__/t2live.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue