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
|
|
@ -1,134 +1,287 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
openGeminiLiveSession,
|
||||
T2_SYSTEM_PROMPT,
|
||||
buildT2SystemPrompt,
|
||||
type WebSocketLike,
|
||||
} from '../geminiLive'
|
||||
} from "../geminiLive";
|
||||
|
||||
class FakeWs extends EventEmitter implements WebSocketLike {
|
||||
public sent: unknown[] = []
|
||||
public closed = false
|
||||
public closeCode?: number
|
||||
public closeReason?: string
|
||||
public sent: unknown[] = [];
|
||||
public closed = false;
|
||||
public closeCode?: number;
|
||||
public closeReason?: string;
|
||||
|
||||
send(data: unknown): void {
|
||||
this.sent.push(data)
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.closeCode = code
|
||||
this.closeReason = reason
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.closeCode = code;
|
||||
this.closeReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
describe('openGeminiLiveSession', () => {
|
||||
let originalKey: string | undefined
|
||||
const SUJET_OPTS = {
|
||||
role: "un bailleur qui propose un appartement à louer",
|
||||
contexte:
|
||||
"Vous cherchez un appartement de 2 pièces dans le centre-ville, votre budget est limité et vous souhaitez emménager le mois prochain.",
|
||||
};
|
||||
|
||||
describe("buildT2SystemPrompt", () => {
|
||||
it("substitue role et contexte dans le template", () => {
|
||||
const prompt = buildT2SystemPrompt(SUJET_OPTS);
|
||||
expect(prompt).toContain(
|
||||
"Tu joues le rôle de un bailleur qui propose un appartement à louer",
|
||||
);
|
||||
expect(prompt).toContain("Vous cherchez un appartement");
|
||||
expect(prompt).toContain("uniquement en français");
|
||||
expect(prompt).toContain("Tu ne prends PAS la parole en premier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openGeminiLiveSession", () => {
|
||||
let originalKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalKey = process.env.GEMINI_API_KEY
|
||||
process.env.GEMINI_API_KEY = 'test-key'
|
||||
})
|
||||
originalKey = process.env.GEMINI_API_KEY;
|
||||
process.env.GEMINI_API_KEY = "test-key";
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
} else {
|
||||
process.env.GEMINI_API_KEY = originalKey
|
||||
process.env.GEMINI_API_KEY = originalKey;
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("envoie le setup frame avec T2_SYSTEM_PROMPT a l'open Gemini", () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
it("envoie le setup frame avec prompt dynamique + VAD + transcriptions", () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
expect(gemini.sent).toHaveLength(1)
|
||||
const setup = JSON.parse(gemini.sent[0] as string)
|
||||
expect(setup.setup.model).toMatch(/gemini/)
|
||||
expect(setup.setup.systemInstruction.parts[0].text).toBe(T2_SYSTEM_PROMPT)
|
||||
expect(setup.setup.generationConfig.responseModalities).toContain('AUDIO')
|
||||
})
|
||||
expect(gemini.sent).toHaveLength(1);
|
||||
const setup = JSON.parse(gemini.sent[0] as string);
|
||||
expect(setup.setup.model).toMatch(/gemini/);
|
||||
expect(setup.setup.systemInstruction.parts[0].text).toContain(
|
||||
"un bailleur qui propose un appartement",
|
||||
);
|
||||
expect(setup.setup.generationConfig.responseModalities).toContain("AUDIO");
|
||||
expect(setup.setup.inputAudioTranscription).toEqual({});
|
||||
expect(setup.setup.outputAudioTranscription).toEqual({});
|
||||
expect(
|
||||
setup.setup.realtimeInputConfig.automaticActivityDetection,
|
||||
).toMatchObject({
|
||||
disabled: false,
|
||||
startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
|
||||
endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
|
||||
silenceDurationMs: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwarde un message client (Buffer audio) vers Gemini', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
it("forwarde un chunk audio client (Buffer) vers Gemini", () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04])
|
||||
client.emit('message', audioChunk)
|
||||
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04]);
|
||||
client.emit("message", audioChunk);
|
||||
|
||||
// [0] = setup frame, [1] = audio forwarde
|
||||
expect(gemini.sent).toHaveLength(2)
|
||||
expect(gemini.sent[1]).toBe(audioChunk)
|
||||
})
|
||||
// [0] = setup, [1] = chunk audio
|
||||
expect(gemini.sent).toHaveLength(2);
|
||||
expect(gemini.sent[1]).toBe(audioChunk);
|
||||
});
|
||||
|
||||
it('forwarde un message Gemini vers le client', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
it("forwarde un chunk audio Gemini (Buffer non-JSON) vers le client sans accumuler de transcript", async () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
onSessionEnd,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
const examinerAudio = Buffer.from([0x10, 0x20])
|
||||
gemini.emit('message', examinerAudio)
|
||||
const examinerAudio = Buffer.from([0x10, 0x20, 0x30]);
|
||||
gemini.emit("message", examinerAudio);
|
||||
expect(client.sent).toHaveLength(1);
|
||||
expect(client.sent[0]).toBe(examinerAudio);
|
||||
|
||||
expect(client.sent).toHaveLength(1)
|
||||
expect(client.sent[0]).toBe(examinerAudio)
|
||||
})
|
||||
// Fin de session via signal client → transcript vide
|
||||
client.emit("message", JSON.stringify({ type: "end" }));
|
||||
await vi.runAllTimersAsync();
|
||||
expect(onSessionEnd).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it('fermeture client → ferme Gemini avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
it("accumule inputTranscription et outputTranscription depuis Gemini", async () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
onSessionEnd,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
client.emit('close')
|
||||
gemini.emit(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
serverContent: {
|
||||
inputTranscription: { text: "Bonjour, je voudrais louer." },
|
||||
},
|
||||
}),
|
||||
);
|
||||
gemini.emit(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
serverContent: {
|
||||
outputTranscription: { text: "Bonjour, c’est pour quel quartier ?" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
gemini.emit(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
serverContent: {
|
||||
inputTranscription: { text: "Le centre-ville." },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(gemini.closed).toBe(true)
|
||||
expect(gemini.closeCode).toBe(1000)
|
||||
})
|
||||
client.emit("message", JSON.stringify({ type: "end" }));
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
it('fermeture Gemini → ferme client avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
expect(onSessionEnd).toHaveBeenCalledTimes(1);
|
||||
const transcript = onSessionEnd.mock.calls[0][0] as string;
|
||||
expect(transcript).toBe(
|
||||
"Candidat : Bonjour, je voudrais louer.\nExaminateur : Bonjour, c’est pour quel quartier ?\nCandidat : Le centre-ville.",
|
||||
);
|
||||
});
|
||||
|
||||
gemini.emit('close')
|
||||
it("ferme Gemini après onSessionEnd, sans fermer le client (réservé à l’appelant)", async () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
onSessionEnd,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1000)
|
||||
})
|
||||
client.emit("message", JSON.stringify({ type: "end" }));
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
it('erreur Gemini → ferme client avec code 1011 GEMINI_ERROR', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
expect(gemini.closed).toBe(true);
|
||||
expect(gemini.closeCode).toBe(1000);
|
||||
expect(client.closed).toBe(false);
|
||||
});
|
||||
|
||||
gemini.emit('error', new Error('boom'))
|
||||
it("warning à 180 s puis timeout à 210 s déclenche endSession", async () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
onSessionEnd,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('GEMINI_ERROR')
|
||||
})
|
||||
// Avancer à 180 s → warning au client
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
const warningFrame = client.sent.find(
|
||||
(f) => typeof f === "string" && f.includes('"warning"'),
|
||||
);
|
||||
expect(warningFrame).toBeDefined();
|
||||
expect(JSON.parse(warningFrame as string)).toEqual({
|
||||
type: "warning",
|
||||
message: "30 secondes restantes",
|
||||
});
|
||||
expect(onSessionEnd).not.toHaveBeenCalled();
|
||||
|
||||
it("absence de GEMINI_API_KEY → close client 1011 CONFIG_ERROR sans appel a la factory", () => {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
const client = new FakeWs()
|
||||
const factory = vi.fn(() => new FakeWs())
|
||||
// Avancer à 210 s total → timeout déclenche endSession
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(onSessionEnd).toHaveBeenCalledTimes(1);
|
||||
expect(gemini.closed).toBe(true);
|
||||
});
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: factory })
|
||||
it("signal end client déclenche endSession une seule fois (idempotent)", async () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
const onSessionEnd = vi.fn();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
onSessionEnd,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('CONFIG_ERROR')
|
||||
})
|
||||
})
|
||||
client.emit("message", JSON.stringify({ type: "end" }));
|
||||
client.emit("message", JSON.stringify({ type: "end" }));
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSessionEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fermeture Gemini avant fin → close client 4006 GEMINI_DISCONNECTED", () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
gemini.emit("close");
|
||||
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.closeCode).toBe(4006);
|
||||
expect(client.closeReason).toBe("GEMINI_DISCONNECTED");
|
||||
});
|
||||
|
||||
it("erreur Gemini → close client 4006 GEMINI_DISCONNECTED", () => {
|
||||
const client = new FakeWs();
|
||||
const gemini = new FakeWs();
|
||||
openGeminiLiveSession(client, {
|
||||
...SUJET_OPTS,
|
||||
geminiFactory: () => gemini,
|
||||
});
|
||||
gemini.emit("open");
|
||||
|
||||
gemini.emit("error", new Error("boom"));
|
||||
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.closeCode).toBe(4006);
|
||||
});
|
||||
|
||||
it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans appel à la factory", () => {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
const client = new FakeWs();
|
||||
const factory = vi.fn(() => new FakeWs());
|
||||
|
||||
openGeminiLiveSession(client, { ...SUJET_OPTS, geminiFactory: factory });
|
||||
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.closeCode).toBe(4005);
|
||||
expect(client.closeReason).toBe("GEMINI_CONFIG");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue