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:
Hermann_Kitio 2026-06-29 22:07:57 +03:00
parent 5f7e52d88a
commit 868bd09397
7 changed files with 1404 additions and 17 deletions

View 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");
});
});