expria-backend/src/lib/geminiLive.ts

481 lines
14 KiB
TypeScript

/**
* geminiLive.ts — Sprint 6d (revert WS brut).
*
* Le SDK `@google/genai` fermait la session sans setupComplete ni raison
* exploitable. On revient au WebSocket brut (package `ws`) qui était utilisé
* par `test-gemini-live.js` et permet de loguer précisément ce que Gemini
* répond. Config setup réduite au strict minimum tant que `setupComplete`
* n'est pas confirmé en prod ; on réintègre champs un par un ensuite.
*
* Interface publique (consommée par `routes/t2live.ts`) — INCHANGÉE :
* - openGeminiLiveSession(clientWs, opts)
* - WebSocketLike, OpenGeminiLiveSessionOptions
* - buildT2SystemPrompt({role, contexte})
* - GEMINI_LIVE_MODEL, T2_SESSION_TIMEOUT_MS, T2_SESSION_WARNING_MS
*/
import { WebSocket as NodeWebSocket } from "ws";
export const GEMINI_LIVE_URL =
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
/**
* Modèle Live cible. `gemini-2.0-flash-live-001` est le modèle Live confirmé
* par la doc Google pour les clés API Developer + Express. Format `models/...`
* dans le setup frame natif (cf. `test-gemini-live.js`).
*/
export const GEMINI_LIVE_MODEL = "gemini-3.1-flash-live-preview";
/** Timeout total session WS T2 Live : 3 min 30 (durée TCF) + marge évaluation. */
export const T2_SESSION_TIMEOUT_MS = 210_000;
/** Warning au client : 30 s avant le timeout. */
export const T2_SESSION_WARNING_MS = 180_000;
/**
* Construit le prompt système T2 Live à partir du sujet (role + contexte).
* Cf. docs/Prompt_t2live.md §3. Conservé en signature pour usage futur quand
* `systemInstruction` sera réintégré dans le setup frame.
*/
export function buildT2SystemPrompt(input: {
role: string;
contexte: string;
}): string {
const { role, contexte } = input;
return `Tu joues le rôle de ${role} dans la situation suivante : ${contexte}
Règles à respecter impérativement :
- Tu réponds uniquement en français, quelle que soit la langue de ton interlocuteur.
- Tu joues ton rôle de façon naturelle et réaliste. Tu n'es pas un examinateur — tu es ${role}.
- Tu réponds aux questions qu'on te pose de façon honnête et naturelle, comme le ferait une vraie personne dans cette situation.
- Tu ne facilites pas la tâche : tu ne reformules pas les questions, tu n'anticipes pas ce que l'interlocuteur veut savoir, tu ne lui suggères pas quoi demander.
- Si ton interlocuteur marque une longue pause ou semble avoir terminé, tu peux dire : "Avez-vous d'autres questions ?" — c'est la seule relance autorisée.
- Tu ne fais aucun commentaire sur la langue, les erreurs ou le niveau de français de ton interlocuteur.
- Tu ne sors jamais de ton rôle.
- Tu ne prends PAS la parole en premier. Tu attends que ton interlocuteur s'adresse à toi, puis tu réponds naturellement dans ton rôle.
- Tes réponses sont concises et naturelles : ni monosyllabiques, ni des monologues.`;
}
/**
* Subset minimal d'une WebSocket — compatible avec :
* - le wrapper exposé par @hono/node-ws (côté client navigateur)
* - la WebSocket de `ws` (côté Gemini)
* - les fakes basés sur EventEmitter dans les tests
*/
export interface WebSocketLike {
send(data: unknown): void;
close(code?: number, reason?: string): void;
on(event: "message", listener: (data: unknown) => void): void;
on(event: "close", listener: (code?: number, reason?: unknown) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open", listener: () => void): void;
}
export interface OpenGeminiLiveSessionOptions {
/** Rôle joué par l'IA, injecté dans le prompt système. */
role: string;
/** Contexte de la situation, injecté dans le prompt système. */
contexte: string;
/** Callback déclenché en fin de session avec le transcript reconstruit. */
onSessionEnd?: (transcript: string) => void | Promise<void>;
/** Override timeout (par défaut T2_SESSION_TIMEOUT_MS). */
timeoutMs?: number;
/** Override warning (par défaut T2_SESSION_WARNING_MS). */
warningMs?: number;
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
apiKey?: string;
/**
* Injection pour les tests — fabrique de WebSocket vers Gemini.
*/
clientFactory?: (url: string) => WebSocketLike;
}
/**
* Forme minimale d'un message Gemini Live JSON entrant.
*/
interface GeminiServerMessage {
setupComplete?: unknown;
serverContent?: {
modelTurn?: {
parts?: Array<{
inlineData?: { data?: string; mimeType?: string };
}>;
};
inputTranscription?: { text?: string };
outputTranscription?: { text?: string };
interrupted?: boolean;
turnComplete?: boolean;
};
}
interface TranscriptEntry {
speaker: "candidat" | "examinateur";
text: string;
}
function reconstructTranscript(entries: TranscriptEntry[]): string {
return entries
.map((e) =>
e.speaker === "candidat"
? `Candidat : ${e.text}`
: `Examinateur : ${e.text}`,
)
.join("\n");
}
/**
* Détecte un signal de fin de session envoyé par le client : `{type:'end'}`.
*/
function isEndSignal(data: unknown): boolean {
let text: string;
if (typeof data === "string") {
text = data;
} else if (data instanceof Buffer) {
try {
text = data.toString("utf8");
} catch {
return false;
}
} else {
return false;
}
if (!text.startsWith("{")) return false;
try {
const parsed = JSON.parse(text) as { type?: string };
return parsed.type === "end";
} catch {
return false;
}
}
/**
* Parse un message client `{type:'audio', data: base64}` et renvoie le base64
* si le format est valide, sinon null.
*/
function parseAudioChunk(data: unknown): string | null {
let text: string;
if (typeof data === "string") {
text = data;
} else if (data instanceof Buffer) {
try {
text = data.toString("utf8");
} catch {
return null;
}
} else {
return null;
}
if (!text.startsWith("{")) return null;
try {
const parsed = JSON.parse(text) as { type?: string; data?: unknown };
if (parsed.type === "audio" && typeof parsed.data === "string") {
return parsed.data;
}
return null;
} catch {
return null;
}
}
/**
* Tente de parser un message Gemini en JSON. Retourne null si binaire / non-JSON.
*/
function tryParseGeminiJson(data: unknown): GeminiServerMessage | null {
let text: string;
if (typeof data === "string") {
text = data;
} else if (data instanceof Buffer) {
try {
text = data.toString("utf8");
if (!text.startsWith("{")) return null;
} catch {
return null;
}
} else if (typeof data === "object" && data !== null && "toString" in data) {
try {
text = (data as { toString: () => string }).toString();
if (!text.startsWith("{")) return null;
} catch {
return null;
}
} else {
return null;
}
try {
return JSON.parse(text) as GeminiServerMessage;
} catch {
return null;
}
}
/**
* Construit le setup frame minimal Gemini Live (équivalent du mode
* `minimal` de `test-gemini-live.js`). Les champs `systemInstruction`,
* `inputAudioTranscription`, `outputAudioTranscription`,
* `realtimeInputConfig.automaticActivityDetection` sont volontairement
* retirés tant que `setupComplete` n'est pas confirmé en prod.
*/
function buildSetupFrame(systemPrompt: string): string {
return JSON.stringify({
setup: {
model: `models/${GEMINI_LIVE_MODEL}`,
generationConfig: {
responseModalities: ["AUDIO"],
},
systemInstruction: {
parts: [{ text: systemPrompt }],
},
},
});
}
/**
* Ouvre une session Gemini Live via WebSocket brut (`ws://...?key=...`) et
* proxifie les messages dans les deux sens entre le client (navigateur) et
* Gemini.
*
* - URL : GEMINI_LIVE_URL?key=apiKey
* - À l'open Gemini : envoi du setup frame minimal.
* - Forward client → Gemini : parse `{type:'audio', data: base64}` →
* message JSON `{ realtimeInput: { audio: { data, mimeType } } }`.
* - Forward Gemini → client : forward verbatim (string ou Buffer).
* - Accumule input/outputTranscription pour la correction finale.
* - Détecte `{type:'end'}` du client → fin de session.
* - Timer 210 s : warning à 180 s, fin auto à 210 s.
* - En fin : `onSessionEnd(transcript)` puis ferme Gemini. Le client WS
* n'est PAS fermé ici — c'est l'appelant qui décide.
* - Erreur Gemini / close prématurée → close client 4006 GEMINI_DISCONNECTED.
* - GEMINI_API_KEY absente → close client 4005 GEMINI_CONFIG.
*/
export function openGeminiLiveSession(
clientWs: WebSocketLike,
opts: OpenGeminiLiveSessionOptions,
): void {
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
if (!apiKey) {
clientWs.close(4005, "GEMINI_CONFIG");
return;
}
const timeoutMs = opts.timeoutMs ?? T2_SESSION_TIMEOUT_MS;
const warningMs = opts.warningMs ?? T2_SESSION_WARNING_MS;
const systemPrompt = buildT2SystemPrompt({
role: opts.role,
contexte: opts.contexte,
});
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const factory =
opts.clientFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***");
console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL);
const geminiWs = factory(url);
const transcriptEntries: TranscriptEntry[] = [];
let sessionEnded = false;
let warningTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = () => {
if (warningTimer !== null) {
clearTimeout(warningTimer);
warningTimer = null;
}
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
};
const endSession = async () => {
if (sessionEnded) return;
sessionEnded = true;
clearTimers();
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
if (opts.onSessionEnd) {
try {
await opts.onSessionEnd(reconstructTranscript(transcriptEntries));
} catch (err) {
console.error(
"[T2] onSessionEnd threw:",
err instanceof Error ? err.message : String(err),
);
}
}
};
geminiWs.on("open", () => {
console.log("[T2] Gemini WS open");
const frame = buildSetupFrame(systemPrompt);
console.log("[T2] Gemini setup frame:", frame);
try {
geminiWs.send(frame);
} catch (err) {
console.error(
"[T2] Gemini setup frame send failed:",
err instanceof Error ? err.message : String(err),
);
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
return;
}
// Timers démarrés à l'ouverture de la WS (avant setupComplete éventuel).
warningTimer = setTimeout(() => {
if (sessionEnded) return;
try {
clientWs.send(
JSON.stringify({
type: "warning",
message: "30 secondes restantes",
}),
);
} catch {
/* ignore */
}
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
});
geminiWs.on("message", (data) => {
const preview =
typeof data === "string"
? data.slice(0, 300)
: data instanceof Buffer
? data.toString("utf8").slice(0, 300)
: "[binary]";
console.log("[T2] Gemini WS message:", preview);
// Accumuler input/outputTranscription.
const parsed = tryParseGeminiJson(data);
if (parsed) {
const sc = parsed.serverContent;
if (
sc?.inputTranscription?.text &&
sc.inputTranscription.text.length > 0
) {
transcriptEntries.push({
speaker: "candidat",
text: sc.inputTranscription.text,
});
}
if (
sc?.outputTranscription?.text &&
sc.outputTranscription.text.length > 0
) {
transcriptEntries.push({
speaker: "examinateur",
text: sc.outputTranscription.text,
});
}
}
// Forward verbatim au client (string ou Buffer audio inlineData).
try {
clientWs.send(data);
} catch {
void endSession();
}
});
geminiWs.on("close", (code, reason) => {
const reasonStr =
reason instanceof Buffer
? reason.toString("utf8")
: typeof reason === "string"
? reason
: "";
console.log(
"[T2] Gemini WS close:",
JSON.stringify({ code, reason: reasonStr }),
);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
geminiWs.on("error", (err) => {
console.log(
"[T2] Gemini WS error:",
JSON.stringify(err instanceof Error ? { message: err.message } : err),
);
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
// ── Forward client → Gemini ──────────────────────────────────────────
clientWs.on("message", (data) => {
if (isEndSignal(data)) {
void endSession();
return;
}
const audioBase64 = parseAudioChunk(data);
if (audioBase64 !== null && !sessionEnded) {
try {
geminiWs.send(
JSON.stringify({
realtimeInput: {
audio: {
data: audioBase64,
mimeType: "audio/pcm;rate=16000",
},
},
}),
);
} catch (err) {
console.log(
"[T2] Gemini WS send (audio) failed:",
err instanceof Error ? err.message : String(err),
);
void endSession();
}
}
// Tout autre message client est ignoré.
});
clientWs.on("close", () => {
clearTimers();
sessionEnded = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
});
clientWs.on("error", () => {
clearTimers();
sessionEnded = true;
try {
geminiWs.close(1011);
} catch {
/* ignore */
}
});
}