From 653fc3150ecde684b13e38369700d788219e8316 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Fri, 17 Apr 2026 03:39:21 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20WS=20/t2/live=20=E2=80=94=20proxy=20Gem?= =?UTF-8?q?ini=20Live=20API=20=E2=80=94=20124/124=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- docs/ARCHITECTURE.md | 77 +++------------ package-lock.json | 21 +++- package.json | 5 +- src/index.ts | 8 +- src/lib/__tests__/geminiLive.test.ts | 134 +++++++++++++++++++++++++ src/lib/geminiLive.ts | 140 +++++++++++++++++++++++++++ src/routes/t2live.ts | 100 +++++++++++++++++++ 8 files changed, 422 insertions(+), 66 deletions(-) create mode 100644 src/lib/__tests__/geminiLive.test.ts create mode 100644 src/lib/geminiLive.ts create mode 100644 src/routes/t2live.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b0b9237..733a6b1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(npm run:*)", - "Bash(SUPABASE_URL=https://dummy.supabase.co SUPABASE_SERVICE_ROLE_KEY=dummy node -e \"import\\('./dist/index.js'\\).then\\(\\(\\) => { console.log\\('IMPORT_OK'\\); process.exit\\(0\\); }\\).catch\\(e => { console.error\\('IMPORT_FAIL:', e.message\\); process.exit\\(1\\); }\\)\")" + "Bash(SUPABASE_URL=https://dummy.supabase.co SUPABASE_SERVICE_ROLE_KEY=dummy node -e \"import\\('./dist/index.js'\\).then\\(\\(\\) => { console.log\\('IMPORT_OK'\\); process.exit\\(0\\); }\\).catch\\(e => { console.error\\('IMPORT_FAIL:', e.message\\); process.exit\\(1\\); }\\)\")", + "Bash(npm install:*)" ] } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c72453e..158bf58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md — Expria / Coach TCF Canada -> **Document de référence — Version 1.1** +> **Document de référence — Version 1.2** > Ce document décrit l'architecture technique complète du projet. > Toute décision architecturale majeure doit être documentée ici avant d'être implémentée. > À lire conjointement avec PLANS_TARIFAIRES.md et PARCOURS_UTILISATEURS.md. @@ -424,35 +424,17 @@ NODE_ENV=production ## 9. Déploiement -### Contexte — Contrainte d'hébergement Git +### Hébergement Git — GitHub -GitHub et GitLab appliquent les sanctions américaines OFAC qui restreignent l'accès -aux résidents de Crimée. Ces plateformes ne sont pas utilisables de façon fiable -pour ce projet. - -**Solution actuelle (Phase 1 — MVP) :** -Codeberg (plateforme européenne, Allemagne) pour l'hébergement Git privé. -Render ne supporte pas l'auto-deploy depuis Codeberg — le déploiement est donc manuel. - -**Évolution prévue (Phase 2 — après premiers revenus) :** -Migration vers un VPS Hetzner (3,29€/mois) avec Coolify. -Coolify supporte l'auto-deploy depuis n'importe quel serveur Git privé (Codeberg, Gitea). -Cette migration supprime la dépendance à Render et restaure l'auto-deploy complet. - ---- - -### Hébergement Git — Codeberg (Phase 1) - -- Plateforme : codeberg.org (Allemagne — hors juridiction américaine) -- Dépôts : privés -- Dépôt frontend : `https://codeberg.org/Hermann_Kitio/expria-frontend` -- Dépôt backend : `https://codeberg.org/Hermann_Kitio/expria-backend` -- Dépôt archive (ancienne version) : `https://codeberg.org/Hermann_Kitio/Expria` -- Limitation : pas d'auto-deploy natif vers Render +- Plateforme : github.com +- Dépôt frontend : `https://github.com/germannoff/expria-frontend` +- Dépôt backend : `https://github.com/germannoff/expria-backend` +- Note : compte GitHub réactivé le 17 avril 2026 après restriction OFAC levée +- Auto-deploy : disponible via Render (connecté à GitHub) ### Frontend — Cloudflare Pages -- Source : dépôt Codeberg `expria-frontend` +- Source : dépôt GitHub `expria-frontend` - Build command : `npm run build` - Output directory : `dist` - Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages) @@ -466,23 +448,15 @@ npx wrangler pages deploy dist --project-name=expria ### Backend — Render -- Source : dépôt Codeberg `expria-backend` +- Source : dépôt GitHub `expria-backend` - Type : Web Service (Node.js) - Région : Frankfurt (EU) — proximité utilisateurs Afrique du Nord - Build command : `npm run build` - Start command : `npm start` -- Domaine : `api.expria.app` +- Domaine : `api.expria.app` (certificat SSL actif) +- URL Render : `https://expria-backend.onrender.com` (alias) - WebSocket : activé nativement sur Render -- Déploiement : **manuel via Render CLI ou dashboard** - -```bash -# Commande de déploiement backend -# Option 1 : via Render CLI -render deploy - -# Option 2 : via dashboard Render -# → Manual Deploy → Deploy latest commit -``` +- Déploiement : **automatique à chaque push sur main (GitHub → Render)** ### Base de données — Supabase @@ -490,39 +464,18 @@ render deploy - Migrations : versionnées dans `supabase/migrations/` - Déploiement : `supabase db push` (manuel, après validation) -### Procédure de déploiement complète (Phase 1) +### Procédure de déploiement complète ``` 1. Tester localement (npm run test — tous les tests verts) 2. Rejouer le Golden Dataset -3. Commit + push sur Codeberg (branche main) -4. Déployer le backend : render deploy (ou dashboard Render) +3. Commit + push sur GitHub (branche main) +4. Backend : auto-deploy Render déclenché automatiquement 5. Déployer le frontend : npm run build && npx wrangler pages deploy dist 6. Vérifier les URLs de production (expria.app + api.expria.app) 7. Rejouer le Smoke Test (Groupe Z du Golden Dataset) ``` -### Évolution Phase 2 — VPS Hetzner + Coolify - -Quand Expria génère ses premiers revenus, migrer vers : - -``` -Codeberg (Git privé — inchangé) - ↓ auto-deploy via webhook -Coolify sur VPS Hetzner CAX11 (3,29€/mois) - — remplace Render pour le backend - — auto-deploy natif depuis Codeberg - — Docker, SSL automatique, logs intégrés - ↓ -Supabase (inchangé) -``` - -Avantages de la Phase 2 : -- Auto-deploy restauré (push → déploiement automatique) -- Coût réduit (3,29€/mois vs Render Starter) -- Aucune dépendance à une plateforme américaine pour le backend -- Cloudflare Pages reste pour le frontend (gratuit, CDN mondial) - --- ## 10. Règles de développement diff --git a/package-lock.json b/package-lock.json index 6c7d701..fde260d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^1.13.7", + "@hono/node-ws": "^1.3.0", "@supabase/supabase-js": "^2.49.4", "dotenv": "^17.4.2", "hono": "^4.7.7", - "stripe": "^17.7.0" + "stripe": "^17.7.0", + "ws": "^8.20.0" }, "devDependencies": { "@types/node": "^22.15.3", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.1.2", "tsx": "^4.19.3", "typescript": "^5.8.3", @@ -550,6 +553,22 @@ "hono": "^4" } }, + "node_modules/@hono/node-ws": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz", + "integrity": "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==", + "license": "MIT", + "dependencies": { + "ws": "^8.17.0" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "@hono/node-server": "^1.19.2", + "hono": "^4.6.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index aecc4d7..0e64c8b 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ }, "dependencies": { "@hono/node-server": "^1.13.7", + "@hono/node-ws": "^1.3.0", "@supabase/supabase-js": "^2.49.4", "dotenv": "^17.4.2", "hono": "^4.7.7", - "stripe": "^17.7.0" + "stripe": "^17.7.0", + "ws": "^8.20.0" }, "devDependencies": { "@types/node": "^22.15.3", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.1.2", "tsx": "^4.19.3", "typescript": "^5.8.3", diff --git a/src/index.ts b/src/index.ts index 1e05f0a..7305eaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ import 'dotenv/config' import { Hono } from 'hono' import { serve } from '@hono/node-server' +import { createNodeWebSocket } from '@hono/node-ws' import authRoutes from './routes/auth.js' import plansRoutes from './routes/plans.js' import simulationsRoutes from './routes/simulations.js' import correctionsRoutes from './routes/corrections.js' import stripeRoutes from './routes/stripe.js' +import createT2LiveRoutes from './routes/t2live.js' const app = new Hono() +const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }) app.get('/', (c) => { return c.json({ message: 'Expria API — OK' }, 200) @@ -18,9 +21,12 @@ app.route('/plans', plansRoutes) app.route('/simulations', simulationsRoutes) app.route('/corrections', correctionsRoutes) app.route('/stripe', stripeRoutes) +app.route('/t2', createT2LiveRoutes(upgradeWebSocket)) const port = Number(process.env.PORT) || 3000 -serve({ fetch: app.fetch, port }, () => { +const server = serve({ fetch: app.fetch, port }, () => { console.log(`Expria API listening on port ${port}`) }) + +injectWebSocket(server) diff --git a/src/lib/__tests__/geminiLive.test.ts b/src/lib/__tests__/geminiLive.test.ts new file mode 100644 index 0000000..2091bbb --- /dev/null +++ b/src/lib/__tests__/geminiLive.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { + openGeminiLiveSession, + T2_SYSTEM_PROMPT, + type WebSocketLike, +} from '../geminiLive' + +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 + } +} + +describe('openGeminiLiveSession', () => { + let originalKey: string | undefined + + beforeEach(() => { + originalKey = process.env.GEMINI_API_KEY + process.env.GEMINI_API_KEY = 'test-key' + }) + + afterEach(() => { + if (originalKey === undefined) { + delete process.env.GEMINI_API_KEY + } else { + process.env.GEMINI_API_KEY = originalKey + } + vi.restoreAllMocks() + }) + + it("envoie le setup frame avec T2_SYSTEM_PROMPT a l'open Gemini", () => { + const client = new FakeWs() + const gemini = new FakeWs() + + openGeminiLiveSession(client, { 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.system_instruction.parts[0].text).toBe(T2_SYSTEM_PROMPT) + expect(setup.setup.generation_config.response_modalities).toContain('AUDIO') + }) + + it('forwarde un message client (Buffer audio) vers Gemini', () => { + const client = new FakeWs() + const gemini = new FakeWs() + openGeminiLiveSession(client, { geminiFactory: () => gemini }) + gemini.emit('open') + + 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) + }) + + it('forwarde un message Gemini vers le client', () => { + const client = new FakeWs() + const gemini = new FakeWs() + openGeminiLiveSession(client, { geminiFactory: () => gemini }) + gemini.emit('open') + + const examinerAudio = Buffer.from([0x10, 0x20]) + gemini.emit('message', examinerAudio) + + expect(client.sent).toHaveLength(1) + expect(client.sent[0]).toBe(examinerAudio) + }) + + it('fermeture client → ferme Gemini avec code 1000', () => { + const client = new FakeWs() + const gemini = new FakeWs() + openGeminiLiveSession(client, { geminiFactory: () => gemini }) + gemini.emit('open') + + client.emit('close') + + expect(gemini.closed).toBe(true) + expect(gemini.closeCode).toBe(1000) + }) + + it('fermeture Gemini → ferme client avec code 1000', () => { + const client = new FakeWs() + const gemini = new FakeWs() + openGeminiLiveSession(client, { geminiFactory: () => gemini }) + gemini.emit('open') + + gemini.emit('close') + + expect(client.closed).toBe(true) + expect(client.closeCode).toBe(1000) + }) + + 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') + + gemini.emit('error', new Error('boom')) + + expect(client.closed).toBe(true) + expect(client.closeCode).toBe(1011) + expect(client.closeReason).toBe('GEMINI_ERROR') + }) + + 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()) + + openGeminiLiveSession(client, { geminiFactory: factory }) + + expect(factory).not.toHaveBeenCalled() + expect(client.closed).toBe(true) + expect(client.closeCode).toBe(1011) + expect(client.closeReason).toBe('CONFIG_ERROR') + }) +}) diff --git a/src/lib/geminiLive.ts b/src/lib/geminiLive.ts new file mode 100644 index 0000000..415c348 --- /dev/null +++ b/src/lib/geminiLive.ts @@ -0,0 +1,140 @@ +import { WebSocket as NodeWebSocket } from 'ws' + +export const T2_SYSTEM_PROMPT = `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif). + +RÔLE : Tu incarnes agent immobilier. +CONTEXTE : Le candidat cherche un appartement à louer. + +RÈGLES ABSOLUES : + +1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1. +2. Tu NE corriges JAMAIS les erreurs du candidat. +3. Tu attends que le candidat finisse sa question avant de répondre. +4. Tes réponses sont courtes (15 à 25 mots maximum). +5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises. +6. Si le candidat est vague, réponds de façon évasive pour le pousser à reformuler. +7. Si le candidat reste silencieux, attends. Ne pose JAMAIS de question spontanée après tes réponses. C'est au candidat d'agir. +8. En dernier recours uniquement (silence prolongé) : "Vous avez d'autres questions ?" +9. Ne prends jamais d'initiatives : réponds uniquement aux questions posées. +10. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste. +11. JAMAIS de listes ni de structure numérotée dans tes réponses. +12. Ne mentionne jamais que tu es une IA. + +Commence l'exercice en te présentant brièvement dans ton rôle (1 phrase courte), +puis attends que le candidat prenne l'initiative.` + +export const GEMINI_LIVE_URL = + 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent' + +export const GEMINI_LIVE_MODEL = 'models/gemini-2.0-flash-exp' + +/** + * 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: () => void): void + on(event: 'error', listener: (err: unknown) => void): void + on(event: 'open', listener: () => void): void +} + +export interface OpenGeminiLiveSessionOptions { + /** Injection pour les tests — fabrique de WebSocket vers Gemini. */ + geminiFactory?: (url: string) => WebSocketLike + /** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */ + apiKey?: string +} + +function buildSetupFrame(): string { + return JSON.stringify({ + setup: { + model: GEMINI_LIVE_MODEL, + system_instruction: { + parts: [{ text: T2_SYSTEM_PROMPT }], + }, + generation_config: { + response_modalities: ['AUDIO'], + }, + }, + }) +} + +/** + * Ouvre une session Gemini Live et proxifie les messages + * dans les deux sens entre le client (navigateur) et Gemini. + * + * - À l'open Gemini : envoie le setup frame (modèle + system_instruction). + * - Forward transparent des frames audio dans les deux directions. + * - Fermeture coordonnée : close d'un côté → close de l'autre. + * - Erreur Gemini → close client avec code 1011. + * - Si GEMINI_API_KEY est absente : close client immédiat avec 1011. + */ +export function openGeminiLiveSession( + clientWs: WebSocketLike, + opts: OpenGeminiLiveSessionOptions = {} +): void { + const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY + + if (!apiKey) { + clientWs.close(1011, 'CONFIG_ERROR') + return + } + + const url = `${GEMINI_LIVE_URL}?key=${apiKey}` + const factory = + opts.geminiFactory ?? + ((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike) + + const geminiWs = factory(url) + + let closed = false + const closeBoth = (code = 1000, reason = '') => { + if (closed) return + closed = true + try { + clientWs.close(code, reason) + } catch { + /* ignore */ + } + try { + geminiWs.close(code, reason) + } catch { + /* ignore */ + } + } + + geminiWs.on('open', () => { + try { + geminiWs.send(buildSetupFrame()) + } catch { + closeBoth(1011, 'SETUP_FAILED') + } + }) + + geminiWs.on('message', (data) => { + try { + clientWs.send(data) + } catch { + closeBoth(1011, 'CLIENT_SEND_FAILED') + } + }) + + clientWs.on('message', (data) => { + try { + geminiWs.send(data) + } catch { + closeBoth(1011, 'GEMINI_SEND_FAILED') + } + }) + + geminiWs.on('close', () => closeBoth(1000)) + clientWs.on('close', () => closeBoth(1000)) + + geminiWs.on('error', () => closeBoth(1011, 'GEMINI_ERROR')) + clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR')) +} diff --git a/src/routes/t2live.ts b/src/routes/t2live.ts new file mode 100644 index 0000000..6579674 --- /dev/null +++ b/src/routes/t2live.ts @@ -0,0 +1,100 @@ +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 { + openGeminiLiveSession, + type WebSocketLike, +} from '../lib/geminiLive.js' + +/** + * Crée le router pour `WS /t2/live`. + * - Auth : JWT Supabase passé en query param `?token=` + * - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess + * - Refus auth → close 4001, refus plan → close 4003 + * - OK → openGeminiLiveSession (proxy vers Gemini Live) + */ +export default function createT2LiveRoutes( + upgradeWebSocket: UpgradeWebSocket +) { + const app = new Hono() + + app.get( + '/live', + upgradeWebSocket(async (c) => { + const token = c.req.query('token') + let denyCode: number | null = null + let denyReason = '' + + if (!token) { + denyCode = 4001 + denyReason = 'AUTH_REQUIRED' + } 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' + } + } + } catch { + denyCode = 4001 + denyReason = 'AUTH_REQUIRED' + } + } + + // Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession + const adapter = new EventEmitter() as EventEmitter & WebSocketLike + adapter.send = () => {} + adapter.close = () => {} + + return { + onOpen(_evt, ws) { + adapter.send = (data: unknown) => + ws.send(data as Parameters[0]) + adapter.close = (code?: number, reason?: string) => + ws.close(code, reason) + + if (denyCode !== null) { + ws.close(denyCode, denyReason) + return + } + + openGeminiLiveSession(adapter) + }, + onMessage(evt) { + adapter.emit('message', evt.data) + }, + onClose() { + adapter.emit('close') + }, + onError() { + adapter.emit('error', new Error('CLIENT_ERROR')) + }, + } + }) + ) + + return app +}