feat(t1-live): examinateur avec interruption probabiliste pilotee backend (Sprint 7a)
- Session T1 Live : monologue candidat + interruptions pilotees backend (VAD manuel).
- Voix examinateur native Gemini ; le backend decide le timing (tirage probabiliste 0-2, fenetre [25s,75s]), Gemini formule la relance sur signal d'injection (anti-TD-22).
- Injection : activityEnd -> clientContent -> activityStart ; signaux WS interruption_start/end.
- Fin de session : activityEnd final flushe le dernier segment candidat ; relance terminale coupee (audio non renvoye, texte jete) ; seul le texte candidat conserve pour l'evaluation.
- buildT1SystemPrompt : nouvel artefact, regle 7 du T2 NON propagee (questions autorisees).
- Route /t1/live : auth Premium reutilisee, contexte questionnaire dynamique, persistance EO_T1 (sujet_id null), evaluation via correctEO('EO_T1'), phonologie stub /4 (TD-08 gele).
- geminiLive.ts : exports additifs + buildSetupFrame parametrable VAD (T2 inchange).
- gitignore : exclusion des artefacts jetables de test/spike.
This commit is contained in:
parent
5f7e52d88a
commit
868bd09397
7 changed files with 1404 additions and 17 deletions
238
src/routes/__tests__/t1live.test.ts
Normal file
238
src/routes/__tests__/t1live.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
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 { parseT1Context, runT1LiveCorrection } from "../t1live";
|
||||
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 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 REPONSES = {
|
||||
prenom_age_ville: "Hermann, 35 ans, Lyon",
|
||||
formation_metier: "ingénieur en informatique",
|
||||
situation_familiale: "marié, deux enfants",
|
||||
loisirs: "la randonnée et la photographie",
|
||||
motivation_canada: "de meilleures opportunités professionnelles",
|
||||
};
|
||||
|
||||
const FAKE_RAPPORT = {
|
||||
score: 14,
|
||||
nclc: 8,
|
||||
nclc_cible: 9 as const,
|
||||
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("parseT1Context", () => {
|
||||
it("accepte un message {type:'context', reponses} valide", () => {
|
||||
const result = parseT1Context(
|
||||
JSON.stringify({ type: "context", reponses: REPONSES }),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, reponses: REPONSES });
|
||||
});
|
||||
|
||||
it("refuse un message sans type 'context'", () => {
|
||||
const result = parseT1Context(
|
||||
JSON.stringify({ type: "audio", data: "AAAA" }),
|
||||
);
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("refuse un contexte aux réponses invalides (champ manquant)", () => {
|
||||
const { motivation_canada: _omit, ...partiel } = REPONSES;
|
||||
const result = parseT1Context(
|
||||
JSON.stringify({ type: "context", reponses: partiel }),
|
||||
);
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("refuse un payload non-JSON", () => {
|
||||
expect(parseT1Context("pas du json {")).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runT1LiveCorrection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const profile = { id: "u1", plan: "premium" as const };
|
||||
|
||||
it("transcript vide → EMPTY_TRANSCRIPT + close 1000 sans appeler DeepSeek", async () => {
|
||||
const ws = new FakeWs();
|
||||
await runT1LiveCorrection({ clientWs: ws, profile, 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 EO_T1 (sujet_id null) → DeepSeek → update → report → close 1000", async () => {
|
||||
const ws = new FakeWs();
|
||||
const insertSpy = vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
single: vi.fn(async () => ({ data: { id: "prod-t1" }, error: null })),
|
||||
})),
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({ insert: insertSpy } as any);
|
||||
vi.mocked(deepseekCorrectEO).mockResolvedValueOnce(FAKE_RAPPORT);
|
||||
mockProductionUpdate();
|
||||
|
||||
await runT1LiveCorrection({
|
||||
clientWs: ws,
|
||||
profile,
|
||||
transcript:
|
||||
"Candidat : Je m'appelle Hermann\nExaminateur : Où vivez-vous ?",
|
||||
});
|
||||
|
||||
// Persistance : tache EO_T1, sujet_id NULL.
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: "u1",
|
||||
tache: "EO_T1",
|
||||
sujet_id: null,
|
||||
mode: "entrainement",
|
||||
}),
|
||||
);
|
||||
// Correction : tache EO_T1, nclcCible 9, pas de consigne.
|
||||
expect(deepseekCorrectEO).toHaveBeenCalledWith(
|
||||
"Candidat : Je m'appelle Hermann\nExaminateur : Où vivez-vous ?",
|
||||
"EO_T1",
|
||||
9,
|
||||
null,
|
||||
);
|
||||
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-t1");
|
||||
});
|
||||
|
||||
it("insert production échoue → PERSISTENCE_FAILED + close 1011", async () => {
|
||||
const ws = new FakeWs();
|
||||
mockProductionInsert(null, "db down");
|
||||
|
||||
await runT1LiveCorrection({
|
||||
clientWs: ws,
|
||||
profile,
|
||||
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-t1");
|
||||
vi.mocked(deepseekCorrectEO).mockRejectedValueOnce(new Error("timeout"));
|
||||
|
||||
await runT1LiveCorrection({
|
||||
clientWs: ws,
|
||||
profile,
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue